/** * Build content/strings.json - the single source of truth for every translatable * string the theme renders, across both destinations: * * target "metaobject" a field on a Shopify metaobject (marketing copy; the * translator can also edit it in Translate & Adapt, and * it survives a future theme replacing this one) * target "locale" a key in locales/*.json (UI chrome and screen-reader * labels, tied to this theme's markup, lives in git) * * English is scraped from wherever the theme currently holds it. Japanese is * seeded from locales/ja.json and from the human translations still archived in * the Builder page, so the file starts populated rather than blank. * * Run: node scripts/build-strings.mjs * Safe: reads the repo, writes only content/strings.json, touches no network, * and merges rather than overwriting Japanese already in the file. */ import fs from 'node:fs'; import path from 'node:path'; const ROOT = process.cwd(); const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8'); const jsonc = (s) => JSON.parse(s.replace(/^/, '').replace(/\/\*[\s\S]*?\*\//g, '')); /* Rx / lens-insert fields are owned by a separate workstream. Named here so the file documents WHY they are absent instead of looking like an oversight. */ const OWNED_ELSEWHERE = /^rx_/; const norm = (s) => s.replace(/\s+/g, ' ').trim(); const unent = (s) => s .replace(/’/g, '’').replace(/&/g, '&').replace(/©/g, '©') .replace(/·/g, '·').replace(/×/g, '×').replace(/°/g, '°') .replace(/ /g, ' ').replace(/"/g, '"'); /* house rule: no em or en dashes in customer-facing copy */ const clean = (s) => norm(unent(s).replace(/\s*—\s*/g, ', ').replace(/\s*–\s*/g, '-')); /* ---- English already stored as section settings in the product template ---- */ const tpl = jsonc(read('templates/product.shop-bs2.json')); const settingVal = new Map(); for (const sec of Object.values(tpl.sections ?? {})) { for (const [k, v] of Object.entries(sec.settings ?? {})) { if (typeof v === 'string') settingVal.set(sec.type + '::' + k, v); } } /* ---- every metaobject field the theme reads ------------------------------- */ const sectionFiles = fs.readdirSync(path.join(ROOT, 'sections')) .filter((f) => f.endsWith('.liquid') && !f.includes('builder')).sort() .map((f) => 'sections/' + f); /* snippets too. rx_insert_form has 35 references and every one of them is in snippets/lensadvizor.liquid, so a sections-only scan reported the whole prescription form as absent from the store's copy. */ const snippetFiles = fs.readdirSync(path.join(ROOT, 'snippets')) .filter((f) => f.endsWith('.liquid') && !f.includes('builder') && !f.includes('OG')).sort() .map((f) => 'snippets/' + f); const scanFiles = sectionFiles.concat(snippetFiles); const meta = new Map(); /* id is type.handle.field. The handle used to be implicit ("live"), which worked only because every type the theme read had exactly one entry. The marquee is 11 entries and addon_type is 6, so the handle has to be part of the key or those rows collide. */ function put(type, field, en, where, handle) { const h = handle || 'live'; const id = type + '.' + h + '.' + field; if (!meta.has(id)) meta.set(id, { type, handle: h, field, en: en || '', where }); else if (!meta.get(id).en && en) meta.get(id).en = en; } for (const f of scanFiles) { const src = read(f); const sect = path.basename(f).slice(0, -7); for (const m of src.matchAll(/shop\.metaobjects\.([a-z_]+)\.live\.([a-zA-Z0-9_]+)\s*\|\s*default:\s*'([^']*)'/g)) { put(m[1], m[2], m[3], sect); } /* Sections that read many fields alias the entry first ({% assign m = shop.metaobjects..live %}) and then use m.. Resolve that alias per file rather than assuming product_misc: the story band moved to its own type when product_misc hit Shopify's 40-field cap, and a hardcoded type here silently routed all 34 of its fields at the wrong record. */ const alias = src.match(/assign\s+m\s*=\s*shop\.metaobjects\.([a-z_]+)\.live/); const aliasType = alias ? alias[1] : 'product_misc'; for (const m of src.matchAll(/assign\s+\w+\s*=\s*m\.([a-zA-Z0-9_]+)\s*\|\s*default:\s*section\.settings\.([a-zA-Z0-9_]+)(?:\s*\|\s*default:\s*'([^']*)')?/g)) { put(aliasType, m[1], settingVal.get(sect + '::' + m[2]) ?? m[3] ?? '', sect); } for (const m of src.matchAll(/shop\.metaobjects\.([a-z_]+)\.live\.([a-zA-Z0-9_]+)/g)) { put(m[1], m[2], '', sect); } } /* ---- every bigscreen.* locale key the theme uses -------------------------- */ const en = jsonc(read('locales/en.default.json')); const ja = jsonc(read('locales/ja.json')); const dig = (o, k) => k.split('.').reduce((a, p) => (a == null ? a : a[p]), o); const localeKeys = new Set(); const scanned = scanFiles.concat(['templates/product.shop-bs2.json']); for (const f of scanned) { for (const m of read(f).matchAll(/'(bigscreen\.[a-z0-9_.]+)'\s*\|\s*t/g)) localeKeys.add(m[1]); } /* ---- human Japanese still archived in the Builder page -------------------- */ const jaFromBuilder = new Map(); try { const seq = []; const walk = (o) => { if (Array.isArray(o)) return o.forEach(walk); if (o && typeof o === 'object') { const t = o.component && o.component.options ? o.component.options.text : undefined; const txt = t && typeof t === 'object' ? t.Default : t; if (typeof txt === 'string' && norm(txt.replace(/<[^>]+>/g, ' '))) { const b = JSON.stringify(o.bindings ?? {}); const gate = b.includes('locale') ? (b.includes('==') ? 'ja' : b.includes('!=') ? 'en' : null) : null; seq.push({ gate, text: clean(txt.replace(/<[^>]+>/g, ' ')) }); } /* descend into component too: Builder nests child elements inside it, so skipping that key stops the walk at the first component and finds nothing below it */ for (const v of Object.values(o)) walk(v); } }; walk(JSON.parse(read('builder-source/store-shop-bs-2.json'))); const hasJa = (s) => /[぀-ヿ一-鿿]/.test(s); for (let i = 0; i < seq.length - 1; i++) { const a = seq[i], b = seq[i + 1]; if (a.gate === 'en' && b.gate === 'ja' && !hasJa(a.text) && hasJa(b.text)) { jaFromBuilder.set(a.text.toLowerCase(), b.text); } } } catch (e) { /* archive absent - seeds stay empty, nothing else changes */ } /* ---- assemble ------------------------------------------------------------- */ const WORD = { fov: 'FOV', oled: 'OLED', ipd: 'IPD', usa: 'USA', vat: 'VAT', gst: 'GST', ppd: 'PPD', msg: 'message', desc: 'description', btn: 'button', lede: 'intro', head: 'heading', sub: 'subtitle' }; const PREFIX = [['story_', 'Story'], ['faq_', 'FAQ'], ['in_action_', 'In action'], ['discord_', 'Discord'], ['nav_', 'Nav'], ['shop_', 'Shop menu'], ['marquee_', 'Marquee'], ['tax_', 'Tax']]; function label(key) { let head = '', rest = key; for (const [p, n] of PREFIX) { if (key.startsWith(p) && key.length > p.length) { head = n; rest = key.slice(p.length); break; } } const body = rest.split('_').filter(Boolean).map((w) => WORD[w] ?? w).join(' '); const cap = body.charAt(0).toUpperCase() + body.slice(1); return head ? head + ': ' + cap : cap; } const strings = {}; let seeded = 0, skipped = 0; for (const [id, v] of [...meta.entries()].sort()) { if (OWNED_ELSEWHERE.test(v.field)) { skipped++; continue; } const enText = clean(v.en); const seed = jaFromBuilder.get(enText.toLowerCase()) || ''; if (seed) seeded++; strings[id] = { target: 'metaobject', metaobject: v.type, handle: v.handle, field: v.field, name: label(v.field), type: enText.length > 90 ? 'multi_line_text_field' : 'single_line_text_field', where: v.where, en: enText, ja: seed, }; if (seed) { strings[id].ja_source = 'builder-archive'; strings[id].needs_review = true; } } for (const key of [...localeKeys].sort()) { const e = dig(en, key), j = dig(ja, key); const pack = (x) => (x && typeof x === 'object' ? x : { _: x ?? '' }); const ev = pack(e), jv = pack(j); for (const form of Object.keys(ev)) { const id = 'locale.' + key + (form === '_' ? '' : '.' + form); strings[id] = { target: 'locale', key: key, en: ev[form] ?? '', ja: jv[form] ?? '' }; if (form !== '_') strings[id].plural = form; if (jv[form]) strings[id].ja_source = 'authored'; } } const out = { _readme: [ 'Single source of truth for every translatable string the theme renders.', '', 'target "metaobject" -> a field on a Shopify metaobject. Marketing copy.', ' The translator can also edit these in Translate & Adapt, and they survive', ' a future theme replacing this one.', 'target "locale" -> a key in locales/*.json. UI chrome and screen-reader', ' labels, tied to this theme markup, versioned in git.', '', 'ja_source "builder-archive" + needs_review: the Japanese was recovered from', ' the live Builder page. The rebuild rewrote some of the English, so those', ' rows need a human pass before they are trusted.', '', 'Rx / lens-insert fields are deliberately absent - owned by a separate', ' workstream. See OWNED_ELSEWHERE in scripts/build-strings.mjs.', '', 'Regenerate with: node scripts/build-strings.mjs', ' It MERGES: Japanese already in this file is never overwritten.', ], locales: ['en', 'ja'], strings: strings, }; /* merge - never clobber a translation already sitting in the file */ const dest = 'content/strings.json'; if (fs.existsSync(path.join(ROOT, dest))) { const prev = JSON.parse(read(dest)); for (const [id, row] of Object.entries(prev.strings ?? {})) { /* Rows the pull DISCOVERED on the store (marquee entries, addon_type, the emails) are not referenced by name anywhere in the theme, so a rescan can never regenerate them. Without this carry-over, every rebuild silently dropped all 136 of them - which is exactly how copy goes missing without anyone noticing. */ if (!out.strings[id] && row.from_store) { out.strings[id] = row; continue; } if (!out.strings[id]) continue; /* Some fields have no English in the theme at all - it lives only in Shopify, and sync-strings.mjs --pull fetched it. Regenerating must not throw that away, or every rebuild silently un-does the pull. */ /* Keep English pulled from Shopify (it is authoritative - the theme's `default:` only renders when the field is empty) and any value the owner edited deliberately. Rescanning the theme must not quietly revert either back to the literal. */ if (row.en && (row.en_source === 'owner' || !out.strings[id].en || row.en !== out.strings[id].en)) { out.strings[id].en = row.en; if (row.en_source) out.strings[id].en_source = row.en_source; } if (row.ja) { out.strings[id].ja = row.ja; if (row.ja_source) out.strings[id].ja_source = row.ja_source; if (row.needs_review) out.strings[id].needs_review = row.needs_review; else delete out.strings[id].needs_review; } /* An empty Japanese can mean "not done yet" or "correctly empty" - a before/after pair where only one half applies in this language. Without the flag the two are indistinguishable, and the translator gets asked to invent a prefix Japanese does not use. */ if (row.ja_intentionally_blank) out.strings[id].ja_intentionally_blank = true; } } fs.mkdirSync(path.join(ROOT, 'content'), { recursive: true }); fs.writeFileSync(path.join(ROOT, dest), JSON.stringify(out, null, 2) + '\n', 'utf8'); const rows = Object.values(out.strings); const mo = rows.filter((r) => r.target === 'metaobject').length; console.log('wrote ' + dest); console.log(' ' + rows.length + ' strings (' + mo + ' metaobject, ' + (rows.length - mo) + ' locale)'); console.log(' japanese present: ' + rows.filter((r) => r.ja).length + ' missing: ' + rows.filter((r) => !r.ja).length); console.log(' seeded from builder archive: ' + seeded + ' rx fields skipped: ' + skipped);