#!/usr/bin/env node /** * Reconcile content/strings.json against the live store's metaobjects. * * node scripts/sync-strings.mjs --check * Read-only. Reports, per metaobject: which fields the theme reads but * Shopify does not have, which exist but are empty, and which have no * Japanese. Also reports fields that exist in Shopify but nothing in the * theme references any more. * * node scripts/sync-strings.mjs --pull * Read-only against Shopify; writes content/strings.json. Fills English * and Japanese from the store for fields we have no source text for. * Never overwrites a value already in the file. * * node scripts/sync-strings.mjs --plan * Writes reviewable .graphql + vars into .shopify/plans/ and prints a * summary. Executes NOTHING. Per SHOPIFY-API.md the human reads the * mutation, then runs it via: * node scripts/shopify-api.mjs .shopify/plans/.graphql \ * --vars-file .shopify/plans/.vars.json --write * * This never deletes a field. Deleting a metaobject field destroys its content * and every translation of it with no undo, so orphans are only ever reported. * * All Shopify access goes through scripts/shopify-api.mjs so its auth, its * fail-closed write gate, and the write protocol in SHOPIFY-API.md all apply. */ import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; const ROOT = process.cwd(); const SRC = path.join(ROOT, 'content/strings.json'); const PLANS = path.join(ROOT, '.shopify/plans'); const RUNNER = path.join(ROOT, 'scripts/shopify-api.mjs'); const LOCALE = 'ja'; const mode = process.argv.find((a) => ['--check', '--pull', '--plan'].includes(a)); if (!mode) { console.error('usage: node scripts/sync-strings.mjs --check | --pull | --plan'); process.exit(1); } /* ---- boundary: another workstream owns the Lenses / Optics data model ------- The prescription card is driven by addon_group / addon_type metaobjects and addon.* variant metafields, plus the product_misc.rx_* strings. None of it is ours. build-strings.mjs already leaves rx_* out of content/strings.json, but that is an omission and omissions rot; this is the enforcement. If a field ever slips through, we refuse rather than quietly plan a mutation against someone else's data on a live store. */ const NOT_OURS = { types: new Set(['addon_group', 'addon_type']), fields: /^rx_/, }; const doc = JSON.parse(fs.readFileSync(SRC, 'utf8')); const all = Object.entries(doc.strings).filter(([, r]) => r.target === 'metaobject'); const isTheirs = ([, r]) => NOT_OURS.types.has(r.metaobject) || NOT_OURS.fields.test(r.field); /* Being in the sheet and being writable are different things. Their copy is listed so the translator can translate it - it is customer-facing text and leaving it out is how it stayed untranslated - but this tool never writes to it. Rows the pull discovered are tagged owned_elsewhere; anything of theirs that appears WITHOUT that tag was hand-added and is refused, because it means someone is about to plan a mutation against another workstream's data. */ const trespass = all.filter((e) => isTheirs(e) && !e[1].owned_elsewhere); if (trespass.length) { console.error('refusing to run: content/strings.json contains fields owned by the'); console.error('Lenses / Optics workstream, not tagged owned_elsewhere:'); for (const [id] of trespass) console.error(' ' + id); process.exit(1); } /* writable set - what --plan is allowed to touch */ const rows = all.filter((e) => !isTheirs(e)); const types = [...new Set(rows.map(([, r]) => r.metaobject))].sort(); /* ---- talk to Shopify through the shared runner ---------------------------- */ function gql(query, vars) { const args = [RUNNER, '--query', query]; if (vars) args.push('--vars', JSON.stringify(vars)); let out; try { out = execFileSync(process.execPath, args, { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 }); } catch (e) { /* Print what the runner actually said. An earlier version guessed ".env is probably missing", which sent us hunting credentials while the real fault was a bad field name in the query. */ const detail = [e.stdout, e.stderr, e.message] .map((x) => (x ?? '').toString().trim()).filter(Boolean).join('\n'); console.error('\nShopify read failed:\n'); console.error(detail.slice(0, 2000)); console.error('\n(missing credentials? see SHOPIFY-API.md)'); process.exit(2); } /* the runner prints the JSON response; tolerate leading log lines */ const start = out.indexOf('{'); if (start < 0) { console.error('unexpected runner output:\n' + out.slice(0, 400)); process.exit(2); } return JSON.parse(out.slice(start)); } const Q_DEF = `query($type: String!) { metaobjectDefinitionByType(type: $type) { id name fieldDefinitions { key name type { name } } } }`; /* One entry per type, handle "live". */ const Q_ENTRY = `query($handle: MetaobjectHandleInput!) { metaobjectByHandle(handle: $handle) { id handle fields { key value } } }`; /* Translations do NOT hang off Metaobject - they live on translatableResource, which also hands back the per-field digest that translationsRegister demands. So this one query answers both "what Japanese exists already" and "what do I need to write it later". */ const Q_TRANSLATABLE = `query($id: ID!, $locale: String!) { translatableResource(resourceId: $id) { translatableContent { key value digest } translations(locale: $locale) { key value } } }`; function readStore() { const store = {}; for (const type of types) { const def = gql(Q_DEF, { type }).data?.metaobjectDefinitionByType; const entry = gql(Q_ENTRY, { handle: { type, handle: 'live' } }).data?.metaobjectByHandle; const tr = entry ? gql(Q_TRANSLATABLE, { id: entry.id, locale: LOCALE }).data?.translatableResource : null; store[type] = { id: def?.id ?? null, exists: !!def, fields: new Map((def?.fieldDefinitions ?? []).map((f) => [f.key, f])), values: new Map((entry?.fields ?? []).map((f) => [f.key, f.value ?? ''])), ja: new Map((tr?.translations ?? []).map((t) => [t.key, t.value ?? ''])), digest: new Map((tr?.translatableContent ?? []).map((c) => [c.key, c.digest])), entryId: entry?.id ?? null, }; } return store; } /* ---- is a field key referenced ANYWHERE in the repo? ----------------------- content/strings.json is not the authority for this. It deliberately omits the Lenses / Optics fields, and it only ever scanned sections/, so a field used from a template (store_disclaimer, in a custom-liquid block) or from a Builder section would look unreferenced. Calling a live field an orphan invites someone to delete it, and deleting one destroys its translations with no undo - so the orphan check greps the whole theme for the literal key instead. */ const HAYSTACK = (() => { const dirs = ['sections', 'snippets', 'templates', 'layout', 'assets', 'config']; let text = ''; for (const d of dirs) { const dir = path.join(ROOT, d); if (!fs.existsSync(dir)) continue; for (const f of fs.readdirSync(dir)) { const p = path.join(dir, f); if (!fs.statSync(p).isFile()) continue; if (!/\.(liquid|json|js|css)$/.test(f)) continue; text += fs.readFileSync(p, 'utf8'); } } return text; })(); const usedAnywhere = (key) => new RegExp(`\\b${key.replace(/[^\w]/g, '\\$&')}\\b`).test(HAYSTACK); /* ---- modes ---------------------------------------------------------------- */ const store = readStore(); if (mode === '--check') { let totalMissing = 0, totalEmpty = 0, totalNoJa = 0; for (const type of types) { const s = store[type]; const mine = rows.filter(([, r]) => r.metaobject === type); if (!s.exists) { console.log(`\n${type}: DEFINITION DOES NOT EXIST`); continue; } const missing = mine.filter(([, r]) => !s.fields.has(r.field)); const empty = mine.filter(([, r]) => s.fields.has(r.field) && !s.values.get(r.field)); const noJa = mine.filter(([, r]) => s.fields.has(r.field) && s.values.get(r.field) && !s.ja.get(r.field)); const orphan = [...s.fields.keys()].filter((k) => !usedAnywhere(k)); totalMissing += missing.length; totalEmpty += empty.length; totalNoJa += noJa.length; console.log(`\n${type} (${s.fields.size} fields in Shopify, theme reads ${mine.length})`); if (missing.length) console.log(` MISSING in Shopify (${missing.length}): ` + missing.map(([, r]) => r.field).join(', ')); if (empty.length) console.log(` exists but empty (${empty.length}): ` + empty.map(([, r]) => r.field).join(', ')); if (noJa.length) console.log(` no ${LOCALE} translation (${noJa.length}): ` + noJa.map(([, r]) => r.field).join(', ')); if (orphan.length) console.log(` in Shopify, unreferenced by the theme (${orphan.length}): ` + orphan.join(', ') + '\n (reported only - never auto-deleted)'); if (!missing.length && !empty.length && !noJa.length) console.log(' all good'); } console.log(`\ntotal: ${totalMissing} fields to create, ${totalEmpty} empty, ${totalNoJa} untranslated`); } if (mode === '--pull') { /* Everything else on the store, not just what the theme reads by name. Two shapes were invisible before: types used only from snippets (rx_insert_form), and types that are many ENTRIES rather than one record with many fields - the marquee ticker is 11 entries, addon_type is 6. Those carry live customer-facing copy and none of it reached the sheet. */ const SKIP = new Set(['notification_debug', 'milestones_refresh']); const defs = gql(`{ metaobjectDefinitions(first:60){ nodes { type } } }`) .data?.metaobjectDefinitions?.nodes ?? []; let added = 0; for (const { type } of defs) { if (SKIP.has(type) || type.startsWith('shopify--')) continue; if (NOT_OURS.types.has(type)) { /* still listed, never written - see below */ } const nodes = gql(`query($t:String!){ metaobjects(type:$t, first:60){ nodes { id handle } } }`, { t: type }).data?.metaobjects?.nodes ?? []; for (const n of nodes) { const tr = gql(Q_TRANSLATABLE, { id: n.id, locale: LOCALE }).data?.translatableResource; if (!tr) continue; const ja = new Map((tr.translations ?? []).map((t) => [t.key, t.value ?? ''])); for (const c of tr.translatableContent ?? []) { if (!c.value) continue; const id = `${type}.${n.handle}.${c.key}`; if (doc.strings[id]) continue; doc.strings[id] = { target: 'metaobject', metaobject: type, handle: n.handle, field: c.key, name: c.key.split('_').map((w) => w[0].toUpperCase() + w.slice(1)).join(' '), type: c.value.length > 90 ? 'multi_line_text_field' : 'single_line_text_field', where: `${type}/${n.handle}`, en: c.value, ja: ja.get(c.key) || '', ...(ja.get(c.key) ? { ja_source: 'shopify' } : {}), /* the theme does not reference this by name; it is reached through a metafield or a list, so build-strings cannot rediscover it */ from_store: true, ...((NOT_OURS.types.has(type) || NOT_OURS.fields.test(c.key)) ? { owned_elsewhere: true } : {}), }; added++; } } } if (added) console.log(`discovered ${added} strings that live only in the store`); let gotEn = 0, gotJa = 0; for (const [id, r] of Object.entries(doc.strings).filter(([, x]) => x.target === 'metaobject')) { const s = store[r.metaobject]; if (!s?.exists || r.handle !== 'live') continue; const en = s.values.get(r.field); const ja = s.ja.get(r.field); /* Shopify is authoritative for English, not the theme's `default:` literal. The fallback only renders when the field is EMPTY, so where both exist and disagree, the store is what customers actually see - Shopify had "Added ✓" and "Sold Out" where the fallbacks said "Added" and "Sold out", and taking the fallback would have quietly proposed reverting live copy. The one exception is a value the owner edited on purpose in the spreadsheet, which is flagged en_source: owner and left alone. */ if (en && doc.strings[id].en_source !== 'owner' && doc.strings[id].en !== en) { doc.strings[id].en = en; gotEn++; } if (!r.ja && ja) { doc.strings[id].ja = ja; doc.strings[id].ja_source = 'shopify'; delete doc.strings[id].needs_review; gotJa++; } } fs.writeFileSync(SRC, JSON.stringify(doc, null, 2) + '\n', 'utf8'); console.log(`pulled from Shopify: ${gotEn} English, ${gotJa} Japanese`); console.log(`still missing english: ${Object.values(doc.strings).filter((r) => !r.en).length}`); console.log(`still missing japanese: ${Object.values(doc.strings).filter((r) => !r.ja).length}`); } if (mode === '--plan') { fs.mkdirSync(PLANS, { recursive: true }); const written = []; for (const type of types) { const s = store[type]; const mine = rows.filter(([, r]) => r.metaobject === type); /* 0. the type itself does not exist yet. Settings are copied from the definitions already on this store rather than invented: storefront PUBLIC_READ or Liquid cannot read it at all, and translatable enabled or the fields never appear in Translate & Adapt - which is the entire point of moving copy here. */ if (!s.exists) { const withEn = mine.filter(([, r]) => r.en); const file = path.join(PLANS, `${type}.0-create-definition`); fs.writeFileSync(file + '.graphql', `mutation($definition: MetaobjectDefinitionCreateInput!) {\n` + ` metaobjectDefinitionCreate(definition: $definition) {\n` + ` metaobjectDefinition { id type fieldDefinitions { key } }\n` + ` userErrors { field message code }\n }\n}\n`, 'utf8'); fs.writeFileSync(file + '.vars.json', JSON.stringify({ definition: { type, name: type.split('_').map((w) => w[0].toUpperCase() + w.slice(1)).join(' '), /* storefront only. `admin` is rejected outright on a normal type ("can only be specified on metaobject definitions that have an app-reserved type") and defaults to merchant-editable anyway. PUBLIC_READ is not optional: without it Liquid cannot read the entry and every field renders blank. */ access: { storefront: 'PUBLIC_READ' }, capabilities: { publishable: { enabled: true }, translatable: { enabled: true } }, fieldDefinitions: withEn.map(([, r]) => ({ key: r.field, name: r.name, type: r.type })), }, }, null, 2), 'utf8'); written.push([`${type}: CREATE the definition with ${withEn.length} fields`, file]); /* and its single "live" entry, carrying the English. ACTIVE because the definition is publishable and a draft entry renders as blank. */ const entry = path.join(PLANS, `${type}.0b-create-entry`); fs.writeFileSync(entry + '.graphql', `mutation($metaobject: MetaobjectCreateInput!) {\n` + ` metaobjectCreate(metaobject: $metaobject) {\n` + ` metaobject { id handle }\n userErrors { field message code }\n }\n}\n`, 'utf8'); fs.writeFileSync(entry + '.vars.json', JSON.stringify({ metaobject: { type, handle: 'live', capabilities: { publishable: { status: 'ACTIVE' } }, fields: withEn.map(([, r]) => ({ key: r.field, value: r.en })), }, }, null, 2), 'utf8'); written.push([`${type}: create the live entry with ${withEn.length} values`, entry]); continue; } /* 1. create missing field definitions */ const create = mine.filter(([, r]) => !s.fields.has(r.field) && r.en); if (create.length) { const file = path.join(PLANS, `${type}.1-create-fields`); fs.writeFileSync(file + '.graphql', `mutation($id: ID!, $definition: MetaobjectDefinitionUpdateInput!) {\n` + ` metaobjectDefinitionUpdate(id: $id, definition: $definition) {\n` + ` metaobjectDefinition { id fieldDefinitions { key } }\n` + ` userErrors { field message code }\n }\n}\n`, 'utf8'); fs.writeFileSync(file + '.vars.json', JSON.stringify({ id: s.id, definition: { fieldDefinitions: create.map(([, r]) => ({ create: { key: r.field, name: r.name, type: r.type }, })), }, }, null, 2), 'utf8'); written.push([`${type}: create ${create.length} fields`, file]); } /* 2. write English into the entry. MUST be `fields`, never `values`. `fields` merges - keys not listed keep their values. `values` is a full replacement and would clear every field we did not mention, wiping the store's existing copy and its Japanese. The two inputs sit next to each other in the schema and read alike. Belt and braces: we only target fields that are currently empty anyway. */ const setEn = mine.filter(([, r]) => r.en && !s.values.get(r.field)); if (setEn.length && s.entryId) { const file = path.join(PLANS, `${type}.2-set-english`); fs.writeFileSync(file + '.graphql', `mutation($id: ID!, $metaobject: MetaobjectUpdateInput!) {\n` + ` metaobjectUpdate(id: $id, metaobject: $metaobject) {\n` + ` metaobject { id }\n userErrors { field message code }\n }\n}\n`, 'utf8'); fs.writeFileSync(file + '.vars.json', JSON.stringify({ id: s.entryId, metaobject: { fields: setEn.map(([, r]) => ({ key: r.field, value: r.en })) }, }, null, 2), 'utf8'); written.push([`${type}: set English on ${setEn.length} fields`, file]); } /* 2b. English that CHANGED. Kept apart from 2 on purpose: step 2 fills blanks and cannot lose anything, this one overwrites copy that is live on the store, so it deserves its own read before it is applied. Editing the English also invalidates the stored Japanese - Shopify ties a translation to a digest of its source - so anything in here needs its translation re-registered afterwards. */ const changeEn = mine.filter(([, r]) => { const cur = s.values.get(r.field); return r.en && cur && cur !== r.en; }); if (changeEn.length && s.entryId) { const file = path.join(PLANS, `${type}.2b-CHANGE-english`); fs.writeFileSync(file + '.graphql', `mutation($id: ID!, $metaobject: MetaobjectUpdateInput!) {\n` + ` metaobjectUpdate(id: $id, metaobject: $metaobject) {\n` + ` metaobject { id }\n userErrors { field message code }\n }\n}\n`, 'utf8'); fs.writeFileSync(file + '.vars.json', JSON.stringify({ id: s.entryId, metaobject: { fields: changeEn.map(([, r]) => ({ key: r.field, value: r.en })) }, }, null, 2), 'utf8'); fs.writeFileSync(file + '.diff.txt', changeEn.map(([id, r]) => `${id}\n was: ${s.values.get(r.field)}\n now: ${r.en}\n`).join('\n'), 'utf8'); written.push([`${type}: CHANGE English on ${changeEn.length} fields (read the .diff.txt)`, file]); } } /* 3. Japanese, via translationsRegister. Grouped per ENTRY, not per type - the marquee lines live on eleven entries, not on a `live` record. Only fields whose store-side Japanese is MISSING are registered: a field already translated in Shopify is never re-registered, so nothing a human entered in Translate & Adapt can be overwritten from here. The digest is read live per entry; if a field has no digest yet its English has not been saved, and it is reported rather than guessed at. */ const byEntry = new Map(); /* Translations include the other workstream's rows - Max's call (2026-08-27), now that the Lenses / Optics data model is settled. Registering a ja layer changes none of their structure, values or definitions; those writes remain restricted to our own types above. */ for (const [id, r] of all) { if (!r.ja) continue; const key = `${r.metaobject}${r.handle || 'live'}`; if (!byEntry.has(key)) byEntry.set(key, []); byEntry.get(key).push([id, r]); } let jaPlanned = 0, noDigest = []; for (const [key, items] of byEntry) { const [type, handle] = key.split(''); const entry = gql(Q_ENTRY, { handle: { type, handle } }).data?.metaobjectByHandle; if (!entry) { noDigest.push(`${type}.${handle} (entry not found)`); continue; } const tr = gql(Q_TRANSLATABLE, { id: entry.id, locale: LOCALE }).data?.translatableResource; const haveJa = new Set((tr?.translations ?? []).filter((t) => t.value).map((t) => t.key)); const digest = new Map((tr?.translatableContent ?? []).map((c) => [c.key, c.digest])); const todo = []; for (const [, r] of items) { if (haveJa.has(r.field)) continue; // human work in Shopify wins if (!digest.get(r.field)) { noDigest.push(`${type}.${handle}.${r.field}`); continue; } todo.push({ locale: LOCALE, key: r.field, value: r.ja, translatableContentDigest: digest.get(r.field) }); } if (!todo.length) continue; const file = path.join(PLANS, `${type}.${handle}.3-set-japanese`); fs.writeFileSync(file + '.graphql', `mutation($id: ID!, $translations: [TranslationInput!]!) {\n` + ` translationsRegister(resourceId: $id, translations: $translations) {\n` + ` translations { key locale }\n userErrors { field message code }\n }\n}\n`, 'utf8'); fs.writeFileSync(file + '.vars.json', JSON.stringify( { id: entry.id, translations: todo }, null, 2), 'utf8'); written.push([`${type}/${handle}: register ${todo.length} Japanese translations`, file]); jaPlanned += todo.length; } /* 3b. variant metafields (the option-card texts). Each is its own translatable resource, so one file registers them all via aliased mutations. Missing-only, digest-pinned, same as the metaobjects. */ const mfRows = Object.entries(doc.strings) .filter(([, r]) => r.target === 'metafield' && r.ja && r.gid); if (mfRows.length) { const QMF = `query($ids:[ID!]!){ translatableResourcesByIds(first:25, resourceIds:$ids){ nodes { resourceId translatableContent { key digest } translations(locale:"${LOCALE}"){ key value } } } }`; const state = new Map(); for (let i = 0; i < mfRows.length; i += 25) { const ids = mfRows.slice(i, i + 25).map(([, r]) => r.gid); for (const n of gql(QMF, { ids }).data?.translatableResourcesByIds?.nodes ?? []) { state.set(n.resourceId, { digest: n.translatableContent?.[0]?.digest, ja: (n.translations ?? []).some((t) => t.value), }); } } const todo = mfRows.filter(([, r]) => { const s2 = state.get(r.gid); return s2 && s2.digest && !s2.ja; }); if (todo.length) { const parts = [], vars = {}; todo.forEach(([, r], i) => { parts.push(` t${i}: translationsRegister(resourceId: $id${i}, translations: $tr${i}) {\n` + ` translations { key locale }\n userErrors { field message code }\n }`); vars[`id${i}`] = r.gid; vars[`tr${i}`] = [{ locale: LOCALE, key: 'value', value: r.ja, translatableContentDigest: state.get(r.gid).digest }]; }); const sig = todo.map((_, i) => `$id${i}: ID!, $tr${i}: [TranslationInput!]!`).join(', '); const file = path.join(PLANS, 'variant-metafields.3-set-japanese'); fs.writeFileSync(file + '.graphql', `mutation(${sig}) {\n${parts.join('\n')}\n}\n`, 'utf8'); fs.writeFileSync(file + '.vars.json', JSON.stringify(vars, null, 2), 'utf8'); written.push([`variant metafields: register ${todo.length} Japanese translations`, file]); jaPlanned += todo.length; } } if (jaPlanned) written.push([`TOTAL Japanese to register: ${jaPlanned}`, null]); if (noDigest.length) { written.push([`no digest / entry for ${noDigest.length} (English not saved yet): ` + noDigest.slice(0, 6).join(', '), null]); } console.log('\nplanned (nothing executed):'); for (const [what, file] of written) { console.log(' ' + what); if (file) console.log(' ' + path.relative(ROOT, file) + '.graphql + .vars.json'); } console.log('\nReview each file, then apply one at a time:'); console.log(' node scripts/shopify-api.mjs .graphql --vars-file .vars.json --write'); }