← blog Read in English
IT 7 min read

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.

shopifymonorepoliquidecommercetooling

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:

  1. Fa theme:pull dall’ambiente di sviluppo su Shopify
  2. Esegue theme:check (linting Liquid)
  3. Su PR: deploya su un tema di sviluppo e posta il link di preview come commento
  4. 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.