Shopify theme monorepo: condividere Liquid tra più store
Come ho strutturato un monorepo per gestire più Shopify theme con snippet condivisi, senza duplicare codice e senza strumenti terzi — solo Node.js, uno script bash e il file TOML di configurazione.
Liquid non ha un sistema di import. Non esiste {% include 'package:snippet' %} — ogni file che usi in un tema Shopify deve stare nella cartella snippets/ di quel tema. Quando gestisci più store con lo stesso team, questo porta inevitabilmente a duplicazione: stessa logica copiata in 8 posti, aggiornata in 3.
Il problema che ho risolto: condividere snippet, sezioni e asset Liquid tra più Shopify theme distinti, mantenendo la possibilità di override per store specifici, senza terze parti e senza complicazioni infrastrutturali.
La struttura del monorepo
Il repository contiene due tipi di workspace:
monorepo/
├── packages/
│ ├── theme/ ← sorgente condivisa (snippet, sections, assets)
│ └── scripts/ ← libreria Node.js per gli script di automazione
├── apps/
│ ├── brand-a/d2c/ ← tema Shopify del brand A
│ ├── brand-b/d2c/ ← tema Shopify del brand B
│ └── ... ← N brand
└── shopify.theme.toml ← mappa tutti gli ambienti Shopify
packages/theme/ è la sorgente canonica: gli snippet che tutti i brand usano vivono lì. Ogni apps/brand-x/d2c/ è un tema Shopify completo — quello che il CLI di Shopify conosce e deploya. La sync da packages/theme/ verso ogni app directory avviene tramite script.
shopify.theme.toml come unica fonte di verità
Il Shopify CLI usa un file TOML per mappare ambienti a store e percorsi. La struttura è:
[environments.brand-a-d2c]
theme = "139383570680"
store = "brand-a-production-store"
path = "apps/brand-a/d2c"
[environments.brand-b-d2c]
theme = "159628362054"
store = "brand-b-production"
path = "apps/brand-b/d2c"
Ho scelto di usare questo file anche come configurazione degli script custom — invece di mantenere una lista separata di store in un JSON o env var. Quando aggiungi un brand, lo aggiungi una volta nel TOML e tutto il tooling lo vede automaticamente.
Il parser TOML è fatto con @iarna/toml:
const getStoresConfig = async () => {
const data = await readFile(join(cwd, 'shopify.theme.toml'), { encoding: 'utf-8' });
const { environments = {} } = toml.parse(data) || {};
let envs = Object.entries(environments);
if (!envs.length) {
throw new Error(`No environments found in shopify.theme.toml`);
}
const environment = processArgv.e || processArgv.environment;
if (environment) {
envs = envs.filter(([env]) => env === environment);
}
return envs.map(([env, config]) => ({
environment: env,
...config
}));
};
Il flag --environment permette di filtrare per un singolo brand quando serve lavorare su uno specifico. Senza flag, restituisce tutti gli ambienti.
Lo script di sync
syncThemes.js copia i file da packages/theme/ verso ogni app directory:
const syncThemes = async () => {
const storesConfig = await getStoresConfig();
const cwd = process.cwd();
const baseThemeFiles = join(cwd, 'packages', 'theme', '**', '*');
const up = baseThemeFiles.split('/').filter(Boolean).length - 1;
for (const { store, path: outDir = '' } of storesConfig) {
if (!store || !outDir) {
logger.error(`Missing store name or path for store: "${store}"`);
continue;
}
logger.info(`Syncing theme for store: "${store}"`);
copyfiles(
[baseThemeFiles, outDir],
{ up, exclude: ['**/package.json', '.theme-check.yml'] },
(error) => {
if (error) throw error;
}
);
}
};
copyfiles è un pacchetto Node.js che gestisce il glob e la struttura directory. Il parametro up tronca il prefisso del percorso sorgente: senza di esso, la copia manterrebbe packages/theme/snippets/foo.liquid invece di snippets/foo.liquid dentro la directory di destinazione.
Il comando diventa semplicemente pnpm theme:sync e propaga tutti i file condivisi in tutti gli store in una volta.
Pull dalle Shopify preview e commit automatico
Il flusso inverso — sincronizzare le modifiche fatte tramite l’editor di tema di Shopify nel repo — è gestito dallo script updateRepoThemes.js più uno script bash:
#!/bin/bash
source "${0%/*}/require-clean-work-tree.sh"
require_clean_work_tree # abortisce se ci sono modifiche non committate
ENVIRONMENTS=("$@")
export HUSKY=0
git fetch && git checkout main && git pull
for ENVIRONMENT in "${ENVIRONMENTS[@]}"; do
MATCH=$(pnpm theme:info -e $ENVIRONMENT | grep "Development Theme ID Not set")
if [[ -z "$MATCH" ]]; then
pnpm theme:pull -e $ENVIRONMENT
git add .
git commit -m "chore($ENVIRONMENT): theme update" --no-verify
else
echo "Environment $ENVIRONMENT not found"
fi
done
git push origin main
unset HUSKY
Punti chiave:
require-clean-work-tree— verifica che non ci siano modifiche locali prima di partire. Evita di perdere lavoro non committato durante il pull.HUSKY=0— disabilita i pre-commit hook per i commit automatici generati dallo script. Non ha senso eseguire lint su file pullati da Shopify.theme:info | grep "Development Theme ID"— controlla che l’environment non sia un tema di sviluppo attivo prima di pullare. Evita di sovrascrivere il tema sbagliato.- Il commit è automatico con messaggio convenzionale (
chore($ENVIRONMENT): theme update), così finisce nel changelog generato da release-please.
Lo script Node che lo invoca legge gli environment dal TOML e li passa come argomenti al bash:
const environments = storesConfig.map(({ environment }) => environment);
const args = ['bin/update-repo-themes.sh', ...environments].filter(Boolean);
const childProcess = spawn('bash', args, { shell: true, stdio: 'inherit' });
Override per brand specifici
La copia da packages/theme/ non cancella i file già presenti nel brand directory — copyfiles sovrascrive solo i file che esistono nella sorgente. Ogni brand può quindi avere file aggiuntivi nella propria snippets/ che non fanno parte del tema condiviso.
Lo stesso vale per le sezioni e i template: il tema condiviso fornisce la base, ogni brand aggiunge o modifica in modo indipendente. Dopo ogni theme:sync, le modifiche locali del brand rimangono intatte perché non esistono nella sorgente condivisa.
Se invece vuoi che un brand usi una versione diversa di uno snippet condiviso, la soluzione è semplicemente non copiare quel file — l’esclusione è gestibile aggiungendo il pattern a exclude in syncThemes.js.
La libreria di snippet condivisi
In packages/theme/snippets/ vivono i componenti riusabili tra tutti i brand. Alcuni esempi che illustrano il pattern:
create-img-sizes.liquid — genera l’attributo sizes per immagini responsive da una configurazione dichiarativa:
{%- render 'create-img-sizes', sizes: '1536=calc(25vw - 32px), 768=64px, 0=100vw, 1024=256px' -%}
Prende la stringa, la split per breakpoint, ordina in modo decrescente e genera la sequenza di media queries corretta. Invece di scrivere (min-width: 1536px) calc(25vw - 32px), (min-width: 1024px) 256px, 100vw a mano in ogni template, l’API è la stringa di configurazione.
content-with-widgets.liquid — interpreta un markup custom embedded nell’HTML degli articoli:
[widget]type=product-card[&]handle=product-1[&]size=lg[/widget]
Il snippet fa il parse del contenuto, estrae tipo e parametri di ogni widget, e delega il rendering a widget-serializer. Permette ai redattori di iniettare componenti dinamici negli articoli senza toccare il codice — utile quando il CMS è l’editor Shopify e non hai un headless CMS con blocchi strutturati.
Pipeline CI/CD
Ogni brand ha il suo workflow GitHub Actions. Il workflow riusabile:
- Fa
theme:pulldall’ambiente di sviluppo su Shopify - Esegue
theme:check(linting Liquid) - Su PR: deploya su un tema di sviluppo e posta il link di preview come commento
- Su tag di versione: deploya sul tema live
La parte interessante è che ogni PR che tocca sia i file condivisi (packages/theme/) che i file di un brand specifico trigghera solo il workflow di quel brand. I path filter in GitHub Actions gestiscono questo: ogni workflow ha paths che include packages/theme/** oltre ai file specifici dell’app.
Cosa ha funzionato, cosa no
Il TOML come unica fonte di verità ha funzionato bene. Aggiungere un brand è una riga nel TOML, non una modifica a tre file diversi. Tutti gli script, comprese le pipeline CI, leggono da lì.
copyfiles è sufficiente per questo use case. Non serve rsync o strumenti di sync più sofisticati — la struttura delle directory dei temi Shopify è piatta e prevedibile.
Lo script bash di update-repo è fragile sugli errori parziali. Se theme:pull fallisce su uno store nel mezzo del loop, il ciclo continua e il commit finale su main include i pull riusciti. Un approccio più robusto registrerebbe i fallimenti e skipperebbe il push fino a risolverli.
Override per brand: funziona ma non scala bene. Con molti snippet divergenti tra brand, il diff tra packages/theme/ e l’app di un brand diventa difficile da tenere sotto controllo. Abbiamo gestito questo con disciplina: le variazioni significative diventano parametri del componente condiviso, non copie separate.