From 0bb1e6bb8a95303d71758ce9bf5e18267d8425ec Mon Sep 17 00:00:00 2001 From: Chris Berthe Date: Thu, 23 Jan 2025 18:43:46 -0500 Subject: [PATCH 01/53] feat: add locale command --- src/commands/theme/locale/clean.ts | 40 +++++++++++ src/utilities/object.ts | 34 ++++++++++ src/utilities/translations.ts | 104 +++++++++++++++++++++++++++++ 3 files changed, 178 insertions(+) create mode 100644 src/commands/theme/locale/clean.ts create mode 100644 src/utilities/object.ts create mode 100644 src/utilities/translations.ts diff --git a/src/commands/theme/locale/clean.ts b/src/commands/theme/locale/clean.ts new file mode 100644 index 00000000..e6784427 --- /dev/null +++ b/src/commands/theme/locale/clean.ts @@ -0,0 +1,40 @@ +/** + * This command cleans up locale files in a theme directory. + * + * - Scans theme files for translation keys + * - Removes unused translations from locale files + */ + +import path from 'node:path' + +import Args from '../../../utilities/args.js' +import BaseCommand from '../../../utilities/base-command.js' +import Flags from '../../../utilities/flags.js' +import { cleanSchemaTranslations, cleanStorefrontTranslations } from '../../../utilities/translations.js' + +export default class Clean extends BaseCommand { + static override args = Args.getDefinitions([ + Args.override(Args.THEME_DIR, { default: '.', required: false }) + ]) + + static override description = 'Clean theme locale files' + + static override examples = [ + '<%= config.bin %> <%= command.id %> theme-directory' + ] + + protected override async init(): Promise { + await super.init(Clean) + } + + public async run(): Promise { + const themeDir = path.resolve(process.cwd(), this.args[Args.THEME_DIR]) + + cleanStorefrontTranslations(themeDir) + cleanSchemaTranslations(themeDir) + + if (!this.flags[Flags.QUIET]) { + this.log('Successfully cleaned translations from locale files') + } + } +} diff --git a/src/utilities/object.ts b/src/utilities/object.ts new file mode 100644 index 00000000..7db73c35 --- /dev/null +++ b/src/utilities/object.ts @@ -0,0 +1,34 @@ +export function flattenObject(obj: Record, prefix = ''): Record { + const result: Record = {} + + for (const [key, value] of Object.entries(obj)) { + const newKey = prefix ? `${prefix}.${key}` : key + + if (typeof value === 'object' && value !== null) { + Object.assign(result, flattenObject(value as Record, newKey)) + } else { + result[newKey] = value + } + } + + return result +} + +export function unflattenObject(obj: Record): Record { + const result: Record = {} + + for (const [key, value] of Object.entries(obj)) { + const parts = key.split('.') + let current = result + + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i] + current[part] = current[part] || {} + current = current[part] as Record + } + + current[parts.at(-1)!] = value + } + + return result +} diff --git a/src/utilities/translations.ts b/src/utilities/translations.ts new file mode 100644 index 00000000..7bcbe186 --- /dev/null +++ b/src/utilities/translations.ts @@ -0,0 +1,104 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { flattenObject, unflattenObject } from './object.js' + +const SCHEMA_DIRS = ['config', 'blocks', 'sections'] as const +const LIQUID_DIRS = ['blocks', 'layout', 'sections', 'snippets', 'templates'] as const + +function findSchemaKeys(content: string): Set { + const keys = new Set() + const matches = content.match(/"t:([^"]+)"/g) || [] + + for (const match of matches) { + const key = match.match(/"t:([^"]+)"/)![1] + keys.add(key) + } + + return keys +} + +function findStorefrontKeys(content: string): Set { + const keys = new Set() + const patterns = [ + /{{\s*-?\s*["']([^"']+)["']\s*\|\s*t[^}]*-?\s*}}/g, + /{%\s*(?:assign|capture)\s+\w+\s*=\s*["']([^"']+)["']\s*\|\s*t[^%]*%}/g, + /(?:^|\s)["']([^"']+)["']\s*\|\s*t[^\n}]*/gm + ] + + for (const pattern of patterns) { + const matches = content.match(pattern) || [] + for (const match of matches) { + const key = match.match(/["']([^"']+)["']/)![1] + keys.add(key) + } + } + + return keys +} + +function cleanLocaleFile(filePath: string, usedKeys: Set): void { + try { + const content = JSON.parse(fs.readFileSync(filePath, 'utf8')) + if (!content || typeof content !== 'object') return + + const flattenedContent = flattenObject(content) + const cleanedContent: Record = {} + + for (const [key, value] of Object.entries(flattenedContent)) { + const basePath = key.split('.').slice(0, -1).join('.') + if (usedKeys.has(key) || usedKeys.has(basePath)) { + cleanedContent[key] = value + } + } + + const unflattened = unflattenObject(cleanedContent) + fs.writeFileSync(filePath, JSON.stringify(unflattened, null, 2)) + } catch (error) { + throw new Error(`Error processing ${path.basename(filePath)}: ${error}`) + } +} + +function scanFiles(themeDir: string, dirs: readonly string[], findKeys: (content: string) => Set): Set { + const usedKeys = new Set() + + for (const dir of dirs) { + const dirPath = path.join(themeDir, dir) + if (!fs.existsSync(dirPath)) continue + + const files = fs.readdirSync(dirPath) + .filter(file => file.endsWith('.liquid') || file.endsWith('.json')) + + for (const file of files) { + const content = fs.readFileSync(path.join(dirPath, file), 'utf8') + const keys = findKeys(content) + for (const key of keys) { + usedKeys.add(key) + } + } + } + + return usedKeys +} + +export function cleanSchemaTranslations(themeDir: string): void { + const usedKeys = scanFiles(themeDir, SCHEMA_DIRS, findSchemaKeys) + const localesDir = path.join(themeDir, 'locales') + const schemaFiles = fs.readdirSync(localesDir) + .filter(file => file.endsWith('.schema.json')) + + for (const file of schemaFiles) { + cleanLocaleFile(path.join(localesDir, file), usedKeys) + } +} + +export function cleanStorefrontTranslations(themeDir: string): void { + const usedKeys = scanFiles(themeDir, LIQUID_DIRS, findStorefrontKeys) + const localesDir = path.join(themeDir, 'locales') + const localeFiles = fs.readdirSync(localesDir) + .filter(file => file.endsWith('.json') && !file.endsWith('.schema.json')) + + for (const file of localeFiles) { + cleanLocaleFile(path.join(localesDir, file), usedKeys) + } +} From 591abc8340663a1165cb6c667f4e3bb8c00b8102 Mon Sep 17 00:00:00 2001 From: Chris Berthe Date: Fri, 24 Jan 2025 11:29:58 -0500 Subject: [PATCH 02/53] consolidate object-related utilities --- src/utilities/object.ts | 34 -------------------------------- src/utilities/objects.ts | 37 ++++++++++++++++++++++++++++++++++- src/utilities/translations.ts | 2 +- 3 files changed, 37 insertions(+), 36 deletions(-) diff --git a/src/utilities/object.ts b/src/utilities/object.ts index 7db73c35..e69de29b 100644 --- a/src/utilities/object.ts +++ b/src/utilities/object.ts @@ -1,34 +0,0 @@ -export function flattenObject(obj: Record, prefix = ''): Record { - const result: Record = {} - - for (const [key, value] of Object.entries(obj)) { - const newKey = prefix ? `${prefix}.${key}` : key - - if (typeof value === 'object' && value !== null) { - Object.assign(result, flattenObject(value as Record, newKey)) - } else { - result[newKey] = value - } - } - - return result -} - -export function unflattenObject(obj: Record): Record { - const result: Record = {} - - for (const [key, value] of Object.entries(obj)) { - const parts = key.split('.') - let current = result - - for (let i = 0; i < parts.length - 1; i++) { - const part = parts[i] - current[part] = current[part] || {} - current = current[part] as Record - } - - current[parts.at(-1)!] = value - } - - return result -} diff --git a/src/utilities/objects.ts b/src/utilities/objects.ts index 81005540..ad534966 100644 --- a/src/utilities/objects.ts +++ b/src/utilities/objects.ts @@ -13,4 +13,39 @@ export function deepMerge(target: DeepObject, source: DeepObject): DeepObject { } return target -} \ No newline at end of file +} + +export function flattenObject(obj: Record, prefix = ''): Record { + const result: Record = {} + + for (const [key, value] of Object.entries(obj)) { + const newKey = prefix ? `${prefix}.${key}` : key + + if (typeof value === 'object' && value !== null) { + Object.assign(result, flattenObject(value as Record, newKey)) + } else { + result[newKey] = value + } + } + + return result +} + +export function unflattenObject(obj: Record): Record { + const result: Record = {} + + for (const [key, value] of Object.entries(obj)) { + const parts = key.split('.') + let current = result + + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i] + current[part] = current[part] || {} + current = current[part] as Record + } + + current[parts.at(-1)!] = value + } + + return result +} diff --git a/src/utilities/translations.ts b/src/utilities/translations.ts index 7bcbe186..270121fb 100644 --- a/src/utilities/translations.ts +++ b/src/utilities/translations.ts @@ -1,7 +1,7 @@ import fs from 'node:fs' import path from 'node:path' -import { flattenObject, unflattenObject } from './object.js' +import { flattenObject, unflattenObject } from './objects.js' const SCHEMA_DIRS = ['config', 'blocks', 'sections'] as const const LIQUID_DIRS = ['blocks', 'layout', 'sections', 'snippets', 'templates'] as const From 70bd5c30fea22b93db33230ee20e875f4201dfd5 Mon Sep 17 00:00:00 2001 From: Chris Berthe Date: Sat, 25 Jan 2025 12:05:55 -0500 Subject: [PATCH 03/53] add support for t_with_fallback references --- src/utilities/object.ts | 0 src/utilities/translations.ts | 150 +++++++++++++++++++++++----------- 2 files changed, 103 insertions(+), 47 deletions(-) delete mode 100644 src/utilities/object.ts diff --git a/src/utilities/object.ts b/src/utilities/object.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/utilities/translations.ts b/src/utilities/translations.ts index 270121fb..b6f468fb 100644 --- a/src/utilities/translations.ts +++ b/src/utilities/translations.ts @@ -6,6 +6,50 @@ import { flattenObject, unflattenObject } from './objects.js' const SCHEMA_DIRS = ['config', 'blocks', 'sections'] as const const LIQUID_DIRS = ['blocks', 'layout', 'sections', 'snippets', 'templates'] as const +export function cleanSchemaTranslations(themeDir: string): void { + const usedKeys = scanFiles(themeDir, SCHEMA_DIRS, findSchemaKeys) + const localesDir = path.join(themeDir, 'locales') + const schemaFiles = fs.readdirSync(localesDir) + .filter(file => file.endsWith('.schema.json')) + + for (const file of schemaFiles) { + cleanLocaleFile(path.join(localesDir, file), usedKeys) + } +} + +export function cleanStorefrontTranslations(themeDir: string): void { + const usedKeys = scanFiles(themeDir, LIQUID_DIRS, findStorefrontKeys) + const localesDir = path.join(themeDir, 'locales') + const localeFiles = fs.readdirSync(localesDir) + .filter(file => file.endsWith('.json') && !file.endsWith('.schema.json')) + + for (const file of localeFiles) { + cleanLocaleFile(path.join(localesDir, file), usedKeys) + } +} + +function scanFiles(themeDir: string, dirs: readonly string[], findKeys: (content: string) => Set): Set { + const usedKeys = new Set() + + for (const dir of dirs) { + const dirPath = path.join(themeDir, dir) + if (!fs.existsSync(dirPath)) continue + + const files = fs.readdirSync(dirPath) + .filter(file => file.endsWith('.liquid') || file.endsWith('.json')) + + for (const file of files) { + const content = fs.readFileSync(path.join(dirPath, file), 'utf8') + const keys = findKeys(content) + for (const key of keys) { + usedKeys.add(key) + } + } + } + + return usedKeys +} + function findSchemaKeys(content: string): Set { const keys = new Set() const matches = content.match(/"t:([^"]+)"/g) || [] @@ -20,13 +64,16 @@ function findSchemaKeys(content: string): Set { function findStorefrontKeys(content: string): Set { const keys = new Set() - const patterns = [ + + // Standard liquid translation patterns + const standardPatterns = [ /{{\s*-?\s*["']([^"']+)["']\s*\|\s*t[^}]*-?\s*}}/g, /{%\s*(?:assign|capture)\s+\w+\s*=\s*["']([^"']+)["']\s*\|\s*t[^%]*%}/g, - /(?:^|\s)["']([^"']+)["']\s*\|\s*t[^\n}]*/gm + /(?:^|\s)["']([^"']+)["']\s*\|\s*t[^\n}]*/gm, ] - for (const pattern of patterns) { + // Find standard translations + for (const pattern of standardPatterns) { const matches = content.match(pattern) || [] for (const match of matches) { const key = match.match(/["']([^"']+)["']/)![1] @@ -34,6 +81,59 @@ function findStorefrontKeys(content: string): Set { } } + // Combine with t_with_fallback translations + return new Set([...keys, ...findTWithFallbackKeys(content)]) +} + +function findTWithFallbackKeys(content: string): Set { + // Find translations assigned to variables first + const assignedTranslations = findAssignedTranslations(content) + + // Find both direct keys and variable-based keys + const directKeys = findDirectFallbackKeys(content) + const variableKeys = findVariableFallbackKeys(content, assignedTranslations) + + return new Set([...directKeys, ...variableKeys]) +} + +function findAssignedTranslations(content: string): Map { + const assignments = new Map() + const pattern = /{%-?\s*assign\s+([^\s=]+)\s*=\s*["']([^"']+)["']\s*\|\s*t[^%]*-?%}/g + + const matches = content.matchAll(pattern) + for (const match of matches) { + const [, varName, translationKey] = match + assignments.set(varName, translationKey) + } + + return assignments +} + +function findDirectFallbackKeys(content: string): Set { + const keys = new Set() + const pattern = /render\s+["']t_with_fallback["'][^%]*key:\s*["']([^"']+)["']/g + + const matches = content.matchAll(pattern) + for (const match of matches) { + keys.add(match[1]) + } + + return keys +} + +function findVariableFallbackKeys(content: string, assignedTranslations: Map): Set { + const keys = new Set() + const pattern = /render\s+["']t_with_fallback["'][^%]*t:\s*([^\s,}]+)/g + + const matches = content.matchAll(pattern) + for (const match of matches) { + const varName = match[1] + const translationKey = assignedTranslations.get(varName) + if (translationKey) { + keys.add(translationKey) + } + } + return keys } @@ -58,47 +158,3 @@ function cleanLocaleFile(filePath: string, usedKeys: Set): void { throw new Error(`Error processing ${path.basename(filePath)}: ${error}`) } } - -function scanFiles(themeDir: string, dirs: readonly string[], findKeys: (content: string) => Set): Set { - const usedKeys = new Set() - - for (const dir of dirs) { - const dirPath = path.join(themeDir, dir) - if (!fs.existsSync(dirPath)) continue - - const files = fs.readdirSync(dirPath) - .filter(file => file.endsWith('.liquid') || file.endsWith('.json')) - - for (const file of files) { - const content = fs.readFileSync(path.join(dirPath, file), 'utf8') - const keys = findKeys(content) - for (const key of keys) { - usedKeys.add(key) - } - } - } - - return usedKeys -} - -export function cleanSchemaTranslations(themeDir: string): void { - const usedKeys = scanFiles(themeDir, SCHEMA_DIRS, findSchemaKeys) - const localesDir = path.join(themeDir, 'locales') - const schemaFiles = fs.readdirSync(localesDir) - .filter(file => file.endsWith('.schema.json')) - - for (const file of schemaFiles) { - cleanLocaleFile(path.join(localesDir, file), usedKeys) - } -} - -export function cleanStorefrontTranslations(themeDir: string): void { - const usedKeys = scanFiles(themeDir, LIQUID_DIRS, findStorefrontKeys) - const localesDir = path.join(themeDir, 'locales') - const localeFiles = fs.readdirSync(localesDir) - .filter(file => file.endsWith('.json') && !file.endsWith('.schema.json')) - - for (const file of localeFiles) { - cleanLocaleFile(path.join(localesDir, file), usedKeys) - } -} From 83cb32ec6f5272e7aa33c754b3b24794c90f36a4 Mon Sep 17 00:00:00 2001 From: Chris Berthe Date: Sat, 25 Jan 2025 13:02:27 -0500 Subject: [PATCH 04/53] add flags to locales clean command --- src/commands/theme/locale/clean.ts | 24 +++++++++++++++++++++--- src/utilities/flags.ts | 19 ++++++++++++++++++- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/commands/theme/locale/clean.ts b/src/commands/theme/locale/clean.ts index e6784427..d9df9ddd 100644 --- a/src/commands/theme/locale/clean.ts +++ b/src/commands/theme/locale/clean.ts @@ -20,18 +20,36 @@ export default class Clean extends BaseCommand { static override description = 'Clean theme locale files' static override examples = [ - '<%= config.bin %> <%= command.id %> theme-directory' + '<%= config.bin %> <%= command.id %> theme-directory', + '<%= config.bin %> <%= command.id %> theme-directory --no-schema-locales', + '<%= config.bin %> <%= command.id %> theme-directory --no-storefront-locales' ] + static override flags = Flags.getDefinitions([ + Flags.SCHEMA_LOCALES, + Flags.STOREFRONT_LOCALES + ]) + protected override async init(): Promise { await super.init(Clean) } public async run(): Promise { const themeDir = path.resolve(process.cwd(), this.args[Args.THEME_DIR]) + const schemaLocales = this.flags[Flags.SCHEMA_LOCALES] + const storefrontLocales = this.flags[Flags.STOREFRONT_LOCALES] + + if (!schemaLocales && !storefrontLocales) { + this.error('Cannot disable cleaning of both schema and storefront locales. Remove either --no-schema-locales or --no-storefront-locales flag') + } - cleanStorefrontTranslations(themeDir) - cleanSchemaTranslations(themeDir) + if (storefrontLocales) { + cleanStorefrontTranslations(themeDir) + } + + if (schemaLocales) { + cleanSchemaTranslations(themeDir) + } if (!this.flags[Flags.QUIET]) { this.log('Successfully cleaned translations from locale files') diff --git a/src/utilities/flags.ts b/src/utilities/flags.ts index bf5d7d13..813e2a09 100644 --- a/src/utilities/flags.ts +++ b/src/utilities/flags.ts @@ -20,13 +20,15 @@ export default class Flags { static readonly PORT = 'port'; static readonly PREVIEW = 'preview'; static readonly QUIET = 'quiet'; + static readonly SCHEMA_LOCALES = 'schema-locales'; static readonly SETUP_FILES = 'setup-files'; static readonly STORE = 'store'; static readonly STORE_PASSWORD = 'store-password'; + static readonly STOREFRONT_LOCALES = 'storefront-locales'; static readonly THEME = 'theme'; static readonly THEME_DIR = 'theme-dir'; static readonly WATCH = 'watch'; - + private flagValues: Record>; constructor(flags: Record>) { this.flagValues = flags @@ -119,6 +121,13 @@ export const flagDefinitions: Record = { description: 'suppress non-essential output' }), + [Flags.SCHEMA_LOCALES]: OclifFlags.boolean({ + allowNo: true, + char: 's', + default: true, + description: 'Clean translations from schema locale files' + }), + [Flags.SETUP_FILES]: OclifFlags.boolean({ allowNo: true, char: 's', @@ -134,6 +143,13 @@ export const flagDefinitions: Record = { description: 'The password for storefronts with password protection.', }), + [Flags.STOREFRONT_LOCALES]: OclifFlags.boolean({ + allowNo: true, + char: 'f', + default: true, + description: 'Clean translations from storefront locale files' + }), + [Flags.THEME]: OclifFlags.string({ description: 'Theme ID or name of the remote theme.', }), @@ -150,4 +166,5 @@ export const flagDefinitions: Record = { default: true, description: 'watch for changes in theme and component directories', }), + } From 781df7b6c4f419eb199fb1e1913e169b4e71a5c1 Mon Sep 17 00:00:00 2001 From: Chris Berthe Date: Sat, 25 Jan 2025 16:11:15 -0500 Subject: [PATCH 05/53] get sync command working --- src/commands/theme/locale/clean.ts | 9 +-- src/commands/theme/locale/sync.ts | 98 +++++++++++++++++++++++++ src/utilities/flags.ts | 22 +++++- src/utilities/locale-sync.ts | 114 +++++++++++++++++++++++++++++ src/utilities/objects.ts | 14 ++++ src/utilities/translations.ts | 45 ++++++++++++ 6 files changed, 294 insertions(+), 8 deletions(-) create mode 100644 src/commands/theme/locale/sync.ts create mode 100644 src/utilities/locale-sync.ts diff --git a/src/commands/theme/locale/clean.ts b/src/commands/theme/locale/clean.ts index d9df9ddd..5ded6b17 100644 --- a/src/commands/theme/locale/clean.ts +++ b/src/commands/theme/locale/clean.ts @@ -43,13 +43,8 @@ export default class Clean extends BaseCommand { this.error('Cannot disable cleaning of both schema and storefront locales. Remove either --no-schema-locales or --no-storefront-locales flag') } - if (storefrontLocales) { - cleanStorefrontTranslations(themeDir) - } - - if (schemaLocales) { - cleanSchemaTranslations(themeDir) - } + schemaLocales && cleanSchemaTranslations(themeDir) + storefrontLocales && cleanStorefrontTranslations(themeDir) if (!this.flags[Flags.QUIET]) { this.log('Successfully cleaned translations from locale files') diff --git a/src/commands/theme/locale/sync.ts b/src/commands/theme/locale/sync.ts new file mode 100644 index 00000000..4e1f7ee4 --- /dev/null +++ b/src/commands/theme/locale/sync.ts @@ -0,0 +1,98 @@ +/** + * This command syncs locale files in a theme directory with a source of translations. + * + * - Fetches translations from source (remote or local) + * - Updates theme locale files with source content + * - Preserves file structure and formatting + */ + +import path from 'node:path' + +import Args from '../../../utilities/args.js' +import BaseCommand from '../../../utilities/base-command.js' +import Flags from '../../../utilities/flags.js' +import { fetchLocaleSource, syncLocales } from '../../../utilities/locale-sync.js' +import { extractRequiredTranslations, getThemeTranslations } from '../../../utilities/translations.js' +import Clean from './clean.js' + +export default class Sync extends BaseCommand { + static override args = Args.getDefinitions([ + Args.override(Args.THEME_DIR, { default: '.', required: false }) + ]) + + static override description = 'Sync theme locale files with source translations' + + static override examples = [ + '<%= config.bin %> <%= command.id %> theme-directory', + '<%= config.bin %> <%= command.id %> theme-directory path/to/locales', + '<%= config.bin %> <%= command.id %> theme-directory --overwrite-locales', + '<%= config.bin %> <%= command.id %> theme-directory --preserve-locales' + ] + + static override flags = Flags.getDefinitions([ + Flags.CLEAN, + Flags.LOCALES_DIR, + Flags.OVERWRITE_LOCALES, + Flags.PRESERVE_LOCALES, + Flags.SCHEMA_LOCALES, + Flags.STOREFRONT_LOCALES + ]) + + + public async run(): Promise { + const themeDir = path.resolve(process.cwd(), this.args[Args.THEME_DIR]) + const localesDir = this.flags[Flags.LOCALES_DIR] + + if (this.flags[Flags.CLEAN]) { + await Clean.run([themeDir, ...this.getCleanFlags()]) + } + + try { + // 1. Get all translations used in theme + this.log(`Scanning theme directory: ${themeDir}`) + const requiredTranslations = getThemeTranslations(themeDir) + + if (!this.flags[Flags.QUIET]) { + this.log('Found translations in theme:') + this.log(`- Schema keys: ${requiredTranslations.schema.size}`) + this.log(`- Storefront keys: ${requiredTranslations.storefront.size}`) + } + + // 2. Fetch source locales + this.log(`Fetching locales from: ${localesDir}`) + const sourceLocales = await fetchLocaleSource(localesDir) + const localeFiles = Object.keys(sourceLocales) + + if (!this.flags[Flags.QUIET]) { + this.log(`Found ${localeFiles.length} locale files in source:`) + this.log(`- Schema files: ${localeFiles.filter(f => f.endsWith('.schema.json')).length}`) + this.log(`- Storefront files: ${localeFiles.filter(f => !f.endsWith('.schema.json')).length}`) + } + + // 3. Extract required translations + this.log('Extracting required translations') + const requiredLocales = extractRequiredTranslations(sourceLocales, requiredTranslations) + + // 4. Sync to theme + const syncMode = this.flags[Flags.OVERWRITE_LOCALES] ? 'overwrite' : this.flags[Flags.PRESERVE_LOCALES] ? 'preserve' : 'merge' + this.log(`Syncing translations to theme (mode: ${syncMode})`) + await syncLocales(themeDir, requiredLocales, { + overwrite: this.flags[Flags.OVERWRITE_LOCALES], + preserve: this.flags[Flags.PRESERVE_LOCALES] + }) + + if (!this.flags[Flags.QUIET]) { + this.log('Successfully synced locale files') + } + } catch (error) { + this.error(`Failed to sync locales: ${error}`) + } + } + + private getCleanFlags(): string[] { + return Object.entries(this.flags) + .filter(([key]) => [Flags.SCHEMA_LOCALES, Flags.STOREFRONT_LOCALES].includes(key)) + .map(([key, value]) => value === false ? `--no-${key}` : null) + .filter(Boolean) as string[] + } +} diff --git a/src/utilities/flags.ts b/src/utilities/flags.ts index 813e2a09..1441a953 100644 --- a/src/utilities/flags.ts +++ b/src/utilities/flags.ts @@ -16,8 +16,11 @@ export default class Flags { static readonly IGNORE_CONFLICTS = 'ignore-conflicts'; static readonly IGNORE_OVERRIDES = 'ignore-overrides'; static readonly LIVE_RELOAD = 'live-reload'; + static readonly LOCALES_DIR = 'locales-dir'; + static readonly OVERWRITE_LOCALES = 'overwrite-locales'; static readonly PASSWORD = 'password'; static readonly PORT = 'port'; + static readonly PRESERVE_LOCALES = 'preserve-locales'; static readonly PREVIEW = 'preview'; static readonly QUIET = 'quiet'; static readonly SCHEMA_LOCALES = 'schema-locales'; @@ -99,6 +102,18 @@ export const flagDefinitions: Record = { description: 'Reload the browser when changes are made.', }), + [Flags.LOCALES_DIR]: OclifFlags.string({ + char: 'l', + default: 'https://github.com/archetype-themes/locales', + description: 'Directory or repository containing locale files', + }), + + [Flags.OVERWRITE_LOCALES]: OclifFlags.boolean({ + char: 'w', + default: false, + description: 'Overwrite existing theme locale files with source versions' + }), + [Flags.PASSWORD]: OclifFlags.string({ description: 'Password generated from the Theme Access app.', }), @@ -107,6 +122,12 @@ export const flagDefinitions: Record = { description: 'Local port to serve theme preview from.', }), + [Flags.PRESERVE_LOCALES]: OclifFlags.boolean({ + char: 'p', + default: false, + description: 'Preserve existing theme locale files when conflicts occur' + }), + [Flags.PREVIEW]: OclifFlags.boolean({ allowNo: true, char: 'y', @@ -166,5 +187,4 @@ export const flagDefinitions: Record = { default: true, description: 'watch for changes in theme and component directories', }), - } diff --git a/src/utilities/locale-sync.ts b/src/utilities/locale-sync.ts new file mode 100644 index 00000000..ebacd00d --- /dev/null +++ b/src/utilities/locale-sync.ts @@ -0,0 +1,114 @@ +import fs, { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' + +import { cloneTheme } from './git.js' +import { flattenObject, sortObjectKeys, unflattenObject } from './objects.js' +import { LocaleContent } from './translations.js' + +interface LocaleDiff { + added: Set + modified: Set + removed: Set +} + +export async function fetchLocaleSource(source: string): Promise { + if (isUrl(source)) { + return fetchRemoteLocales(source) + } + + return loadLocalLocales(source) +} + +function isUrl(source: string): boolean { + return source.startsWith('http://') || source.startsWith('https://') +} + +async function fetchRemoteLocales(url: string): Promise { + const tempDir = mkdtempSync(path.join(tmpdir(), 'theme-locales-')) + + try { + await cloneTheme(url, tempDir) + return loadLocalLocales(path.join(tempDir, 'locales')) + } finally { + rmSync(tempDir, { force: true, recursive: true }) + } +} + +function loadLocalLocales(dir: string): LocaleContent { + const content: LocaleContent = {} + + const files = fs.readdirSync(dir) + .filter(file => file.endsWith('.json')) + + for (const file of files) { + const filePath = path.join(dir, file) + content[file] = JSON.parse(fs.readFileSync(filePath, 'utf8')) as Record + } + + return content +} + +export function compareLocales(source: Record, target: Record): LocaleDiff { + const flatSource = flattenObject(source) + const flatTarget = flattenObject(target) + + return { + added: new Set(Object.keys(flatSource).filter(key => !(key in flatTarget))), + modified: new Set(Object.keys(flatSource).filter(key => + key in flatTarget && flatSource[key] !== flatTarget[key] + )), + removed: new Set(Object.keys(flatTarget).filter(key => !(key in flatSource))), + } +} + +export async function syncLocales( + themeDir: string, + sourceLocales: Record>, + options: { overwrite?: boolean, preserve?: boolean } +): Promise { + const localesDir = path.join(themeDir, 'locales') + + for (const [file, content] of Object.entries(sourceLocales)) { + const targetPath = path.join(localesDir, file) + + if (!fs.existsSync(targetPath)) { + // fs.writeFileSync(targetPath, JSON.stringify(sortObjectKeys(content), null, 2)) + fs.writeFileSync(targetPath, JSON.stringify(content, null, 2)) + continue + } + + const targetContent = JSON.parse(fs.readFileSync(targetPath, 'utf8')) + const diff = compareLocales(content, targetContent) + + if (options.preserve) { + // Only add new translations + const merged = {...targetContent} + const flatContent = flattenObject(content) + const flatMerged = flattenObject(merged) + + for (const key of diff.added) { + flatMerged[key] = flatContent[key] + } + + // fs.writeFileSync(targetPath, JSON.stringify(sortObjectKeys(unflattenObject(flatMerged)), null, 2)) + fs.writeFileSync(targetPath, JSON.stringify(unflattenObject(flatMerged), null, 2)) + } else if (options.overwrite) { + // Use source version entirely + // fs.writeFileSync(targetPath, JSON.stringify(sortObjectKeys(content), null, 2)) + fs.writeFileSync(targetPath, JSON.stringify(content, null, 2)) + } else { + // Selective merge based on diffs + const merged = {...targetContent} + const flatContent = flattenObject(content) + const flatMerged = flattenObject(merged) + + for (const key of [...diff.added, ...diff.modified]) { + flatMerged[key] = flatContent[key] + } + + // fs.writeFileSync(targetPath, JSON.stringify(sortObjectKeys(unflattenObject(flatMerged)), null, 2)) + fs.writeFileSync(targetPath, JSON.stringify(unflattenObject(flatMerged), null, 2)) + } + } +} diff --git a/src/utilities/objects.ts b/src/utilities/objects.ts index ad534966..a04802bb 100644 --- a/src/utilities/objects.ts +++ b/src/utilities/objects.ts @@ -49,3 +49,17 @@ export function unflattenObject(obj: Record): Record): Record { + const sorted: Record = {} + const keys = Object.keys(obj).sort() + + for (const key of keys) { + const value = obj[key] + sorted[key] = value && typeof value === 'object' && !Array.isArray(value) + ? sortObjectKeys(value as Record) + : value + } + + return sorted +} diff --git a/src/utilities/translations.ts b/src/utilities/translations.ts index b6f468fb..084a1d23 100644 --- a/src/utilities/translations.ts +++ b/src/utilities/translations.ts @@ -3,9 +3,21 @@ import path from 'node:path' import { flattenObject, unflattenObject } from './objects.js' +// Export interfaces +export interface LocaleContent { + [key: string]: Record +} + +export interface ThemeTranslations { + schema: Set + storefront: Set +} + +// Constants remain internal const SCHEMA_DIRS = ['config', 'blocks', 'sections'] as const const LIQUID_DIRS = ['blocks', 'layout', 'sections', 'snippets', 'templates'] as const +// Export functions export function cleanSchemaTranslations(themeDir: string): void { const usedKeys = scanFiles(themeDir, SCHEMA_DIRS, findSchemaKeys) const localesDir = path.join(themeDir, 'locales') @@ -28,6 +40,39 @@ export function cleanStorefrontTranslations(themeDir: string): void { } } +export function getThemeTranslations(themeDir: string): ThemeTranslations { + return { + schema: scanFiles(themeDir, SCHEMA_DIRS, findSchemaKeys), + storefront: scanFiles(themeDir, LIQUID_DIRS, findStorefrontKeys) + } +} + +export function extractRequiredTranslations( + sourceLocales: Record>, + required: ThemeTranslations +): LocaleContent { + const result: LocaleContent = {} + + for (const [file, content] of Object.entries(sourceLocales)) { + const isSchema = file.endsWith('.schema.json') + const requiredKeys = isSchema ? required.schema : required.storefront + + const flatContent = flattenObject(content) + const filteredContent: Record = {} + + for (const key of requiredKeys) { + if (key in flatContent) { + filteredContent[key] = flatContent[key] + } + } + + result[file] = unflattenObject(filteredContent) + } + + return result +} + +// Helper functions remain internal function scanFiles(themeDir: string, dirs: readonly string[], findKeys: (content: string) => Set): Set { const usedKeys = new Set() From e6c7ebb5b0c081b12d23687ac56096d92b570428 Mon Sep 17 00:00:00 2001 From: Chris Berthe Date: Sat, 25 Jan 2025 19:35:53 -0500 Subject: [PATCH 06/53] chore: update clean command text --- src/commands/theme/locale/clean.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/theme/locale/clean.ts b/src/commands/theme/locale/clean.ts index 5ded6b17..3f782148 100644 --- a/src/commands/theme/locale/clean.ts +++ b/src/commands/theme/locale/clean.ts @@ -17,7 +17,7 @@ export default class Clean extends BaseCommand { Args.override(Args.THEME_DIR, { default: '.', required: false }) ]) - static override description = 'Clean theme locale files' + static override description = 'Remove unused translations from theme locale files' static override examples = [ '<%= config.bin %> <%= command.id %> theme-directory', @@ -47,7 +47,7 @@ export default class Clean extends BaseCommand { storefrontLocales && cleanStorefrontTranslations(themeDir) if (!this.flags[Flags.QUIET]) { - this.log('Successfully cleaned translations from locale files') + this.log('Successfully cleaned locale files') } } } From 7e9d42058415f748441d3b41a2a1dca19d6b1f28 Mon Sep 17 00:00:00 2001 From: Chris Berthe Date: Sat, 25 Jan 2025 20:15:34 -0500 Subject: [PATCH 07/53] refactor: minor changes in translations.ts --- src/utilities/translations.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/utilities/translations.ts b/src/utilities/translations.ts index 084a1d23..a6f06aff 100644 --- a/src/utilities/translations.ts +++ b/src/utilities/translations.ts @@ -3,7 +3,6 @@ import path from 'node:path' import { flattenObject, unflattenObject } from './objects.js' -// Export interfaces export interface LocaleContent { [key: string]: Record } @@ -13,11 +12,9 @@ export interface ThemeTranslations { storefront: Set } -// Constants remain internal const SCHEMA_DIRS = ['config', 'blocks', 'sections'] as const -const LIQUID_DIRS = ['blocks', 'layout', 'sections', 'snippets', 'templates'] as const +const STOREFRONT_DIRS = ['blocks', 'layout', 'sections', 'snippets', 'templates'] as const -// Export functions export function cleanSchemaTranslations(themeDir: string): void { const usedKeys = scanFiles(themeDir, SCHEMA_DIRS, findSchemaKeys) const localesDir = path.join(themeDir, 'locales') @@ -30,7 +27,7 @@ export function cleanSchemaTranslations(themeDir: string): void { } export function cleanStorefrontTranslations(themeDir: string): void { - const usedKeys = scanFiles(themeDir, LIQUID_DIRS, findStorefrontKeys) + const usedKeys = scanFiles(themeDir, STOREFRONT_DIRS, findStorefrontKeys) const localesDir = path.join(themeDir, 'locales') const localeFiles = fs.readdirSync(localesDir) .filter(file => file.endsWith('.json') && !file.endsWith('.schema.json')) @@ -43,7 +40,7 @@ export function cleanStorefrontTranslations(themeDir: string): void { export function getThemeTranslations(themeDir: string): ThemeTranslations { return { schema: scanFiles(themeDir, SCHEMA_DIRS, findSchemaKeys), - storefront: scanFiles(themeDir, LIQUID_DIRS, findStorefrontKeys) + storefront: scanFiles(themeDir, STOREFRONT_DIRS, findStorefrontKeys) } } @@ -72,7 +69,6 @@ export function extractRequiredTranslations( return result } -// Helper functions remain internal function scanFiles(themeDir: string, dirs: readonly string[], findKeys: (content: string) => Set): Set { const usedKeys = new Set() @@ -110,7 +106,7 @@ function findSchemaKeys(content: string): Set { function findStorefrontKeys(content: string): Set { const keys = new Set() - // Standard liquid translation patterns + // Standard Liquid translation patterns const standardPatterns = [ /{{\s*-?\s*["']([^"']+)["']\s*\|\s*t[^}]*-?\s*}}/g, /{%\s*(?:assign|capture)\s+\w+\s*=\s*["']([^"']+)["']\s*\|\s*t[^%]*%}/g, From e7b757aaae9f1fef367d3a818fef6ba7ee55692e Mon Sep 17 00:00:00 2001 From: Chris Berthe Date: Sat, 25 Jan 2025 21:02:00 -0500 Subject: [PATCH 08/53] refactor: sync, locales, and translations files --- src/commands/theme/locale/sync.ts | 105 ++++++++++++--------- src/utilities/locale-sync.ts | 114 ----------------------- src/utilities/locales.ts | 147 ++++++++++++++++++++++++++++++ src/utilities/translations.ts | 55 ++++++----- 4 files changed, 235 insertions(+), 186 deletions(-) delete mode 100644 src/utilities/locale-sync.ts create mode 100644 src/utilities/locales.ts diff --git a/src/commands/theme/locale/sync.ts b/src/commands/theme/locale/sync.ts index 4e1f7ee4..6e62cbb6 100644 --- a/src/commands/theme/locale/sync.ts +++ b/src/commands/theme/locale/sync.ts @@ -11,8 +11,8 @@ import path from 'node:path' import Args from '../../../utilities/args.js' import BaseCommand from '../../../utilities/base-command.js' import Flags from '../../../utilities/flags.js' -import { fetchLocaleSource, syncLocales } from '../../../utilities/locale-sync.js' -import { extractRequiredTranslations, getThemeTranslations } from '../../../utilities/translations.js' +import { LocaleSourceStats, analyzeLocaleFiles, fetchLocaleSource, syncLocales } from '../../../utilities/locales.js' +import { ThemeTranslations, extractRequiredTranslations, getThemeTranslations } from '../../../utilities/translations.js' import Clean from './clean.js' export default class Sync extends BaseCommand { @@ -38,7 +38,6 @@ export default class Sync extends BaseCommand { Flags.STOREFRONT_LOCALES ]) - public async run(): Promise { const themeDir = path.resolve(process.cwd(), this.args[Args.THEME_DIR]) const localesDir = this.flags[Flags.LOCALES_DIR] @@ -47,46 +46,19 @@ export default class Sync extends BaseCommand { await Clean.run([themeDir, ...this.getCleanFlags()]) } - try { - // 1. Get all translations used in theme - this.log(`Scanning theme directory: ${themeDir}`) - const requiredTranslations = getThemeTranslations(themeDir) - - if (!this.flags[Flags.QUIET]) { - this.log('Found translations in theme:') - this.log(`- Schema keys: ${requiredTranslations.schema.size}`) - this.log(`- Storefront keys: ${requiredTranslations.storefront.size}`) - } - - // 2. Fetch source locales - this.log(`Fetching locales from: ${localesDir}`) - const sourceLocales = await fetchLocaleSource(localesDir) - const localeFiles = Object.keys(sourceLocales) - - if (!this.flags[Flags.QUIET]) { - this.log(`Found ${localeFiles.length} locale files in source:`) - this.log(`- Schema files: ${localeFiles.filter(f => f.endsWith('.schema.json')).length}`) - this.log(`- Storefront files: ${localeFiles.filter(f => !f.endsWith('.schema.json')).length}`) - } - - // 3. Extract required translations - this.log('Extracting required translations') - const requiredLocales = extractRequiredTranslations(sourceLocales, requiredTranslations) - - // 4. Sync to theme - const syncMode = this.flags[Flags.OVERWRITE_LOCALES] ? 'overwrite' : this.flags[Flags.PRESERVE_LOCALES] ? 'preserve' : 'merge' - this.log(`Syncing translations to theme (mode: ${syncMode})`) - await syncLocales(themeDir, requiredLocales, { - overwrite: this.flags[Flags.OVERWRITE_LOCALES], - preserve: this.flags[Flags.PRESERVE_LOCALES] - }) - - if (!this.flags[Flags.QUIET]) { - this.log('Successfully synced locale files') - } - } catch (error) { - this.error(`Failed to sync locales: ${error}`) - } + const translations = await this.scanThemeTranslations(themeDir) + const sourceLocales = await this.fetchAndAnalyzeSource(localesDir) + await this.syncTranslations(themeDir, translations, sourceLocales) + } + + private async fetchAndAnalyzeSource(localesDir: string): Promise<{ + locales: Record>, + stats: LocaleSourceStats + }> { + const sourceLocales = await fetchLocaleSource(localesDir) + const stats = analyzeLocaleFiles(Object.keys(sourceLocales)) + this.logSourceStats(stats) + return { locales: sourceLocales, stats } } private getCleanFlags(): string[] { @@ -95,4 +67,51 @@ export default class Sync extends BaseCommand { .map(([key, value]) => value === false ? `--no-${key}` : null) .filter(Boolean) as string[] } + + private getSyncMode(): string { + if (this.flags[Flags.OVERWRITE_LOCALES]) return 'overwrite' + if (this.flags[Flags.PRESERVE_LOCALES]) return 'preserve' + return 'merge' + } + + private logSourceStats(stats: LocaleSourceStats): void { + if (!this.flags[Flags.QUIET]) { + this.log(`Found ${stats.totalFiles} locale files in source:`) + this.log(`- Schema files: ${stats.schemaFiles}`) + this.log(`- Storefront files: ${stats.storefrontFiles}`) + } + } + + private logTranslationStats(stats: ThemeTranslations): void { + if (!this.flags[Flags.QUIET]) { + this.log('Found translations in theme:') + this.log(`- Schema keys: ${stats.schema.size}`) + this.log(`- Storefront keys: ${stats.storefront.size}`) + } + } + + private async scanThemeTranslations(themeDir: string): Promise { + const translations = getThemeTranslations(themeDir) + this.logTranslationStats(translations) + return translations + } + + private async syncTranslations( + themeDir: string, + translations: ThemeTranslations, + sourceData: { locales: Record> } + ): Promise { + const requiredLocales = extractRequiredTranslations(sourceData.locales, translations) + const syncMode = this.getSyncMode() + this.log(`Syncing translations to theme (mode: ${syncMode})`) + + await syncLocales(themeDir, requiredLocales, { + overwrite: this.flags[Flags.OVERWRITE_LOCALES], + preserve: this.flags[Flags.PRESERVE_LOCALES] + }) + + if (!this.flags[Flags.QUIET]) { + this.log('Successfully synced locale files') + } + } } diff --git a/src/utilities/locale-sync.ts b/src/utilities/locale-sync.ts deleted file mode 100644 index ebacd00d..00000000 --- a/src/utilities/locale-sync.ts +++ /dev/null @@ -1,114 +0,0 @@ -import fs, { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import path from 'node:path' - -import { cloneTheme } from './git.js' -import { flattenObject, sortObjectKeys, unflattenObject } from './objects.js' -import { LocaleContent } from './translations.js' - -interface LocaleDiff { - added: Set - modified: Set - removed: Set -} - -export async function fetchLocaleSource(source: string): Promise { - if (isUrl(source)) { - return fetchRemoteLocales(source) - } - - return loadLocalLocales(source) -} - -function isUrl(source: string): boolean { - return source.startsWith('http://') || source.startsWith('https://') -} - -async function fetchRemoteLocales(url: string): Promise { - const tempDir = mkdtempSync(path.join(tmpdir(), 'theme-locales-')) - - try { - await cloneTheme(url, tempDir) - return loadLocalLocales(path.join(tempDir, 'locales')) - } finally { - rmSync(tempDir, { force: true, recursive: true }) - } -} - -function loadLocalLocales(dir: string): LocaleContent { - const content: LocaleContent = {} - - const files = fs.readdirSync(dir) - .filter(file => file.endsWith('.json')) - - for (const file of files) { - const filePath = path.join(dir, file) - content[file] = JSON.parse(fs.readFileSync(filePath, 'utf8')) as Record - } - - return content -} - -export function compareLocales(source: Record, target: Record): LocaleDiff { - const flatSource = flattenObject(source) - const flatTarget = flattenObject(target) - - return { - added: new Set(Object.keys(flatSource).filter(key => !(key in flatTarget))), - modified: new Set(Object.keys(flatSource).filter(key => - key in flatTarget && flatSource[key] !== flatTarget[key] - )), - removed: new Set(Object.keys(flatTarget).filter(key => !(key in flatSource))), - } -} - -export async function syncLocales( - themeDir: string, - sourceLocales: Record>, - options: { overwrite?: boolean, preserve?: boolean } -): Promise { - const localesDir = path.join(themeDir, 'locales') - - for (const [file, content] of Object.entries(sourceLocales)) { - const targetPath = path.join(localesDir, file) - - if (!fs.existsSync(targetPath)) { - // fs.writeFileSync(targetPath, JSON.stringify(sortObjectKeys(content), null, 2)) - fs.writeFileSync(targetPath, JSON.stringify(content, null, 2)) - continue - } - - const targetContent = JSON.parse(fs.readFileSync(targetPath, 'utf8')) - const diff = compareLocales(content, targetContent) - - if (options.preserve) { - // Only add new translations - const merged = {...targetContent} - const flatContent = flattenObject(content) - const flatMerged = flattenObject(merged) - - for (const key of diff.added) { - flatMerged[key] = flatContent[key] - } - - // fs.writeFileSync(targetPath, JSON.stringify(sortObjectKeys(unflattenObject(flatMerged)), null, 2)) - fs.writeFileSync(targetPath, JSON.stringify(unflattenObject(flatMerged), null, 2)) - } else if (options.overwrite) { - // Use source version entirely - // fs.writeFileSync(targetPath, JSON.stringify(sortObjectKeys(content), null, 2)) - fs.writeFileSync(targetPath, JSON.stringify(content, null, 2)) - } else { - // Selective merge based on diffs - const merged = {...targetContent} - const flatContent = flattenObject(content) - const flatMerged = flattenObject(merged) - - for (const key of [...diff.added, ...diff.modified]) { - flatMerged[key] = flatContent[key] - } - - // fs.writeFileSync(targetPath, JSON.stringify(sortObjectKeys(unflattenObject(flatMerged)), null, 2)) - fs.writeFileSync(targetPath, JSON.stringify(unflattenObject(flatMerged), null, 2)) - } - } -} diff --git a/src/utilities/locales.ts b/src/utilities/locales.ts new file mode 100644 index 00000000..0b4156c6 --- /dev/null +++ b/src/utilities/locales.ts @@ -0,0 +1,147 @@ +import fs, { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' + +import { cloneTheme } from './git.js' +import { flattenObject, sortObjectKeys, unflattenObject } from './objects.js' + +export interface LocaleContent { + [key: string]: Record +} + +export interface LocaleSourceStats { + schemaFiles: number + storefrontFiles: number + totalFiles: number +} + +export interface LocaleDiff { + added: Set + modified: Set + removed: Set +} + +interface SyncOptions { + overwrite?: boolean + preserve?: boolean +} + +export function analyzeLocaleFiles(localeFiles: string[]): LocaleSourceStats { + return { + schemaFiles: localeFiles.filter(f => f.endsWith('.schema.json')).length, + storefrontFiles: localeFiles.filter(f => !f.endsWith('.schema.json')).length, + totalFiles: localeFiles.length + } +} + +export async function fetchLocaleSource(source: string): Promise { + if (isUrl(source)) { + return fetchRemoteLocales(source) + } + + return loadLocalLocales(source) +} + +function isUrl(source: string): boolean { + return source.startsWith('http://') || source.startsWith('https://') +} + +async function fetchRemoteLocales(url: string): Promise { + const tempDir = mkdtempSync(path.join(tmpdir(), 'theme-locales-')) + + try { + await cloneTheme(url, tempDir) + return loadLocalLocales(path.join(tempDir, 'locales')) + } finally { + rmSync(tempDir, { force: true, recursive: true }) + } +} + +function loadLocalLocales(dir: string): LocaleContent { + const content: LocaleContent = {} + const files = fs.readdirSync(dir).filter(file => file.endsWith('.json')) + + for (const file of files) { + const filePath = path.join(dir, file) + content[file] = JSON.parse(fs.readFileSync(filePath, 'utf8')) + } + + return content +} + +export async function syncLocales( + themeDir: string, + sourceLocales: Record>, + options: SyncOptions +): Promise { + const localesDir = path.join(themeDir, 'locales') + + for (const [file, sourceContent] of Object.entries(sourceLocales)) { + const targetPath = path.join(localesDir, file) + + if (!fs.existsSync(targetPath)) { + fs.writeFileSync(targetPath, JSON.stringify(sortObjectKeys(sourceContent), null, 2)) + continue + } + + const targetContent = JSON.parse(fs.readFileSync(targetPath, 'utf8')) + const diff = compareLocales(sourceContent, targetContent) + + const mergedContent = options.overwrite + ? mergeOverwrite(sourceContent) + : options.preserve + ? mergePreserve(sourceContent, targetContent, diff) + : mergeSelective(sourceContent, targetContent, diff) + + fs.writeFileSync(targetPath, JSON.stringify(sortObjectKeys(mergedContent), null, 2)) + } +} + +export function compareLocales(source: Record, target: Record): LocaleDiff { + const flatSource = flattenObject(source) + const flatTarget = flattenObject(target) + + return { + added: new Set(Object.keys(flatSource).filter(key => !(key in flatTarget))), + modified: new Set(Object.keys(flatSource).filter(key => + key in flatTarget && flatSource[key] !== flatTarget[key] + )), + removed: new Set(Object.keys(flatTarget).filter(key => !(key in flatSource))), + } +} + +function mergeOverwrite(source: Record): Record { + return source +} + +function mergePreserve( + source: Record, + target: Record, + diff: LocaleDiff +): Record { + const merged = {...target} + const flatContent = flattenObject(source) + const flatMerged = flattenObject(merged) + + for (const key of diff.added) { + flatMerged[key] = flatContent[key] + } + + return unflattenObject(flatMerged) +} + +function mergeSelective( + source: Record, + target: Record, + diff: LocaleDiff +): Record { + const merged = {...target} + const flatContent = flattenObject(source) + const flatMerged = flattenObject(merged) + + for (const key of [...diff.added, ...diff.modified]) { + flatMerged[key] = flatContent[key] + } + + return unflattenObject(flatMerged) +} diff --git a/src/utilities/translations.ts b/src/utilities/translations.ts index a6f06aff..07351e2f 100644 --- a/src/utilities/translations.ts +++ b/src/utilities/translations.ts @@ -1,12 +1,9 @@ import fs from 'node:fs' import path from 'node:path' +import { LocaleContent } from './locales.js' import { flattenObject, unflattenObject } from './objects.js' -export interface LocaleContent { - [key: string]: Record -} - export interface ThemeTranslations { schema: Set storefront: Set @@ -44,31 +41,6 @@ export function getThemeTranslations(themeDir: string): ThemeTranslations { } } -export function extractRequiredTranslations( - sourceLocales: Record>, - required: ThemeTranslations -): LocaleContent { - const result: LocaleContent = {} - - for (const [file, content] of Object.entries(sourceLocales)) { - const isSchema = file.endsWith('.schema.json') - const requiredKeys = isSchema ? required.schema : required.storefront - - const flatContent = flattenObject(content) - const filteredContent: Record = {} - - for (const key of requiredKeys) { - if (key in flatContent) { - filteredContent[key] = flatContent[key] - } - } - - result[file] = unflattenObject(filteredContent) - } - - return result -} - function scanFiles(themeDir: string, dirs: readonly string[], findKeys: (content: string) => Set): Set { const usedKeys = new Set() @@ -199,3 +171,28 @@ function cleanLocaleFile(filePath: string, usedKeys: Set): void { throw new Error(`Error processing ${path.basename(filePath)}: ${error}`) } } + +export function extractRequiredTranslations( + sourceLocales: Record>, + required: ThemeTranslations +): LocaleContent { + const result: LocaleContent = {} + + for (const [file, content] of Object.entries(sourceLocales)) { + const isSchema = file.endsWith('.schema.json') + const requiredKeys = isSchema ? required.schema : required.storefront + + const flatContent = flattenObject(content) + const filteredContent: Record = {} + + for (const key of requiredKeys) { + if (key in flatContent) { + filteredContent[key] = flatContent[key] + } + } + + result[file] = unflattenObject(filteredContent) + } + + return result +} From 2e5aaa6089e604e3db59f9fbbf94061bc01f91cd Mon Sep 17 00:00:00 2001 From: Chris Berthe Date: Sat, 25 Jan 2025 21:11:01 -0500 Subject: [PATCH 09/53] refactor: remove logging in sync (for now) --- src/commands/theme/locale/sync.ts | 28 +--------------------------- src/utilities/translations.ts | 14 +++++++------- 2 files changed, 8 insertions(+), 34 deletions(-) diff --git a/src/commands/theme/locale/sync.ts b/src/commands/theme/locale/sync.ts index 6e62cbb6..6484aaa0 100644 --- a/src/commands/theme/locale/sync.ts +++ b/src/commands/theme/locale/sync.ts @@ -46,7 +46,7 @@ export default class Sync extends BaseCommand { await Clean.run([themeDir, ...this.getCleanFlags()]) } - const translations = await this.scanThemeTranslations(themeDir) + const translations = getThemeTranslations(themeDir) const sourceLocales = await this.fetchAndAnalyzeSource(localesDir) await this.syncTranslations(themeDir, translations, sourceLocales) } @@ -57,7 +57,6 @@ export default class Sync extends BaseCommand { }> { const sourceLocales = await fetchLocaleSource(localesDir) const stats = analyzeLocaleFiles(Object.keys(sourceLocales)) - this.logSourceStats(stats) return { locales: sourceLocales, stats } } @@ -74,37 +73,12 @@ export default class Sync extends BaseCommand { return 'merge' } - private logSourceStats(stats: LocaleSourceStats): void { - if (!this.flags[Flags.QUIET]) { - this.log(`Found ${stats.totalFiles} locale files in source:`) - this.log(`- Schema files: ${stats.schemaFiles}`) - this.log(`- Storefront files: ${stats.storefrontFiles}`) - } - } - - private logTranslationStats(stats: ThemeTranslations): void { - if (!this.flags[Flags.QUIET]) { - this.log('Found translations in theme:') - this.log(`- Schema keys: ${stats.schema.size}`) - this.log(`- Storefront keys: ${stats.storefront.size}`) - } - } - - private async scanThemeTranslations(themeDir: string): Promise { - const translations = getThemeTranslations(themeDir) - this.logTranslationStats(translations) - return translations - } - private async syncTranslations( themeDir: string, translations: ThemeTranslations, sourceData: { locales: Record> } ): Promise { const requiredLocales = extractRequiredTranslations(sourceData.locales, translations) - const syncMode = this.getSyncMode() - this.log(`Syncing translations to theme (mode: ${syncMode})`) - await syncLocales(themeDir, requiredLocales, { overwrite: this.flags[Flags.OVERWRITE_LOCALES], preserve: this.flags[Flags.PRESERVE_LOCALES] diff --git a/src/utilities/translations.ts b/src/utilities/translations.ts index 07351e2f..8f0ee98e 100644 --- a/src/utilities/translations.ts +++ b/src/utilities/translations.ts @@ -12,6 +12,13 @@ export interface ThemeTranslations { const SCHEMA_DIRS = ['config', 'blocks', 'sections'] as const const STOREFRONT_DIRS = ['blocks', 'layout', 'sections', 'snippets', 'templates'] as const +export function getThemeTranslations(themeDir: string): ThemeTranslations { + return { + schema: scanFiles(themeDir, SCHEMA_DIRS, findSchemaKeys), + storefront: scanFiles(themeDir, STOREFRONT_DIRS, findStorefrontKeys) + } +} + export function cleanSchemaTranslations(themeDir: string): void { const usedKeys = scanFiles(themeDir, SCHEMA_DIRS, findSchemaKeys) const localesDir = path.join(themeDir, 'locales') @@ -34,13 +41,6 @@ export function cleanStorefrontTranslations(themeDir: string): void { } } -export function getThemeTranslations(themeDir: string): ThemeTranslations { - return { - schema: scanFiles(themeDir, SCHEMA_DIRS, findSchemaKeys), - storefront: scanFiles(themeDir, STOREFRONT_DIRS, findStorefrontKeys) - } -} - function scanFiles(themeDir: string, dirs: readonly string[], findKeys: (content: string) => Set): Set { const usedKeys = new Set() From 168d0ee00737fdbff12947ee0d9f3435831356fb Mon Sep 17 00:00:00 2001 From: Chris Berthe Date: Sat, 25 Jan 2025 21:17:21 -0500 Subject: [PATCH 10/53] chore: capitalization FTW --- src/commands/theme/locale/sync.ts | 1 + src/utilities/flags.ts | 22 +++++++++++----------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/commands/theme/locale/sync.ts b/src/commands/theme/locale/sync.ts index 6484aaa0..07e803ad 100644 --- a/src/commands/theme/locale/sync.ts +++ b/src/commands/theme/locale/sync.ts @@ -79,6 +79,7 @@ export default class Sync extends BaseCommand { sourceData: { locales: Record> } ): Promise { const requiredLocales = extractRequiredTranslations(sourceData.locales, translations) + await syncLocales(themeDir, requiredLocales, { overwrite: this.flags[Flags.OVERWRITE_LOCALES], preserve: this.flags[Flags.PRESERVE_LOCALES] diff --git a/src/utilities/flags.ts b/src/utilities/flags.ts index 1441a953..84f197b7 100644 --- a/src/utilities/flags.ts +++ b/src/utilities/flags.ts @@ -58,12 +58,12 @@ export const flagDefinitions: Record = { [Flags.COLLECTION_NAME]: OclifFlags.string({ char: 'n', - description: 'name of the component collection', + description: 'Name of the component collection', }), [Flags.COLLECTION_VERSION]: OclifFlags.string({ char: 'v', - description: 'version of the component collection', + description: 'Version of the component collection', }), [Flags.ENVIRONMENT]: OclifFlags.string({ @@ -73,13 +73,13 @@ export const flagDefinitions: Record = { [Flags.GENERATE_IMPORT_MAP]: OclifFlags.boolean({ char: 'i', default: true, - description: 'generate import map', + description: 'Generate import map', }), [Flags.GENERATE_TEMPLATE_MAP]: OclifFlags.boolean({ char: 'm', default: true, - description: 'generate template map', + description: 'Generate template map', }), [Flags.HOST]: OclifFlags.string({ @@ -89,13 +89,13 @@ export const flagDefinitions: Record = { [Flags.IGNORE_CONFLICTS]: OclifFlags.boolean({ char: 'f', default: false, - description: 'ignore conflicts when mapping components', + description: 'Ignore conflicts when mapping components', }), [Flags.IGNORE_OVERRIDES]: OclifFlags.boolean({ char: 'o', default: false, - description: 'ignore overrides when mapping components', + description: 'Ignore overrides when mapping components', }), [Flags.LIVE_RELOAD]: OclifFlags.boolean({ @@ -132,14 +132,14 @@ export const flagDefinitions: Record = { allowNo: true, char: 'y', default: true, - description: 'sync changes to theme directory', + description: 'Sync changes to theme directory', }), [Flags.QUIET]: OclifFlags.boolean({ allowNo: true, char: 'q', default: false, - description: 'suppress non-essential output' + description: 'Suppress non-essential output' }), [Flags.SCHEMA_LOCALES]: OclifFlags.boolean({ @@ -153,7 +153,7 @@ export const flagDefinitions: Record = { allowNo: true, char: 's', default: true, - description: 'copy setup files to theme directory', + description: 'Copy setup files to theme directory', }), [Flags.STORE]: OclifFlags.string({ @@ -178,13 +178,13 @@ export const flagDefinitions: Record = { [Flags.THEME_DIR]: OclifFlags.string({ char: 't', default: 'https://github.com/archetype-themes/explorer', - description: 'directory that contains theme files for development', + description: 'Directory that contains theme files for development', }), [Flags.WATCH]: OclifFlags.boolean({ allowNo: true, char: 'w', default: true, - description: 'watch for changes in theme and component directories', + description: 'Watch for changes in theme and component directories', }), } From 8f7b0469500c0875ffebb699d4c978792a429418 Mon Sep 17 00:00:00 2001 From: Chris Berthe Date: Sat, 25 Jan 2025 21:21:44 -0500 Subject: [PATCH 11/53] chore: remove unnecessary code --- src/commands/theme/locale/sync.ts | 14 +++----------- src/utilities/locales.ts | 14 -------------- 2 files changed, 3 insertions(+), 25 deletions(-) diff --git a/src/commands/theme/locale/sync.ts b/src/commands/theme/locale/sync.ts index 07e803ad..ef89642c 100644 --- a/src/commands/theme/locale/sync.ts +++ b/src/commands/theme/locale/sync.ts @@ -11,7 +11,7 @@ import path from 'node:path' import Args from '../../../utilities/args.js' import BaseCommand from '../../../utilities/base-command.js' import Flags from '../../../utilities/flags.js' -import { LocaleSourceStats, analyzeLocaleFiles, fetchLocaleSource, syncLocales } from '../../../utilities/locales.js' +import { analyzeLocaleFiles, fetchLocaleSource, syncLocales } from '../../../utilities/locales.js' import { ThemeTranslations, extractRequiredTranslations, getThemeTranslations } from '../../../utilities/translations.js' import Clean from './clean.js' @@ -52,12 +52,10 @@ export default class Sync extends BaseCommand { } private async fetchAndAnalyzeSource(localesDir: string): Promise<{ - locales: Record>, - stats: LocaleSourceStats + locales: Record> }> { const sourceLocales = await fetchLocaleSource(localesDir) - const stats = analyzeLocaleFiles(Object.keys(sourceLocales)) - return { locales: sourceLocales, stats } + return { locales: sourceLocales } } private getCleanFlags(): string[] { @@ -67,12 +65,6 @@ export default class Sync extends BaseCommand { .filter(Boolean) as string[] } - private getSyncMode(): string { - if (this.flags[Flags.OVERWRITE_LOCALES]) return 'overwrite' - if (this.flags[Flags.PRESERVE_LOCALES]) return 'preserve' - return 'merge' - } - private async syncTranslations( themeDir: string, translations: ThemeTranslations, diff --git a/src/utilities/locales.ts b/src/utilities/locales.ts index 0b4156c6..4ed14d60 100644 --- a/src/utilities/locales.ts +++ b/src/utilities/locales.ts @@ -9,12 +9,6 @@ export interface LocaleContent { [key: string]: Record } -export interface LocaleSourceStats { - schemaFiles: number - storefrontFiles: number - totalFiles: number -} - export interface LocaleDiff { added: Set modified: Set @@ -26,14 +20,6 @@ interface SyncOptions { preserve?: boolean } -export function analyzeLocaleFiles(localeFiles: string[]): LocaleSourceStats { - return { - schemaFiles: localeFiles.filter(f => f.endsWith('.schema.json')).length, - storefrontFiles: localeFiles.filter(f => !f.endsWith('.schema.json')).length, - totalFiles: localeFiles.length - } -} - export async function fetchLocaleSource(source: string): Promise { if (isUrl(source)) { return fetchRemoteLocales(source) From e51606cf8cbe93013a031481aff3175ae8116871 Mon Sep 17 00:00:00 2001 From: Chris Berthe Date: Sat, 25 Jan 2025 22:34:26 -0500 Subject: [PATCH 12/53] refactor: locale sync mode --- src/commands/theme/locale/sync.ts | 18 ++++++------------ src/utilities/flags.ts | 31 +++++++++++++++---------------- src/utilities/locales.ts | 23 ++++++++++------------- 3 files changed, 31 insertions(+), 41 deletions(-) diff --git a/src/commands/theme/locale/sync.ts b/src/commands/theme/locale/sync.ts index ef89642c..d3f28608 100644 --- a/src/commands/theme/locale/sync.ts +++ b/src/commands/theme/locale/sync.ts @@ -2,7 +2,7 @@ * This command syncs locale files in a theme directory with a source of translations. * * - Fetches translations from source (remote or local) - * - Updates theme locale files with source content + * - Updates theme locale files based on selected mode * - Preserves file structure and formatting */ @@ -11,7 +11,7 @@ import path from 'node:path' import Args from '../../../utilities/args.js' import BaseCommand from '../../../utilities/base-command.js' import Flags from '../../../utilities/flags.js' -import { analyzeLocaleFiles, fetchLocaleSource, syncLocales } from '../../../utilities/locales.js' +import { fetchLocaleSource, syncLocales } from '../../../utilities/locales.js' import { ThemeTranslations, extractRequiredTranslations, getThemeTranslations } from '../../../utilities/translations.js' import Clean from './clean.js' @@ -23,17 +23,13 @@ export default class Sync extends BaseCommand { static override description = 'Sync theme locale files with source translations' static override examples = [ - '<%= config.bin %> <%= command.id %> theme-directory', - '<%= config.bin %> <%= command.id %> theme-directory path/to/locales', - '<%= config.bin %> <%= command.id %> theme-directory --overwrite-locales', - '<%= config.bin %> <%= command.id %> theme-directory --preserve-locales' + '<%= config.bin %> <%= command.id %> theme-directory' ] static override flags = Flags.getDefinitions([ Flags.CLEAN, Flags.LOCALES_DIR, - Flags.OVERWRITE_LOCALES, - Flags.PRESERVE_LOCALES, + Flags.SYNC_MODE, Flags.SCHEMA_LOCALES, Flags.STOREFRONT_LOCALES ]) @@ -71,11 +67,9 @@ export default class Sync extends BaseCommand { sourceData: { locales: Record> } ): Promise { const requiredLocales = extractRequiredTranslations(sourceData.locales, translations) + const mode = this.flags[Flags.SYNC_MODE] - await syncLocales(themeDir, requiredLocales, { - overwrite: this.flags[Flags.OVERWRITE_LOCALES], - preserve: this.flags[Flags.PRESERVE_LOCALES] - }) + await syncLocales(themeDir, requiredLocales, mode) if (!this.flags[Flags.QUIET]) { this.log('Successfully synced locale files') diff --git a/src/utilities/flags.ts b/src/utilities/flags.ts index 84f197b7..d67ba823 100644 --- a/src/utilities/flags.ts +++ b/src/utilities/flags.ts @@ -17,10 +17,8 @@ export default class Flags { static readonly IGNORE_OVERRIDES = 'ignore-overrides'; static readonly LIVE_RELOAD = 'live-reload'; static readonly LOCALES_DIR = 'locales-dir'; - static readonly OVERWRITE_LOCALES = 'overwrite-locales'; static readonly PASSWORD = 'password'; static readonly PORT = 'port'; - static readonly PRESERVE_LOCALES = 'preserve-locales'; static readonly PREVIEW = 'preview'; static readonly QUIET = 'quiet'; static readonly SCHEMA_LOCALES = 'schema-locales'; @@ -28,6 +26,7 @@ export default class Flags { static readonly STORE = 'store'; static readonly STORE_PASSWORD = 'store-password'; static readonly STOREFRONT_LOCALES = 'storefront-locales'; + static readonly SYNC_MODE = 'sync-mode'; static readonly THEME = 'theme'; static readonly THEME_DIR = 'theme-dir'; static readonly WATCH = 'watch'; @@ -52,8 +51,10 @@ export default class Flags { // eslint-disable-next-line @typescript-eslint/no-explicit-any export const flagDefinitions: Record = { [Flags.CLEAN]: OclifFlags.boolean({ - default: false, - description: 'Clean the theme directory before copying components' + allowNo: true, + char: 'c', + default: true, + description: 'Clean unused translations from locale files' }), [Flags.COLLECTION_NAME]: OclifFlags.string({ @@ -108,12 +109,6 @@ export const flagDefinitions: Record = { description: 'Directory or repository containing locale files', }), - [Flags.OVERWRITE_LOCALES]: OclifFlags.boolean({ - char: 'w', - default: false, - description: 'Overwrite existing theme locale files with source versions' - }), - [Flags.PASSWORD]: OclifFlags.string({ description: 'Password generated from the Theme Access app.', }), @@ -122,12 +117,6 @@ export const flagDefinitions: Record = { description: 'Local port to serve theme preview from.', }), - [Flags.PRESERVE_LOCALES]: OclifFlags.boolean({ - char: 'p', - default: false, - description: 'Preserve existing theme locale files when conflicts occur' - }), - [Flags.PREVIEW]: OclifFlags.boolean({ allowNo: true, char: 'y', @@ -171,6 +160,16 @@ export const flagDefinitions: Record = { description: 'Clean translations from storefront locale files' }), + [Flags.SYNC_MODE]: OclifFlags.string({ + char: 'm', + default: 'update', + description: 'Sync mode for locale files:\n' + + '- update: Add new and update modified translations (default)\n' + + '- replace: Replace all translations with source versions\n' + + '- add: Only add new translations, preserve existing ones', + options: ['update', 'replace', 'add'] + }), + [Flags.THEME]: OclifFlags.string({ description: 'Theme ID or name of the remote theme.', }), diff --git a/src/utilities/locales.ts b/src/utilities/locales.ts index 4ed14d60..7d67fdc8 100644 --- a/src/utilities/locales.ts +++ b/src/utilities/locales.ts @@ -15,10 +15,7 @@ export interface LocaleDiff { removed: Set } -interface SyncOptions { - overwrite?: boolean - preserve?: boolean -} +export type SyncMode = 'add' | 'replace' | 'update' export async function fetchLocaleSource(source: string): Promise { if (isUrl(source)) { @@ -58,7 +55,7 @@ function loadLocalLocales(dir: string): LocaleContent { export async function syncLocales( themeDir: string, sourceLocales: Record>, - options: SyncOptions + mode: SyncMode ): Promise { const localesDir = path.join(themeDir, 'locales') @@ -73,11 +70,11 @@ export async function syncLocales( const targetContent = JSON.parse(fs.readFileSync(targetPath, 'utf8')) const diff = compareLocales(sourceContent, targetContent) - const mergedContent = options.overwrite - ? mergeOverwrite(sourceContent) - : options.preserve - ? mergePreserve(sourceContent, targetContent, diff) - : mergeSelective(sourceContent, targetContent, diff) + const mergedContent = mode === 'replace' + ? replaceContent(sourceContent) + : mode === 'add' + ? addNewContent(sourceContent, targetContent, diff) + : updateContent(sourceContent, targetContent, diff) fs.writeFileSync(targetPath, JSON.stringify(sortObjectKeys(mergedContent), null, 2)) } @@ -96,11 +93,11 @@ export function compareLocales(source: Record, target: Record): Record { +function replaceContent(source: Record): Record { return source } -function mergePreserve( +function addNewContent( source: Record, target: Record, diff: LocaleDiff @@ -116,7 +113,7 @@ function mergePreserve( return unflattenObject(flatMerged) } -function mergeSelective( +function updateContent( source: Record, target: Record, diff: LocaleDiff From 10df25eb4ca27a0002add85eb7dcbf5dfb1e4f59 Mon Sep 17 00:00:00 2001 From: Chris Berthe Date: Sun, 26 Jan 2025 12:26:04 -0500 Subject: [PATCH 13/53] refactor: add format flag for syncing locales --- src/commands/theme/locale/sync.ts | 4 +++- src/utilities/flags.ts | 6 ++++++ src/utilities/locales.ts | 14 +++++++++++--- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/commands/theme/locale/sync.ts b/src/commands/theme/locale/sync.ts index d3f28608..f6bb3a1d 100644 --- a/src/commands/theme/locale/sync.ts +++ b/src/commands/theme/locale/sync.ts @@ -28,6 +28,7 @@ export default class Sync extends BaseCommand { static override flags = Flags.getDefinitions([ Flags.CLEAN, + Flags.FORMAT, Flags.LOCALES_DIR, Flags.SYNC_MODE, Flags.SCHEMA_LOCALES, @@ -67,9 +68,10 @@ export default class Sync extends BaseCommand { sourceData: { locales: Record> } ): Promise { const requiredLocales = extractRequiredTranslations(sourceData.locales, translations) + const format = this.flags[Flags.FORMAT] const mode = this.flags[Flags.SYNC_MODE] - await syncLocales(themeDir, requiredLocales, mode) + await syncLocales(themeDir, requiredLocales, { format, mode }) if (!this.flags[Flags.QUIET]) { this.log('Successfully synced locale files') diff --git a/src/utilities/flags.ts b/src/utilities/flags.ts index d67ba823..2dfc97fb 100644 --- a/src/utilities/flags.ts +++ b/src/utilities/flags.ts @@ -10,6 +10,7 @@ export default class Flags { static readonly COLLECTION_PACKAGE_JSON = 'collection-package-json'; static readonly COLLECTION_VERSION = 'collection-version'; static readonly ENVIRONMENT = 'environment'; + static readonly FORMAT = 'format'; static readonly GENERATE_IMPORT_MAP = 'generate-import-map'; static readonly GENERATE_TEMPLATE_MAP = 'generate-template-map'; static readonly HOST = 'host'; @@ -71,6 +72,11 @@ export const flagDefinitions: Record = { description: 'The environment to apply to the current command.', }), + [Flags.FORMAT]: OclifFlags.boolean({ + default: false, + description: 'Format locale files (sort keys alphabetically)', + }), + [Flags.GENERATE_IMPORT_MAP]: OclifFlags.boolean({ char: 'i', default: true, diff --git a/src/utilities/locales.ts b/src/utilities/locales.ts index 7d67fdc8..8c8b0f79 100644 --- a/src/utilities/locales.ts +++ b/src/utilities/locales.ts @@ -17,6 +17,11 @@ export interface LocaleDiff { export type SyncMode = 'add' | 'replace' | 'update' +export interface SyncOptions { + format?: boolean + mode: SyncMode +} + export async function fetchLocaleSource(source: string): Promise { if (isUrl(source)) { return fetchRemoteLocales(source) @@ -55,15 +60,17 @@ function loadLocalLocales(dir: string): LocaleContent { export async function syncLocales( themeDir: string, sourceLocales: Record>, - mode: SyncMode + options?: Partial ): Promise { const localesDir = path.join(themeDir, 'locales') + const { format = false, mode = 'update' } = options ?? {} for (const [file, sourceContent] of Object.entries(sourceLocales)) { const targetPath = path.join(localesDir, file) if (!fs.existsSync(targetPath)) { - fs.writeFileSync(targetPath, JSON.stringify(sortObjectKeys(sourceContent), null, 2)) + const content = format ? sortObjectKeys(sourceContent) : sourceContent + fs.writeFileSync(targetPath, JSON.stringify(content, null, 2)) continue } @@ -76,7 +83,8 @@ export async function syncLocales( ? addNewContent(sourceContent, targetContent, diff) : updateContent(sourceContent, targetContent, diff) - fs.writeFileSync(targetPath, JSON.stringify(sortObjectKeys(mergedContent), null, 2)) + const content = format ? sortObjectKeys(mergedContent) : mergedContent + fs.writeFileSync(targetPath, JSON.stringify(content, null, 2)) } } From 7846161dad18ce1be11d31831334132500734089 Mon Sep 17 00:00:00 2001 From: Chris Berthe Date: Sun, 26 Jan 2025 12:29:49 -0500 Subject: [PATCH 14/53] chore: move sortObjectKeys to objects.ts --- src/commands/theme/component/map.ts | 29 ++++++----------------------- src/utilities/objects.ts | 25 +++++++++++++++---------- 2 files changed, 21 insertions(+), 33 deletions(-) diff --git a/src/commands/theme/component/map.ts b/src/commands/theme/component/map.ts index bde02f5a..51948b87 100644 --- a/src/commands/theme/component/map.ts +++ b/src/commands/theme/component/map.ts @@ -1,6 +1,6 @@ /** * This command generates or updates a component.manifest.json file - * + * * - Updates component files (assets and snippets) mapping * - Updates component collection details */ @@ -8,11 +8,12 @@ import fs from 'node:fs' import path from 'node:path' -import Args from '../../../utilities/args.js' +import Args from '../../../utilities/args.js' import BaseCommand from '../../../utilities/base-command.js' import Flags from '../../../utilities/flags.js' import { getLastCommitHash } from '../../../utilities/git.js' import { ManifestOptions, generateManifestFiles, getManifest } from '../../../utilities/manifest.js' +import { sortObjectKeys } from '../../../utilities/objects.js' import { getNameFromPackageJson, getVersionFromPackageJson } from '../../../utilities/package-json.js' export default class Manifest extends BaseCommand { @@ -67,9 +68,9 @@ export default class Manifest extends BaseCommand { } const files = await generateManifestFiles( - manifest.files, - themeDir, - collectionDir, + manifest.files, + themeDir, + collectionDir, collectionName, options ) @@ -83,21 +84,3 @@ export default class Manifest extends BaseCommand { fs.writeFileSync(manifestPath, JSON.stringify(sortObjectKeys(manifest), null, 2)) } } - -function sortObjectKeys(obj: T): T { - if (Array.isArray(obj)) { - return obj.map((item) => sortObjectKeys(item)) as T; - } - - if (obj !== null && typeof obj === 'object') { - const sortedObj: Record = {}; - const sortedKeys = Object.keys(obj as object).sort(); - for (const key of sortedKeys) { - sortedObj[key] = sortObjectKeys((obj as Record)[key]); - } - - return sortedObj as T; - } - - return obj; -} diff --git a/src/utilities/objects.ts b/src/utilities/objects.ts index a04802bb..922c083e 100644 --- a/src/utilities/objects.ts +++ b/src/utilities/objects.ts @@ -50,16 +50,21 @@ export function unflattenObject(obj: Record): Record): Record { - const sorted: Record = {} - const keys = Object.keys(obj).sort() - - for (const key of keys) { - const value = obj[key] - sorted[key] = value && typeof value === 'object' && !Array.isArray(value) - ? sortObjectKeys(value as Record) - : value +export function sortObjectKeys(obj: T): T { + if (Array.isArray(obj)) { + return obj.map((item) => sortObjectKeys(item)) as T } - return sorted + if (obj !== null && typeof obj === 'object') { + const sortedObj: Record = {} + const sortedKeys = Object.keys(obj as object).sort() + + for (const key of sortedKeys) { + sortedObj[key] = sortObjectKeys((obj as Record)[key]) + } + + return sortedObj as T + } + + return obj } From 179c8b0f096e3dffe5e863e3d427e846714b0ea6 Mon Sep 17 00:00:00 2001 From: Chris Berthe Date: Sun, 26 Jan 2025 12:55:32 -0500 Subject: [PATCH 15/53] refactor: replace schema and storefront flags with target flag --- src/commands/theme/locale/clean.ts | 19 ++++++------------- src/commands/theme/locale/sync.ts | 26 +++++++++++++------------- src/utilities/flags.ts | 27 +++++++++------------------ src/utilities/translations.ts | 22 ++++++++++++++++++++++ 4 files changed, 50 insertions(+), 44 deletions(-) diff --git a/src/commands/theme/locale/clean.ts b/src/commands/theme/locale/clean.ts index 3f782148..1cda2528 100644 --- a/src/commands/theme/locale/clean.ts +++ b/src/commands/theme/locale/clean.ts @@ -10,7 +10,7 @@ import path from 'node:path' import Args from '../../../utilities/args.js' import BaseCommand from '../../../utilities/base-command.js' import Flags from '../../../utilities/flags.js' -import { cleanSchemaTranslations, cleanStorefrontTranslations } from '../../../utilities/translations.js' +import { CleanTarget, cleanTranslations } from '../../../utilities/translations.js' export default class Clean extends BaseCommand { static override args = Args.getDefinitions([ @@ -21,13 +21,12 @@ export default class Clean extends BaseCommand { static override examples = [ '<%= config.bin %> <%= command.id %> theme-directory', - '<%= config.bin %> <%= command.id %> theme-directory --no-schema-locales', - '<%= config.bin %> <%= command.id %> theme-directory --no-storefront-locales' + '<%= config.bin %> <%= command.id %> theme-directory --target=schema', + '<%= config.bin %> <%= command.id %> theme-directory --target=storefront' ] static override flags = Flags.getDefinitions([ - Flags.SCHEMA_LOCALES, - Flags.STOREFRONT_LOCALES + Flags.TARGET ]) protected override async init(): Promise { @@ -36,15 +35,9 @@ export default class Clean extends BaseCommand { public async run(): Promise { const themeDir = path.resolve(process.cwd(), this.args[Args.THEME_DIR]) - const schemaLocales = this.flags[Flags.SCHEMA_LOCALES] - const storefrontLocales = this.flags[Flags.STOREFRONT_LOCALES] + const target = this.flags[Flags.TARGET] as CleanTarget - if (!schemaLocales && !storefrontLocales) { - this.error('Cannot disable cleaning of both schema and storefront locales. Remove either --no-schema-locales or --no-storefront-locales flag') - } - - schemaLocales && cleanSchemaTranslations(themeDir) - storefrontLocales && cleanStorefrontTranslations(themeDir) + await cleanTranslations(themeDir, target) if (!this.flags[Flags.QUIET]) { this.log('Successfully cleaned locale files') diff --git a/src/commands/theme/locale/sync.ts b/src/commands/theme/locale/sync.ts index f6bb3a1d..f6c1ece4 100644 --- a/src/commands/theme/locale/sync.ts +++ b/src/commands/theme/locale/sync.ts @@ -12,8 +12,13 @@ import Args from '../../../utilities/args.js' import BaseCommand from '../../../utilities/base-command.js' import Flags from '../../../utilities/flags.js' import { fetchLocaleSource, syncLocales } from '../../../utilities/locales.js' -import { ThemeTranslations, extractRequiredTranslations, getThemeTranslations } from '../../../utilities/translations.js' -import Clean from './clean.js' +import { + CleanTarget, + ThemeTranslations, + cleanTranslations, + extractRequiredTranslations, + getThemeTranslations +} from '../../../utilities/translations.js' export default class Sync extends BaseCommand { static override args = Args.getDefinitions([ @@ -23,7 +28,9 @@ export default class Sync extends BaseCommand { static override description = 'Sync theme locale files with source translations' static override examples = [ - '<%= config.bin %> <%= command.id %> theme-directory' + '<%= config.bin %> <%= command.id %> theme-directory', + '<%= config.bin %> <%= command.id %> theme-directory --clean', + '<%= config.bin %> <%= command.id %> theme-directory --clean --target=schema' ] static override flags = Flags.getDefinitions([ @@ -31,16 +38,16 @@ export default class Sync extends BaseCommand { Flags.FORMAT, Flags.LOCALES_DIR, Flags.SYNC_MODE, - Flags.SCHEMA_LOCALES, - Flags.STOREFRONT_LOCALES + Flags.TARGET ]) public async run(): Promise { const themeDir = path.resolve(process.cwd(), this.args[Args.THEME_DIR]) const localesDir = this.flags[Flags.LOCALES_DIR] + const target = this.flags[Flags.TARGET] as CleanTarget if (this.flags[Flags.CLEAN]) { - await Clean.run([themeDir, ...this.getCleanFlags()]) + await cleanTranslations(themeDir, target) } const translations = getThemeTranslations(themeDir) @@ -55,13 +62,6 @@ export default class Sync extends BaseCommand { return { locales: sourceLocales } } - private getCleanFlags(): string[] { - return Object.entries(this.flags) - .filter(([key]) => [Flags.SCHEMA_LOCALES, Flags.STOREFRONT_LOCALES].includes(key)) - .map(([key, value]) => value === false ? `--no-${key}` : null) - .filter(Boolean) as string[] - } - private async syncTranslations( themeDir: string, translations: ThemeTranslations, diff --git a/src/utilities/flags.ts b/src/utilities/flags.ts index 2dfc97fb..23806ff8 100644 --- a/src/utilities/flags.ts +++ b/src/utilities/flags.ts @@ -22,12 +22,11 @@ export default class Flags { static readonly PORT = 'port'; static readonly PREVIEW = 'preview'; static readonly QUIET = 'quiet'; - static readonly SCHEMA_LOCALES = 'schema-locales'; static readonly SETUP_FILES = 'setup-files'; static readonly STORE = 'store'; static readonly STORE_PASSWORD = 'store-password'; - static readonly STOREFRONT_LOCALES = 'storefront-locales'; static readonly SYNC_MODE = 'sync-mode'; + static readonly TARGET = 'target'; static readonly THEME = 'theme'; static readonly THEME_DIR = 'theme-dir'; static readonly WATCH = 'watch'; @@ -54,8 +53,8 @@ export const flagDefinitions: Record = { [Flags.CLEAN]: OclifFlags.boolean({ allowNo: true, char: 'c', - default: true, - description: 'Clean unused translations from locale files' + default: false, + description: 'Clean unused translations before syncing' }), [Flags.COLLECTION_NAME]: OclifFlags.string({ @@ -137,13 +136,6 @@ export const flagDefinitions: Record = { description: 'Suppress non-essential output' }), - [Flags.SCHEMA_LOCALES]: OclifFlags.boolean({ - allowNo: true, - char: 's', - default: true, - description: 'Clean translations from schema locale files' - }), - [Flags.SETUP_FILES]: OclifFlags.boolean({ allowNo: true, char: 's', @@ -159,13 +151,6 @@ export const flagDefinitions: Record = { description: 'The password for storefronts with password protection.', }), - [Flags.STOREFRONT_LOCALES]: OclifFlags.boolean({ - allowNo: true, - char: 'f', - default: true, - description: 'Clean translations from storefront locale files' - }), - [Flags.SYNC_MODE]: OclifFlags.string({ char: 'm', default: 'update', @@ -176,6 +161,12 @@ export const flagDefinitions: Record = { options: ['update', 'replace', 'add'] }), + [Flags.TARGET]: OclifFlags.string({ + default: 'all', + description: 'Target for cleaning: all (default), schema, or storefront', + options: ['all', 'schema', 'storefront'] + }), + [Flags.THEME]: OclifFlags.string({ description: 'Theme ID or name of the remote theme.', }), diff --git a/src/utilities/translations.ts b/src/utilities/translations.ts index 8f0ee98e..3c123e97 100644 --- a/src/utilities/translations.ts +++ b/src/utilities/translations.ts @@ -9,6 +9,8 @@ export interface ThemeTranslations { storefront: Set } +export type CleanTarget = 'all' | 'schema' | 'storefront' + const SCHEMA_DIRS = ['config', 'blocks', 'sections'] as const const STOREFRONT_DIRS = ['blocks', 'layout', 'sections', 'snippets', 'templates'] as const @@ -196,3 +198,23 @@ export function extractRequiredTranslations( return result } + +export async function cleanTranslations(themeDir: string, target: CleanTarget): Promise { + switch (target) { + case 'schema': { + await cleanSchemaTranslations(themeDir) + break + } + + case 'storefront': { + await cleanStorefrontTranslations(themeDir) + break + } + + case 'all': { + await cleanSchemaTranslations(themeDir) + await cleanStorefrontTranslations(themeDir) + break + } + } +} From 8404082a1d3d714f45cc291879ba31492d30db30 Mon Sep 17 00:00:00 2001 From: Chris Berthe Date: Thu, 30 Jan 2025 14:04:59 -0500 Subject: [PATCH 16/53] remove allowNo from clean flag --- src/utilities/flags.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/utilities/flags.ts b/src/utilities/flags.ts index 23806ff8..b36d78a3 100644 --- a/src/utilities/flags.ts +++ b/src/utilities/flags.ts @@ -51,7 +51,6 @@ export default class Flags { // eslint-disable-next-line @typescript-eslint/no-explicit-any export const flagDefinitions: Record = { [Flags.CLEAN]: OclifFlags.boolean({ - allowNo: true, char: 'c', default: false, description: 'Clean unused translations before syncing' From 6c1583d70ae97a04eb061410aebe527f0f4e6fb0 Mon Sep 17 00:00:00 2001 From: Chris Berthe Date: Mon, 24 Feb 2025 16:09:52 -0500 Subject: [PATCH 17/53] refactor: replaceContent method, wasn't preserving locale object structure --- src/utilities/locales.ts | 38 +++++++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/src/utilities/locales.ts b/src/utilities/locales.ts index 8c8b0f79..7a95dfa4 100644 --- a/src/utilities/locales.ts +++ b/src/utilities/locales.ts @@ -78,10 +78,10 @@ export async function syncLocales( const diff = compareLocales(sourceContent, targetContent) const mergedContent = mode === 'replace' - ? replaceContent(sourceContent) + ? replaceContent(sourceContent, targetContent) : mode === 'add' - ? addNewContent(sourceContent, targetContent, diff) - : updateContent(sourceContent, targetContent, diff) + ? addNewContent(sourceContent, targetContent, diff) + : updateContent(sourceContent, targetContent, diff) const content = format ? sortObjectKeys(mergedContent) : mergedContent fs.writeFileSync(targetPath, JSON.stringify(content, null, 2)) @@ -101,8 +101,32 @@ export function compareLocales(source: Record, target: Record): Record { - return source +function replaceContent( + source: Record, + target: Record +): Record { + // Helper function to update values in an object while preserving structure + function updateValues(targetObj: Record, sourceObj: Record): Record { + const result: Record = {} + + // Iterate through target's keys to preserve order + for (const [key, value] of Object.entries(targetObj)) { + if (typeof value === 'object' && value !== null && key in sourceObj && typeof sourceObj[key] === 'object') { + // Recursively handle nested objects + result[key] = updateValues(value as Record, sourceObj[key] as Record) + } else if (key in sourceObj) { + // Replace value from source if it exists + result[key] = sourceObj[key] + } else { + // Keep original value if not in source + result[key] = value + } + } + + return result + } + + return updateValues(target, source) } function addNewContent( @@ -110,7 +134,7 @@ function addNewContent( target: Record, diff: LocaleDiff ): Record { - const merged = {...target} + const merged = { ...target } const flatContent = flattenObject(source) const flatMerged = flattenObject(merged) @@ -126,7 +150,7 @@ function updateContent( target: Record, diff: LocaleDiff ): Record { - const merged = {...target} + const merged = { ...target } const flatContent = flattenObject(source) const flatMerged = flattenObject(merged) From afdaf3b435210e2563fd9fb51646bac87cc1c058 Mon Sep 17 00:00:00 2001 From: Chris Berthe Date: Mon, 24 Feb 2025 16:18:50 -0500 Subject: [PATCH 18/53] chore: rename methods in locales.ts --- src/utilities/locales.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/utilities/locales.ts b/src/utilities/locales.ts index 7a95dfa4..b8d3ae19 100644 --- a/src/utilities/locales.ts +++ b/src/utilities/locales.ts @@ -75,20 +75,20 @@ export async function syncLocales( } const targetContent = JSON.parse(fs.readFileSync(targetPath, 'utf8')) - const diff = compareLocales(sourceContent, targetContent) + const diff = diffLocales(sourceContent, targetContent) const mergedContent = mode === 'replace' - ? replaceContent(sourceContent, targetContent) + ? replaceTranslations(sourceContent, targetContent) : mode === 'add' - ? addNewContent(sourceContent, targetContent, diff) - : updateContent(sourceContent, targetContent, diff) + ? addMissingTranslations(sourceContent, targetContent, diff) + : mergeTranslations(sourceContent, targetContent, diff) const content = format ? sortObjectKeys(mergedContent) : mergedContent fs.writeFileSync(targetPath, JSON.stringify(content, null, 2)) } } -export function compareLocales(source: Record, target: Record): LocaleDiff { +export function diffLocales(source: Record, target: Record): LocaleDiff { const flatSource = flattenObject(source) const flatTarget = flattenObject(target) @@ -101,7 +101,7 @@ export function compareLocales(source: Record, target: Record, target: Record ): Record { @@ -129,7 +129,7 @@ function replaceContent( return updateValues(target, source) } -function addNewContent( +function addMissingTranslations( source: Record, target: Record, diff: LocaleDiff @@ -145,7 +145,7 @@ function addNewContent( return unflattenObject(flatMerged) } -function updateContent( +function mergeTranslations( source: Record, target: Record, diff: LocaleDiff From 0d4e323769d0b66e3e38beb4700af971c4f94105 Mon Sep 17 00:00:00 2001 From: Chris Berthe Date: Mon, 24 Feb 2025 16:23:04 -0500 Subject: [PATCH 19/53] refactor: replaceTranslations method --- src/utilities/locales.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/utilities/locales.ts b/src/utilities/locales.ts index b8d3ae19..cc62289f 100644 --- a/src/utilities/locales.ts +++ b/src/utilities/locales.ts @@ -105,21 +105,17 @@ function replaceTranslations( source: Record, target: Record ): Record { - // Helper function to update values in an object while preserving structure - function updateValues(targetObj: Record, sourceObj: Record): Record { + const updateValues = (targetObj: Record, sourceObj: Record): Record => { const result: Record = {} - // Iterate through target's keys to preserve order for (const [key, value] of Object.entries(targetObj)) { - if (typeof value === 'object' && value !== null && key in sourceObj && typeof sourceObj[key] === 'object') { - // Recursively handle nested objects + const isNestedObject = typeof value === 'object' && value !== null + const hasSourceValue = key in sourceObj + + if (isNestedObject && hasSourceValue && typeof sourceObj[key] === 'object') { result[key] = updateValues(value as Record, sourceObj[key] as Record) - } else if (key in sourceObj) { - // Replace value from source if it exists - result[key] = sourceObj[key] } else { - // Keep original value if not in source - result[key] = value + result[key] = hasSourceValue ? sourceObj[key] : value } } From f6dd71afc3164a1e0e8acb49dd42a2fc1e21fbae Mon Sep 17 00:00:00 2001 From: Chris Berthe Date: Mon, 24 Feb 2025 16:30:56 -0500 Subject: [PATCH 20/53] refactor: update sync mode option names for clarity --- src/utilities/flags.ts | 12 ++++++------ src/utilities/locales.ts | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/utilities/flags.ts b/src/utilities/flags.ts index b36d78a3..86723dc6 100644 --- a/src/utilities/flags.ts +++ b/src/utilities/flags.ts @@ -1,4 +1,4 @@ -import {Flags as OclifFlags} from '@oclif/core' +import { Flags as OclifFlags } from '@oclif/core' import { FlagInput } from '@oclif/core/interfaces'; import { ComponentConfig } from './types.js' @@ -152,12 +152,12 @@ export const flagDefinitions: Record = { [Flags.SYNC_MODE]: OclifFlags.string({ char: 'm', - default: 'update', + default: 'add-and-override', description: 'Sync mode for locale files:\n' + - '- update: Add new and update modified translations (default)\n' + - '- replace: Replace all translations with source versions\n' + - '- add: Only add new translations, preserve existing ones', - options: ['update', 'replace', 'add'] + '- add-and-override: Add new translations and override existing ones with source values (default)\n' + + '- replace-existing: Replace values of existing translations with source values\n' + + '- add-missing: Only add new translations that do not exist in theme', + options: ['add-and-override', 'add-missing', 'replace-existing'] }), [Flags.TARGET]: OclifFlags.string({ diff --git a/src/utilities/locales.ts b/src/utilities/locales.ts index cc62289f..68380040 100644 --- a/src/utilities/locales.ts +++ b/src/utilities/locales.ts @@ -15,7 +15,7 @@ export interface LocaleDiff { removed: Set } -export type SyncMode = 'add' | 'replace' | 'update' +export type SyncMode = 'add-and-override' | 'add-missing' | 'replace-existing' export interface SyncOptions { format?: boolean @@ -63,7 +63,7 @@ export async function syncLocales( options?: Partial ): Promise { const localesDir = path.join(themeDir, 'locales') - const { format = false, mode = 'update' } = options ?? {} + const { format = false, mode = 'add-and-override' } = options ?? {} for (const [file, sourceContent] of Object.entries(sourceLocales)) { const targetPath = path.join(localesDir, file) @@ -77,9 +77,9 @@ export async function syncLocales( const targetContent = JSON.parse(fs.readFileSync(targetPath, 'utf8')) const diff = diffLocales(sourceContent, targetContent) - const mergedContent = mode === 'replace' + const mergedContent = mode === 'replace-existing' ? replaceTranslations(sourceContent, targetContent) - : mode === 'add' + : mode === 'add-missing' ? addMissingTranslations(sourceContent, targetContent, diff) : mergeTranslations(sourceContent, targetContent, diff) From bf26e178cadabd3fe17d824d6060fe25e1481acf Mon Sep 17 00:00:00 2001 From: Chris Berthe Date: Mon, 24 Feb 2025 16:32:35 -0500 Subject: [PATCH 21/53] refactor: rename sync mode to just mode --- src/commands/theme/locale/sync.ts | 4 ++-- src/utilities/flags.ts | 22 +++++++++++----------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/commands/theme/locale/sync.ts b/src/commands/theme/locale/sync.ts index f6c1ece4..561725cb 100644 --- a/src/commands/theme/locale/sync.ts +++ b/src/commands/theme/locale/sync.ts @@ -37,7 +37,7 @@ export default class Sync extends BaseCommand { Flags.CLEAN, Flags.FORMAT, Flags.LOCALES_DIR, - Flags.SYNC_MODE, + Flags.MODE, Flags.TARGET ]) @@ -69,7 +69,7 @@ export default class Sync extends BaseCommand { ): Promise { const requiredLocales = extractRequiredTranslations(sourceData.locales, translations) const format = this.flags[Flags.FORMAT] - const mode = this.flags[Flags.SYNC_MODE] + const mode = this.flags[Flags.MODE] await syncLocales(themeDir, requiredLocales, { format, mode }) diff --git a/src/utilities/flags.ts b/src/utilities/flags.ts index 86723dc6..27f958ae 100644 --- a/src/utilities/flags.ts +++ b/src/utilities/flags.ts @@ -18,6 +18,7 @@ export default class Flags { static readonly IGNORE_OVERRIDES = 'ignore-overrides'; static readonly LIVE_RELOAD = 'live-reload'; static readonly LOCALES_DIR = 'locales-dir'; + static readonly MODE = 'mode'; static readonly PASSWORD = 'password'; static readonly PORT = 'port'; static readonly PREVIEW = 'preview'; @@ -25,7 +26,6 @@ export default class Flags { static readonly SETUP_FILES = 'setup-files'; static readonly STORE = 'store'; static readonly STORE_PASSWORD = 'store-password'; - static readonly SYNC_MODE = 'sync-mode'; static readonly TARGET = 'target'; static readonly THEME = 'theme'; static readonly THEME_DIR = 'theme-dir'; @@ -113,6 +113,16 @@ export const flagDefinitions: Record = { description: 'Directory or repository containing locale files', }), + [Flags.MODE]: OclifFlags.string({ + char: 'm', + default: 'add-and-override', + description: 'Sync mode for locale files:\n' + + '- add-and-override: Add new translations and override existing ones with source values (default)\n' + + '- replace-existing: Replace values of existing translations with source values\n' + + '- add-missing: Only add new translations that do not exist in theme', + options: ['add-and-override', 'add-missing', 'replace-existing'] + }), + [Flags.PASSWORD]: OclifFlags.string({ description: 'Password generated from the Theme Access app.', }), @@ -150,16 +160,6 @@ export const flagDefinitions: Record = { description: 'The password for storefronts with password protection.', }), - [Flags.SYNC_MODE]: OclifFlags.string({ - char: 'm', - default: 'add-and-override', - description: 'Sync mode for locale files:\n' + - '- add-and-override: Add new translations and override existing ones with source values (default)\n' + - '- replace-existing: Replace values of existing translations with source values\n' + - '- add-missing: Only add new translations that do not exist in theme', - options: ['add-and-override', 'add-missing', 'replace-existing'] - }), - [Flags.TARGET]: OclifFlags.string({ default: 'all', description: 'Target for cleaning: all (default), schema, or storefront', From 674714c8ce47900675588b7853129cc2d1501198 Mon Sep 17 00:00:00 2001 From: Chris Berthe Date: Mon, 24 Feb 2025 16:41:44 -0500 Subject: [PATCH 22/53] refactor: change the default for sync mode for safety --- src/utilities/flags.ts | 10 +++++----- src/utilities/locales.ts | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/utilities/flags.ts b/src/utilities/flags.ts index 27f958ae..c5b0878b 100644 --- a/src/utilities/flags.ts +++ b/src/utilities/flags.ts @@ -115,12 +115,12 @@ export const flagDefinitions: Record = { [Flags.MODE]: OclifFlags.string({ char: 'm', - default: 'add-and-override', + default: 'add-missing', description: 'Sync mode for locale files:\n' + - '- add-and-override: Add new translations and override existing ones with source values (default)\n' + - '- replace-existing: Replace values of existing translations with source values\n' + - '- add-missing: Only add new translations that do not exist in theme', - options: ['add-and-override', 'add-missing', 'replace-existing'] + '- add-missing: Only add new translations that do not exist in theme (default)\n' + + '- add-and-override: Add new translations and override existing ones with source values\n' + + '- replace-existing: Replace values of existing translations with source values', + options: ['add-missing', 'add-and-override', 'replace-existing'] }), [Flags.PASSWORD]: OclifFlags.string({ diff --git a/src/utilities/locales.ts b/src/utilities/locales.ts index 68380040..5720db1d 100644 --- a/src/utilities/locales.ts +++ b/src/utilities/locales.ts @@ -63,7 +63,7 @@ export async function syncLocales( options?: Partial ): Promise { const localesDir = path.join(themeDir, 'locales') - const { format = false, mode = 'add-and-override' } = options ?? {} + const { format = false, mode = 'add-missing' } = options ?? {} for (const [file, sourceContent] of Object.entries(sourceLocales)) { const targetPath = path.join(localesDir, file) From 8552fec94783efcb2d09ab9d4607dfa1b554f637 Mon Sep 17 00:00:00 2001 From: Chris Berthe Date: Mon, 24 Feb 2025 17:03:28 -0500 Subject: [PATCH 23/53] chore: update sync flag descriptions --- src/utilities/flags.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utilities/flags.ts b/src/utilities/flags.ts index c5b0878b..41255cea 100644 --- a/src/utilities/flags.ts +++ b/src/utilities/flags.ts @@ -53,7 +53,7 @@ export const flagDefinitions: Record = { [Flags.CLEAN]: OclifFlags.boolean({ char: 'c', default: false, - description: 'Clean unused translations before syncing' + description: 'Remove unused translations from theme locale files before syncing' }), [Flags.COLLECTION_NAME]: OclifFlags.string({ @@ -72,7 +72,7 @@ export const flagDefinitions: Record = { [Flags.FORMAT]: OclifFlags.boolean({ default: false, - description: 'Format locale files (sort keys alphabetically)', + description: 'Format locale files by sorting keys alphabetically', }), [Flags.GENERATE_IMPORT_MAP]: OclifFlags.boolean({ From 42df8a75f79fb3c93b7022ba597adf066c07b3aa Mon Sep 17 00:00:00 2001 From: Chris Berthe Date: Tue, 25 Feb 2025 11:11:09 -0500 Subject: [PATCH 24/53] chore: only clean after syncing --- src/commands/theme/locale/sync.ts | 8 ++++---- src/utilities/flags.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/commands/theme/locale/sync.ts b/src/commands/theme/locale/sync.ts index 561725cb..32ea2095 100644 --- a/src/commands/theme/locale/sync.ts +++ b/src/commands/theme/locale/sync.ts @@ -46,13 +46,13 @@ export default class Sync extends BaseCommand { const localesDir = this.flags[Flags.LOCALES_DIR] const target = this.flags[Flags.TARGET] as CleanTarget - if (this.flags[Flags.CLEAN]) { - await cleanTranslations(themeDir, target) - } - const translations = getThemeTranslations(themeDir) const sourceLocales = await this.fetchAndAnalyzeSource(localesDir) await this.syncTranslations(themeDir, translations, sourceLocales) + + if (this.flags[Flags.CLEAN]) { + await cleanTranslations(themeDir, target) + } } private async fetchAndAnalyzeSource(localesDir: string): Promise<{ diff --git a/src/utilities/flags.ts b/src/utilities/flags.ts index 41255cea..5ff0597a 100644 --- a/src/utilities/flags.ts +++ b/src/utilities/flags.ts @@ -53,7 +53,7 @@ export const flagDefinitions: Record = { [Flags.CLEAN]: OclifFlags.boolean({ char: 'c', default: false, - description: 'Remove unused translations from theme locale files before syncing' + description: 'Remove unused translations from theme locale files' }), [Flags.COLLECTION_NAME]: OclifFlags.string({ From b187e291aabbd02c54651e81b48891c9b5489fc0 Mon Sep 17 00:00:00 2001 From: Chris Berthe Date: Tue, 25 Feb 2025 11:38:25 -0500 Subject: [PATCH 25/53] chore: ensure target flag works without clean flag in sync --- src/commands/theme/locale/sync.ts | 3 ++- src/utilities/flags.ts | 5 ++++- src/utilities/locales.ts | 14 ++++++++++++-- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/commands/theme/locale/sync.ts b/src/commands/theme/locale/sync.ts index 32ea2095..14cc1ad9 100644 --- a/src/commands/theme/locale/sync.ts +++ b/src/commands/theme/locale/sync.ts @@ -70,8 +70,9 @@ export default class Sync extends BaseCommand { const requiredLocales = extractRequiredTranslations(sourceData.locales, translations) const format = this.flags[Flags.FORMAT] const mode = this.flags[Flags.MODE] + const target = this.flags[Flags.TARGET] as CleanTarget - await syncLocales(themeDir, requiredLocales, { format, mode }) + await syncLocales(themeDir, requiredLocales, { format, mode, target }) if (!this.flags[Flags.QUIET]) { this.log('Successfully synced locale files') diff --git a/src/utilities/flags.ts b/src/utilities/flags.ts index 5ff0597a..35dedddd 100644 --- a/src/utilities/flags.ts +++ b/src/utilities/flags.ts @@ -162,7 +162,10 @@ export const flagDefinitions: Record = { [Flags.TARGET]: OclifFlags.string({ default: 'all', - description: 'Target for cleaning: all (default), schema, or storefront', + description: 'Locale files to target for syncing and cleaning:\n' + + '- all: Process all locale files (default)\n' + + '- schema: Process only schema translations (*.schema.json)\n' + + '- storefront: Process only storefront translations (non-schema files)', options: ['all', 'schema', 'storefront'] }), diff --git a/src/utilities/locales.ts b/src/utilities/locales.ts index 5720db1d..769944ff 100644 --- a/src/utilities/locales.ts +++ b/src/utilities/locales.ts @@ -4,6 +4,7 @@ import path from 'node:path' import { cloneTheme } from './git.js' import { flattenObject, sortObjectKeys, unflattenObject } from './objects.js' +import { CleanTarget } from './translations.js' export interface LocaleContent { [key: string]: Record @@ -20,6 +21,7 @@ export type SyncMode = 'add-and-override' | 'add-missing' | 'replace-existing' export interface SyncOptions { format?: boolean mode: SyncMode + target?: CleanTarget } export async function fetchLocaleSource(source: string): Promise { @@ -63,9 +65,17 @@ export async function syncLocales( options?: Partial ): Promise { const localesDir = path.join(themeDir, 'locales') - const { format = false, mode = 'add-missing' } = options ?? {} + const { format = false, mode = 'add-missing', target = 'all' } = options ?? {} - for (const [file, sourceContent] of Object.entries(sourceLocales)) { + const filesToSync = Object.entries(sourceLocales).filter(([file]) => { + const isSchemaFile = file.endsWith('.schema.json') + + if (target === 'schema') return isSchemaFile + if (target === 'storefront') return !isSchemaFile + return true + }) + + for (const [file, sourceContent] of filesToSync) { const targetPath = path.join(localesDir, file) if (!fs.existsSync(targetPath)) { From 74eef658e32eb5876cb47cdaea57a73ae4dc7030 Mon Sep 17 00:00:00 2001 From: Chris Berthe Date: Tue, 25 Feb 2025 11:44:18 -0500 Subject: [PATCH 26/53] chore: ensure last line in locale files are preserved on write --- src/utilities/flags.ts | 4 ++-- src/utilities/locales.ts | 4 ++-- src/utilities/translations.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/utilities/flags.ts b/src/utilities/flags.ts index 35dedddd..26463041 100644 --- a/src/utilities/flags.ts +++ b/src/utilities/flags.ts @@ -162,10 +162,10 @@ export const flagDefinitions: Record = { [Flags.TARGET]: OclifFlags.string({ default: 'all', - description: 'Locale files to target for syncing and cleaning:\n' + + description: 'Target locale files to process:\n' + '- all: Process all locale files (default)\n' + '- schema: Process only schema translations (*.schema.json)\n' + - '- storefront: Process only storefront translations (non-schema files)', + '- storefront: Process only storefront translations', options: ['all', 'schema', 'storefront'] }), diff --git a/src/utilities/locales.ts b/src/utilities/locales.ts index 769944ff..6e038aa6 100644 --- a/src/utilities/locales.ts +++ b/src/utilities/locales.ts @@ -80,7 +80,7 @@ export async function syncLocales( if (!fs.existsSync(targetPath)) { const content = format ? sortObjectKeys(sourceContent) : sourceContent - fs.writeFileSync(targetPath, JSON.stringify(content, null, 2)) + fs.writeFileSync(targetPath, JSON.stringify(content, null, 2) + '\n') continue } @@ -94,7 +94,7 @@ export async function syncLocales( : mergeTranslations(sourceContent, targetContent, diff) const content = format ? sortObjectKeys(mergedContent) : mergedContent - fs.writeFileSync(targetPath, JSON.stringify(content, null, 2)) + fs.writeFileSync(targetPath, JSON.stringify(content, null, 2) + '\n') } } diff --git a/src/utilities/translations.ts b/src/utilities/translations.ts index 3c123e97..dbac7776 100644 --- a/src/utilities/translations.ts +++ b/src/utilities/translations.ts @@ -168,7 +168,7 @@ function cleanLocaleFile(filePath: string, usedKeys: Set): void { } const unflattened = unflattenObject(cleanedContent) - fs.writeFileSync(filePath, JSON.stringify(unflattened, null, 2)) + fs.writeFileSync(filePath, JSON.stringify(unflattened, null, 2) + '\n') } catch (error) { throw new Error(`Error processing ${path.basename(filePath)}: ${error}`) } From 8194b6127b15501cc0445d7a3bb4d57357bec548 Mon Sep 17 00:00:00 2001 From: Chris Berthe Date: Tue, 25 Feb 2025 15:46:58 -0500 Subject: [PATCH 27/53] chore: fix formatting in dev.ts and git.ts --- src/commands/theme/component/dev.ts | 24 ++++++++++++------------ src/utilities/git.ts | 4 ++-- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/commands/theme/component/dev.ts b/src/commands/theme/component/dev.ts index 9da3cecd..6d15f4cf 100644 --- a/src/commands/theme/component/dev.ts +++ b/src/commands/theme/component/dev.ts @@ -1,6 +1,6 @@ /** * This command sets up a sandboxed development environment for components. - * + * * - Removes any existing development directory * - Copies dev theme files and rendered component files into a temporary development directory * - Watches for changes to synchronize updates @@ -12,7 +12,7 @@ import chokidar from 'chokidar' import path from 'node:path' import { URL } from 'node:url' -import Args from '../../../utilities/args.js' +import Args from '../../../utilities/args.js' import BaseCommand from '../../../utilities/base-command.js' import { cleanDir, syncFiles } from '../../../utilities/files.js' import Flags from '../../../utilities/flags.js' @@ -82,14 +82,14 @@ export default class Dev extends BaseCommand { const themeDir = await this.getThemeDirectory(devDir) this.log(`Building theme in ${devDir}...`) - + const buildThemeParams: BuildThemeParams = { componentSelector, generateTemplateMap, setupFiles, themeDir } - + await this.buildTheme(devDir, buildThemeParams) // Run shopify theme dev if preview is enabled @@ -111,12 +111,12 @@ export default class Dev extends BaseCommand { // Copy the component setup files if needed if (params.setupFiles) { await copySetupComponentFiles( - process.cwd(), - destination, + process.cwd(), + destination, params.componentSelector ) } - + // Install the components await Install.run([destination]) @@ -154,24 +154,24 @@ export default class Dev extends BaseCommand { private async getThemeDirectory(devDir: string): Promise { if (this.flags[Flags.THEME_DIR].startsWith('http')) { const url = new URL(this.flags[Flags.THEME_DIR]) - const {host} = url - + const { host } = url + if (host === 'github.com' || host.endsWith('.github.com')) { const themeDir = path.join(devDir, '.repo') this.log(`Cloning theme from ${this.flags[Flags.THEME_DIR]} into dev directory ${devDir}`) await cloneTheme(this.flags[Flags.THEME_DIR], themeDir) return themeDir } - + throw new Error(`Unsupported theme URL: ${this.flags[Flags.THEME_DIR]}`) } - + return path.resolve(process.cwd(), this.flags[Flags.THEME_DIR]) } private setupWatcher(devDir: string, themeDir: string, componentsDir: string, buildThemeParams: BuildThemeParams): Promise { const watchDir = path.join(devDir, '.watch') - + // Need to access chokidar as a default import so it can be mocked in tests // eslint-disable-next-line import/no-named-as-default-member const themeWatcher = chokidar.watch([themeDir, componentsDir], { diff --git a/src/utilities/git.ts b/src/utilities/git.ts index 7cbb362b..44136875 100644 --- a/src/utilities/git.ts +++ b/src/utilities/git.ts @@ -1,4 +1,4 @@ -import {execSync} from 'node:child_process' +import { execSync } from 'node:child_process' import path from 'node:path' export async function cloneTheme(repoUrl: string, targetDir: string): Promise { @@ -21,4 +21,4 @@ export function getLastCommitHash(directory: string): null | string { } catch { return null } -} \ No newline at end of file +} From 41d4fc15c7645669557036375fa943c0d7b4d49d Mon Sep 17 00:00:00 2001 From: Chris Berthe Date: Tue, 25 Feb 2025 16:07:25 -0500 Subject: [PATCH 28/53] chore: baseline tests for locale command --- test/commands/theme/locale/clean.test.ts | 83 +++++++++++ test/commands/theme/locale/sync.test.ts | 131 ++++++++++++++++++ test/fixtures/locales/en.default.json | 8 ++ test/fixtures/locales/en.default.schema.json | 11 ++ test/fixtures/locales/fr.json | 8 ++ test/fixtures/locales/fr.schema.json | 11 ++ test/fixtures/theme/locales/en.default.json | 6 + .../theme/locales/en.default.schema.json | 9 ++ .../sections/schema-translation-usage.liquid | 12 ++ .../storefront-translation-usage.liquid | 1 + 10 files changed, 280 insertions(+) create mode 100644 test/commands/theme/locale/clean.test.ts create mode 100644 test/commands/theme/locale/sync.test.ts create mode 100644 test/fixtures/locales/en.default.json create mode 100644 test/fixtures/locales/en.default.schema.json create mode 100644 test/fixtures/locales/fr.json create mode 100644 test/fixtures/locales/fr.schema.json create mode 100644 test/fixtures/theme/locales/en.default.json create mode 100644 test/fixtures/theme/locales/en.default.schema.json create mode 100644 test/fixtures/theme/sections/schema-translation-usage.liquid create mode 100644 test/fixtures/theme/snippets/storefront-translation-usage.liquid diff --git a/test/commands/theme/locale/clean.test.ts b/test/commands/theme/locale/clean.test.ts new file mode 100644 index 00000000..d7dd1f4e --- /dev/null +++ b/test/commands/theme/locale/clean.test.ts @@ -0,0 +1,83 @@ +import { runCommand } from '@oclif/test' +import { expect } from 'chai' +import * as fs from 'node:fs' +import * as path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const fixturesPath = path.join(__dirname, '../../../fixtures') +const themePath = path.join(fixturesPath, 'theme') +const testThemePath = path.join(fixturesPath, 'test-theme') +const testThemeLocalesPath = path.join(testThemePath, 'locales') + +describe('theme locale clean', () => { + beforeEach(() => { + fs.cpSync(themePath, testThemePath, { recursive: true }) + process.chdir(testThemePath) + }) + + afterEach(() => { + fs.rmSync(testThemePath, { force: true, recursive: true }) + }) + + it('cleans unused translations from all locale files by default', async () => { + await runCommand(['theme', 'locale', 'clean']) + + const enDefaultContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.json'), 'utf8')) + expect(enDefaultContent).to.not.have.property('unused') + + const enSchemaContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.schema.json'), 'utf8')) + expect(enSchemaContent).to.not.have.property('unused_schema') + }) + + it('cleans only schema files when target is set to schema', async () => { + const backupDir = path.join(testThemePath, 'locales-backup') + fs.cpSync(testThemeLocalesPath, backupDir, { recursive: true }) + + await runCommand(['theme', 'locale', 'clean', '--target', 'schema']) + + const enSchemaContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.schema.json'), 'utf8')) + expect(enSchemaContent).to.not.have.property('unused_schema') + + const enDefaultContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.json'), 'utf8')) + const backupEnDefaultContent = JSON.parse(fs.readFileSync(path.join(backupDir, 'en.default.json'), 'utf8')) + expect(enDefaultContent).to.deep.equal(backupEnDefaultContent) + + fs.rmSync(backupDir, { force: true, recursive: true }) + }) + + it('cleans only storefront files when target is set to storefront', async () => { + const backupDir = path.join(testThemePath, 'locales-backup') + fs.cpSync(testThemeLocalesPath, backupDir, { recursive: true }) + + await runCommand(['theme', 'locale', 'clean', '--target', 'storefront']) + + const enDefaultContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.json'), 'utf8')) + expect(enDefaultContent).to.not.have.property('unused') + + const enSchemaContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.schema.json'), 'utf8')) + const backupEnSchemaContent = JSON.parse(fs.readFileSync(path.join(backupDir, 'en.default.schema.json'), 'utf8')) + expect(enSchemaContent).to.deep.equal(backupEnSchemaContent) + + fs.rmSync(backupDir, { force: true, recursive: true }) + }) + + it('preserves used translations when cleaning', async () => { + await runCommand(['theme', 'locale', 'clean']) + + const enDefaultContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.json'), 'utf8')) + expect(enDefaultContent).to.have.nested.property('actions.add_to_cart') + + const enSchemaContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.schema.json'), 'utf8')) + expect(enSchemaContent).to.have.nested.property('section.name') + expect(enSchemaContent).to.have.nested.property('section.settings.logo_label') + }) + + it('can be run from a theme directory without an argument', async () => { + process.chdir(testThemePath) + await runCommand(['theme', 'locale', 'clean']) + + const enDefaultContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.json'), 'utf8')) + expect(enDefaultContent).to.not.have.property('unused') + }) +}) diff --git a/test/commands/theme/locale/sync.test.ts b/test/commands/theme/locale/sync.test.ts new file mode 100644 index 00000000..14172385 --- /dev/null +++ b/test/commands/theme/locale/sync.test.ts @@ -0,0 +1,131 @@ +import { runCommand } from '@oclif/test' +import { expect } from 'chai' +import * as fs from 'node:fs' +import * as path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const fixturesPath = path.join(__dirname, '../../../fixtures') +const localesPath = path.join(fixturesPath, 'locales') +const themePath = path.join(fixturesPath, 'theme') +const testThemePath = path.join(fixturesPath, 'test-theme') +const testThemeLocalesPath = path.join(testThemePath, 'locales') + +describe('theme locale sync', () => { + beforeEach(() => { + fs.cpSync(themePath, testThemePath, { recursive: true }) + process.chdir(testThemePath) + }) + + afterEach(() => { + fs.rmSync(testThemePath, { force: true, recursive: true }) + }) + + it('syncs locale files from a local source', async () => { + await runCommand(['theme', 'locale', 'sync', '--locales-dir', localesPath]) + + expect(fs.existsSync(path.join(testThemeLocalesPath, 'en.default.json'))).to.be.true + expect(fs.existsSync(path.join(testThemeLocalesPath, 'en.default.schema.json'))).to.be.true + expect(fs.existsSync(path.join(testThemeLocalesPath, 'fr.json'))).to.be.true + expect(fs.existsSync(path.join(testThemeLocalesPath, 'fr.schema.json'))).to.be.true + + const content = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.json'), 'utf8')) + expect(content).to.have.nested.property('actions.add_to_cart') + + const frContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'fr.json'), 'utf8')) + expect(frContent).to.have.nested.property('actions.add_to_cart') + }) + + it('syncs only schema files when target is set to schema', async () => { + const backupDir = path.join(testThemePath, 'locales-backup') + fs.cpSync(testThemeLocalesPath, backupDir, { recursive: true }) + + await runCommand(['theme', 'locale', 'sync', '--locales-dir', localesPath, '--target', 'schema']) + + const enSchemaContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.schema.json'), 'utf8')) + expect(enSchemaContent).to.have.nested.property('section.name') + + const enDefaultContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.json'), 'utf8')) + const backupEnDefaultContent = JSON.parse(fs.readFileSync(path.join(backupDir, 'en.default.json'), 'utf8')) + expect(enDefaultContent).to.deep.equal(backupEnDefaultContent) + + fs.rmSync(backupDir, { force: true, recursive: true }) + }) + + it('syncs only storefront files when target is set to storefront', async () => { + const backupDir = path.join(testThemePath, 'locales-backup') + fs.cpSync(testThemeLocalesPath, backupDir, { recursive: true }) + + await runCommand(['theme', 'locale', 'sync', '--locales-dir', localesPath, '--target', 'storefront']) + + const enDefaultContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.json'), 'utf8')) + expect(enDefaultContent).to.have.nested.property('actions.add_to_cart') + + const enSchemaContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.schema.json'), 'utf8')) + const backupEnSchemaContent = JSON.parse(fs.readFileSync(path.join(backupDir, 'en.default.schema.json'), 'utf8')) + expect(enSchemaContent).to.deep.equal(backupEnSchemaContent) + + fs.rmSync(backupDir, { force: true, recursive: true }) + }) + + it('adds missing translations when mode is set to add-missing', async () => { + const enDefaultPath = path.join(testThemeLocalesPath, 'en.default.json') + const enDefault = JSON.parse(fs.readFileSync(enDefaultPath, 'utf8')) + enDefault.custom = { key: 'This should not be changed' } + fs.writeFileSync(enDefaultPath, JSON.stringify(enDefault, null, 2)) + + const originalAddToCartValue = enDefault.actions.add_to_cart + + await runCommand(['theme', 'locale', 'sync', '--locales-dir', localesPath, '--mode', 'add-missing']) + + const content = JSON.parse(fs.readFileSync(enDefaultPath, 'utf8')) + expect(content.custom.key).to.equal('This should not be changed') + expect(content.actions.add_to_cart).to.equal(originalAddToCartValue) + }) + + it('replaces existing translations when mode is set to replace-existing', async () => { + const enDefaultPath = path.join(testThemeLocalesPath, 'en.default.json') + const enDefault = JSON.parse(fs.readFileSync(enDefaultPath, 'utf8')) + const originalAddToCartValue = enDefault.actions.add_to_cart + + await runCommand(['theme', 'locale', 'sync', '--locales-dir', localesPath, '--mode', 'replace-existing']) + + const content = JSON.parse(fs.readFileSync(enDefaultPath, 'utf8')) + expect(content.actions.add_to_cart).to.not.equal(originalAddToCartValue) + }) + + it('adds and overrides translations when mode is set to add-and-override', async () => { + const enDefaultPath = path.join(testThemeLocalesPath, 'en.default.json') + const enDefault = JSON.parse(fs.readFileSync(enDefaultPath, 'utf8')) + enDefault.custom = { key: 'This should not be changed' } + + const originalAddToCartValue = enDefault.actions.add_to_cart + + fs.writeFileSync(enDefaultPath, JSON.stringify(enDefault, null, 2)) + + await runCommand(['theme', 'locale', 'sync', '--locales-dir', localesPath, '--mode', 'add-and-override']) + + const content = JSON.parse(fs.readFileSync(enDefaultPath, 'utf8')) + expect(content.custom.key).to.equal('This should not be changed') + + expect(content.actions.add_to_cart).to.not.equal(originalAddToCartValue) + }) + + it('formats the output files when format flag is set', async () => { + await runCommand(['theme', 'locale', 'sync', '--locales-dir', localesPath, '--format']) + + const fileContent = fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.json'), 'utf8') + + expect(fileContent).to.include(' "actions": {') + expect(fileContent).to.include(' "add_to_cart"') + }) + + it('cleans locale files when clean flag is set', async () => { + await runCommand(['theme', 'locale', 'sync', '--locales-dir', localesPath, '--clean']) + + const content = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.json'), 'utf8')) + expect(content).to.not.have.property('unused') + + expect(content).to.have.nested.property('actions.add_to_cart') + }) +}) diff --git a/test/fixtures/locales/en.default.json b/test/fixtures/locales/en.default.json new file mode 100644 index 00000000..7e2fb5ca --- /dev/null +++ b/test/fixtures/locales/en.default.json @@ -0,0 +1,8 @@ +{ + "actions": { + "add_to_cart": "Add to cart (source)" + }, + "additional": { + "new_key": "This is a new translation from source" + } +} diff --git a/test/fixtures/locales/en.default.schema.json b/test/fixtures/locales/en.default.schema.json new file mode 100644 index 00000000..a13055c1 --- /dev/null +++ b/test/fixtures/locales/en.default.schema.json @@ -0,0 +1,11 @@ +{ + "section": { + "name": "Section schema (source)", + "settings": { + "logo_label": "Logo Setting (source)" + } + }, + "additional": { + "new_setting": "This is a new schema setting from source" + } +} diff --git a/test/fixtures/locales/fr.json b/test/fixtures/locales/fr.json new file mode 100644 index 00000000..49574547 --- /dev/null +++ b/test/fixtures/locales/fr.json @@ -0,0 +1,8 @@ +{ + "actions": { + "add_to_cart": "Ajouter au panier (source)" + }, + "additional": { + "new_key": "Ceci est une nouvelle traduction de la source" + } +} diff --git a/test/fixtures/locales/fr.schema.json b/test/fixtures/locales/fr.schema.json new file mode 100644 index 00000000..d437da64 --- /dev/null +++ b/test/fixtures/locales/fr.schema.json @@ -0,0 +1,11 @@ +{ + "section": { + "name": "Schéma de section (source)", + "settings": { + "logo_label": "Paramètre de Logo (source)" + } + }, + "additional": { + "new_setting": "Ceci est un nouveau paramètre de schéma de la source" + } +} diff --git a/test/fixtures/theme/locales/en.default.json b/test/fixtures/theme/locales/en.default.json new file mode 100644 index 00000000..afb3d9e6 --- /dev/null +++ b/test/fixtures/theme/locales/en.default.json @@ -0,0 +1,6 @@ +{ + "actions": { + "add_to_cart": "Add to cart" + }, + "unused": "This is an unused translation" +} diff --git a/test/fixtures/theme/locales/en.default.schema.json b/test/fixtures/theme/locales/en.default.schema.json new file mode 100644 index 00000000..86d6bd5e --- /dev/null +++ b/test/fixtures/theme/locales/en.default.schema.json @@ -0,0 +1,9 @@ +{ + "section": { + "name": "Section schema", + "settings": { + "logo_label": "Logo Setting" + } + }, + "unused_schema": "This is an unused schema translation" +} diff --git a/test/fixtures/theme/sections/schema-translation-usage.liquid b/test/fixtures/theme/sections/schema-translation-usage.liquid new file mode 100644 index 00000000..56d9416a --- /dev/null +++ b/test/fixtures/theme/sections/schema-translation-usage.liquid @@ -0,0 +1,12 @@ +{% schema %} +{ + "name": "t:section.name", + "settings": [ + { + "type": "image_picker", + "id": "logo", + "label": "t:section.settings.logo_label" + } + ] +} +{% endschema %} diff --git a/test/fixtures/theme/snippets/storefront-translation-usage.liquid b/test/fixtures/theme/snippets/storefront-translation-usage.liquid new file mode 100644 index 00000000..d7729eff --- /dev/null +++ b/test/fixtures/theme/snippets/storefront-translation-usage.liquid @@ -0,0 +1 @@ + From c06608a29b8ba45b1f4b649003060bd5b6b105ab Mon Sep 17 00:00:00 2001 From: Chris Berthe Date: Tue, 25 Feb 2025 16:25:24 -0500 Subject: [PATCH 29/53] chore: update unused_schema key to unused for consistency --- test/commands/theme/locale/clean.test.ts | 4 ++-- test/fixtures/theme/locales/en.default.schema.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/test/commands/theme/locale/clean.test.ts b/test/commands/theme/locale/clean.test.ts index d7dd1f4e..80978cf5 100644 --- a/test/commands/theme/locale/clean.test.ts +++ b/test/commands/theme/locale/clean.test.ts @@ -27,7 +27,7 @@ describe('theme locale clean', () => { expect(enDefaultContent).to.not.have.property('unused') const enSchemaContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.schema.json'), 'utf8')) - expect(enSchemaContent).to.not.have.property('unused_schema') + expect(enSchemaContent).to.not.have.property('unused') }) it('cleans only schema files when target is set to schema', async () => { @@ -37,7 +37,7 @@ describe('theme locale clean', () => { await runCommand(['theme', 'locale', 'clean', '--target', 'schema']) const enSchemaContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.schema.json'), 'utf8')) - expect(enSchemaContent).to.not.have.property('unused_schema') + expect(enSchemaContent).to.not.have.property('unused') const enDefaultContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.json'), 'utf8')) const backupEnDefaultContent = JSON.parse(fs.readFileSync(path.join(backupDir, 'en.default.json'), 'utf8')) diff --git a/test/fixtures/theme/locales/en.default.schema.json b/test/fixtures/theme/locales/en.default.schema.json index 86d6bd5e..6bcf3553 100644 --- a/test/fixtures/theme/locales/en.default.schema.json +++ b/test/fixtures/theme/locales/en.default.schema.json @@ -5,5 +5,5 @@ "logo_label": "Logo Setting" } }, - "unused_schema": "This is an unused schema translation" + "unused": "This is an unused schema translation" } From 1839669007dd2d76ff7ee009b67f71f5a08e67f1 Mon Sep 17 00:00:00 2001 From: Chris Berthe Date: Tue, 25 Feb 2025 17:11:55 -0500 Subject: [PATCH 30/53] refactor: format flag for clean command --- src/commands/theme/locale/clean.ts | 7 +++-- src/commands/theme/locale/sync.ts | 18 ++++++++----- src/utilities/locales.ts | 17 +++++-------- src/utilities/translations.ts | 41 +++++++++++++++++++++--------- 4 files changed, 53 insertions(+), 30 deletions(-) diff --git a/src/commands/theme/locale/clean.ts b/src/commands/theme/locale/clean.ts index 1cda2528..ce73c145 100644 --- a/src/commands/theme/locale/clean.ts +++ b/src/commands/theme/locale/clean.ts @@ -10,7 +10,7 @@ import path from 'node:path' import Args from '../../../utilities/args.js' import BaseCommand from '../../../utilities/base-command.js' import Flags from '../../../utilities/flags.js' -import { CleanTarget, cleanTranslations } from '../../../utilities/translations.js' +import { CleanTarget, FormatOptions, cleanTranslations } from '../../../utilities/translations.js' export default class Clean extends BaseCommand { static override args = Args.getDefinitions([ @@ -26,6 +26,7 @@ export default class Clean extends BaseCommand { ] static override flags = Flags.getDefinitions([ + Flags.FORMAT, Flags.TARGET ]) @@ -36,8 +37,10 @@ export default class Clean extends BaseCommand { public async run(): Promise { const themeDir = path.resolve(process.cwd(), this.args[Args.THEME_DIR]) const target = this.flags[Flags.TARGET] as CleanTarget + const format = this.flags[Flags.FORMAT] - await cleanTranslations(themeDir, target) + const options: FormatOptions = { format } + await cleanTranslations(themeDir, target, options) if (!this.flags[Flags.QUIET]) { this.log('Successfully cleaned locale files') diff --git a/src/commands/theme/locale/sync.ts b/src/commands/theme/locale/sync.ts index 14cc1ad9..fbd976e6 100644 --- a/src/commands/theme/locale/sync.ts +++ b/src/commands/theme/locale/sync.ts @@ -11,9 +11,10 @@ import path from 'node:path' import Args from '../../../utilities/args.js' import BaseCommand from '../../../utilities/base-command.js' import Flags from '../../../utilities/flags.js' -import { fetchLocaleSource, syncLocales } from '../../../utilities/locales.js' +import { SyncOptions, fetchLocaleSource, syncLocales } from '../../../utilities/locales.js' import { CleanTarget, + FormatOptions, ThemeTranslations, cleanTranslations, extractRequiredTranslations, @@ -45,13 +46,15 @@ export default class Sync extends BaseCommand { const themeDir = path.resolve(process.cwd(), this.args[Args.THEME_DIR]) const localesDir = this.flags[Flags.LOCALES_DIR] const target = this.flags[Flags.TARGET] as CleanTarget + const format = this.flags[Flags.FORMAT] const translations = getThemeTranslations(themeDir) const sourceLocales = await this.fetchAndAnalyzeSource(localesDir) await this.syncTranslations(themeDir, translations, sourceLocales) if (this.flags[Flags.CLEAN]) { - await cleanTranslations(themeDir, target) + const options: FormatOptions = { format } + await cleanTranslations(themeDir, target, options) } } @@ -68,11 +71,14 @@ export default class Sync extends BaseCommand { sourceData: { locales: Record> } ): Promise { const requiredLocales = extractRequiredTranslations(sourceData.locales, translations) - const format = this.flags[Flags.FORMAT] - const mode = this.flags[Flags.MODE] - const target = this.flags[Flags.TARGET] as CleanTarget - await syncLocales(themeDir, requiredLocales, { format, mode, target }) + const options: SyncOptions = { + format: this.flags[Flags.FORMAT], + mode: this.flags[Flags.MODE], + target: this.flags[Flags.TARGET] as CleanTarget + } + + await syncLocales(themeDir, requiredLocales, options) if (!this.flags[Flags.QUIET]) { this.log('Successfully synced locale files') diff --git a/src/utilities/locales.ts b/src/utilities/locales.ts index 6e038aa6..3b1829d1 100644 --- a/src/utilities/locales.ts +++ b/src/utilities/locales.ts @@ -3,8 +3,8 @@ import { tmpdir } from 'node:os' import path from 'node:path' import { cloneTheme } from './git.js' -import { flattenObject, sortObjectKeys, unflattenObject } from './objects.js' -import { CleanTarget } from './translations.js' +import { flattenObject, unflattenObject } from './objects.js' +import { CleanTarget, FormatOptions, writeLocaleFile } from './translations.js' export interface LocaleContent { [key: string]: Record @@ -18,9 +18,8 @@ export interface LocaleDiff { export type SyncMode = 'add-and-override' | 'add-missing' | 'replace-existing' -export interface SyncOptions { - format?: boolean - mode: SyncMode +export interface SyncOptions extends FormatOptions { + mode?: SyncMode target?: CleanTarget } @@ -62,7 +61,7 @@ function loadLocalLocales(dir: string): LocaleContent { export async function syncLocales( themeDir: string, sourceLocales: Record>, - options?: Partial + options?: SyncOptions ): Promise { const localesDir = path.join(themeDir, 'locales') const { format = false, mode = 'add-missing', target = 'all' } = options ?? {} @@ -79,8 +78,7 @@ export async function syncLocales( const targetPath = path.join(localesDir, file) if (!fs.existsSync(targetPath)) { - const content = format ? sortObjectKeys(sourceContent) : sourceContent - fs.writeFileSync(targetPath, JSON.stringify(content, null, 2) + '\n') + writeLocaleFile(targetPath, sourceContent, { format }) continue } @@ -93,8 +91,7 @@ export async function syncLocales( ? addMissingTranslations(sourceContent, targetContent, diff) : mergeTranslations(sourceContent, targetContent, diff) - const content = format ? sortObjectKeys(mergedContent) : mergedContent - fs.writeFileSync(targetPath, JSON.stringify(content, null, 2) + '\n') + writeLocaleFile(targetPath, mergedContent, { format }) } } diff --git a/src/utilities/translations.ts b/src/utilities/translations.ts index dbac7776..26850324 100644 --- a/src/utilities/translations.ts +++ b/src/utilities/translations.ts @@ -2,7 +2,7 @@ import fs from 'node:fs' import path from 'node:path' import { LocaleContent } from './locales.js' -import { flattenObject, unflattenObject } from './objects.js' +import { flattenObject, sortObjectKeys, unflattenObject } from './objects.js' export interface ThemeTranslations { schema: Set @@ -11,9 +11,22 @@ export interface ThemeTranslations { export type CleanTarget = 'all' | 'schema' | 'storefront' +export interface FormatOptions { + format?: boolean +} + const SCHEMA_DIRS = ['config', 'blocks', 'sections'] as const const STOREFRONT_DIRS = ['blocks', 'layout', 'sections', 'snippets', 'templates'] as const +export function writeLocaleFile( + filePath: string, + content: Record, + options?: FormatOptions +): void { + const formattedContent = options?.format ? sortObjectKeys(content) : content + fs.writeFileSync(filePath, JSON.stringify(formattedContent, null, 2) + '\n') +} + export function getThemeTranslations(themeDir: string): ThemeTranslations { return { schema: scanFiles(themeDir, SCHEMA_DIRS, findSchemaKeys), @@ -21,25 +34,25 @@ export function getThemeTranslations(themeDir: string): ThemeTranslations { } } -export function cleanSchemaTranslations(themeDir: string): void { +export function cleanSchemaTranslations(themeDir: string, options?: FormatOptions): void { const usedKeys = scanFiles(themeDir, SCHEMA_DIRS, findSchemaKeys) const localesDir = path.join(themeDir, 'locales') const schemaFiles = fs.readdirSync(localesDir) .filter(file => file.endsWith('.schema.json')) for (const file of schemaFiles) { - cleanLocaleFile(path.join(localesDir, file), usedKeys) + cleanLocaleFile(path.join(localesDir, file), usedKeys, options) } } -export function cleanStorefrontTranslations(themeDir: string): void { +export function cleanStorefrontTranslations(themeDir: string, options?: FormatOptions): void { const usedKeys = scanFiles(themeDir, STOREFRONT_DIRS, findStorefrontKeys) const localesDir = path.join(themeDir, 'locales') const localeFiles = fs.readdirSync(localesDir) .filter(file => file.endsWith('.json') && !file.endsWith('.schema.json')) for (const file of localeFiles) { - cleanLocaleFile(path.join(localesDir, file), usedKeys) + cleanLocaleFile(path.join(localesDir, file), usedKeys, options) } } @@ -152,7 +165,7 @@ function findVariableFallbackKeys(content: string, assignedTranslations: Map): void { +function cleanLocaleFile(filePath: string, usedKeys: Set, options?: FormatOptions): void { try { const content = JSON.parse(fs.readFileSync(filePath, 'utf8')) if (!content || typeof content !== 'object') return @@ -168,7 +181,7 @@ function cleanLocaleFile(filePath: string, usedKeys: Set): void { } const unflattened = unflattenObject(cleanedContent) - fs.writeFileSync(filePath, JSON.stringify(unflattened, null, 2) + '\n') + writeLocaleFile(filePath, unflattened, options) } catch (error) { throw new Error(`Error processing ${path.basename(filePath)}: ${error}`) } @@ -199,21 +212,25 @@ export function extractRequiredTranslations( return result } -export async function cleanTranslations(themeDir: string, target: CleanTarget): Promise { +export async function cleanTranslations( + themeDir: string, + target: CleanTarget = 'all', + options?: FormatOptions +): Promise { switch (target) { case 'schema': { - await cleanSchemaTranslations(themeDir) + await cleanSchemaTranslations(themeDir, options) break } case 'storefront': { - await cleanStorefrontTranslations(themeDir) + await cleanStorefrontTranslations(themeDir, options) break } case 'all': { - await cleanSchemaTranslations(themeDir) - await cleanStorefrontTranslations(themeDir) + await cleanSchemaTranslations(themeDir, options) + await cleanStorefrontTranslations(themeDir, options) break } } From d84cbad2d3a3ff37bd34098226c216065fa68933 Mon Sep 17 00:00:00 2001 From: Chris Berthe Date: Tue, 25 Feb 2025 17:33:05 -0500 Subject: [PATCH 31/53] chore: add format tests for sync command --- test/commands/theme/locale/clean.test.ts | 73 ++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/test/commands/theme/locale/clean.test.ts b/test/commands/theme/locale/clean.test.ts index 80978cf5..a7bfdab1 100644 --- a/test/commands/theme/locale/clean.test.ts +++ b/test/commands/theme/locale/clean.test.ts @@ -80,4 +80,77 @@ describe('theme locale clean', () => { const enDefaultContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.json'), 'utf8')) expect(enDefaultContent).to.not.have.property('unused') }) + + it('formats the output files when format flag is set', async () => { + await runCommand(['theme', 'locale', 'clean', '--format']) + + const fileContent = fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.json'), 'utf8') + + expect(fileContent).to.include(' "actions": {') + expect(fileContent).to.include(' "add_to_cart"') + + const content = JSON.parse(fileContent) + expect(content).to.not.have.property('unused') + expect(content).to.have.nested.property('actions.add_to_cart') + }) + + it('formats only target files when used with target flag', async () => { + const backupDir = path.join(testThemePath, 'locales-backup') + fs.mkdirSync(backupDir, { recursive: true }) + fs.copyFileSync( + path.join(testThemeLocalesPath, 'en.default.json'), + path.join(backupDir, 'en.default.json') + ) + fs.copyFileSync( + path.join(testThemeLocalesPath, 'en.default.schema.json'), + path.join(backupDir, 'en.default.schema.json') + ) + + const schemaFile = path.join(testThemeLocalesPath, 'en.default.schema.json') + const storefrontFile = path.join(testThemeLocalesPath, 'en.default.json') + + const schemaContent = JSON.parse(fs.readFileSync(schemaFile, 'utf8')) + const storefrontContent = JSON.parse(fs.readFileSync(storefrontFile, 'utf8')) + + fs.writeFileSync(schemaFile, JSON.stringify(schemaContent)) + fs.writeFileSync(storefrontFile, JSON.stringify(storefrontContent)) + + await runCommand(['theme', 'locale', 'clean', '--format', '--target', 'schema']) + + const formattedSchemaContent = fs.readFileSync(schemaFile, 'utf8') + const formattedStorefrontContent = fs.readFileSync(storefrontFile, 'utf8') + + expect(formattedSchemaContent).to.include(' "section": {') + expect(formattedSchemaContent).to.include(' "name"') + expect(formattedStorefrontContent).not.to.include(' "actions": {') + + fs.rmSync(backupDir, { force: true, recursive: true }) + }) + + it('formats nested objects correctly when format flag is set', async () => { + const schemaFile = path.join(testThemeLocalesPath, 'en.default.schema.json') + const originalContent = JSON.parse(fs.readFileSync(schemaFile, 'utf8')) + fs.writeFileSync(schemaFile, JSON.stringify(originalContent)) + + await runCommand(['theme', 'locale', 'clean', '--format']) + + const formattedContent = fs.readFileSync(schemaFile, 'utf8') + + expect(formattedContent).to.include(' "section": {') + expect(formattedContent).to.include(' "name"') + expect(formattedContent).to.include(' "settings": {') + expect(formattedContent).to.include(' "logo_label"') + + const parsedContent = JSON.parse(formattedContent) + const keys = Object.keys(parsedContent) + + if (keys.length > 1) { + const sectionIndex = keys.indexOf('section') + const additionalIndex = keys.indexOf('additional') + + if (sectionIndex !== -1 && additionalIndex !== -1) { + expect(additionalIndex).to.be.lessThan(sectionIndex) + } + } + }) }) From 12c8e113bf78ddee8eb20ec9a9c658c0acc0ecb2 Mon Sep 17 00:00:00 2001 From: Chris Berthe Date: Tue, 25 Feb 2025 17:37:02 -0500 Subject: [PATCH 32/53] chore: rename variables in locale tests for consistency --- test/commands/theme/locale/clean.test.ts | 96 ++++++++++++------------ test/commands/theme/locale/sync.test.ts | 82 ++++++++++---------- 2 files changed, 89 insertions(+), 89 deletions(-) diff --git a/test/commands/theme/locale/clean.test.ts b/test/commands/theme/locale/clean.test.ts index a7bfdab1..2cf30ca9 100644 --- a/test/commands/theme/locale/clean.test.ts +++ b/test/commands/theme/locale/clean.test.ts @@ -23,11 +23,11 @@ describe('theme locale clean', () => { it('cleans unused translations from all locale files by default', async () => { await runCommand(['theme', 'locale', 'clean']) - const enDefaultContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.json'), 'utf8')) - expect(enDefaultContent).to.not.have.property('unused') + const storefrontContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.json'), 'utf8')) + expect(storefrontContent).to.not.have.property('unused') - const enSchemaContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.schema.json'), 'utf8')) - expect(enSchemaContent).to.not.have.property('unused') + const schemaContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.schema.json'), 'utf8')) + expect(schemaContent).to.not.have.property('unused') }) it('cleans only schema files when target is set to schema', async () => { @@ -36,12 +36,12 @@ describe('theme locale clean', () => { await runCommand(['theme', 'locale', 'clean', '--target', 'schema']) - const enSchemaContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.schema.json'), 'utf8')) - expect(enSchemaContent).to.not.have.property('unused') + const schemaContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.schema.json'), 'utf8')) + expect(schemaContent).to.not.have.property('unused') - const enDefaultContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.json'), 'utf8')) - const backupEnDefaultContent = JSON.parse(fs.readFileSync(path.join(backupDir, 'en.default.json'), 'utf8')) - expect(enDefaultContent).to.deep.equal(backupEnDefaultContent) + const storefrontContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.json'), 'utf8')) + const backupStorefrontContent = JSON.parse(fs.readFileSync(path.join(backupDir, 'en.default.json'), 'utf8')) + expect(storefrontContent).to.deep.equal(backupStorefrontContent) fs.rmSync(backupDir, { force: true, recursive: true }) }) @@ -52,12 +52,12 @@ describe('theme locale clean', () => { await runCommand(['theme', 'locale', 'clean', '--target', 'storefront']) - const enDefaultContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.json'), 'utf8')) - expect(enDefaultContent).to.not.have.property('unused') + const storefrontContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.json'), 'utf8')) + expect(storefrontContent).to.not.have.property('unused') - const enSchemaContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.schema.json'), 'utf8')) - const backupEnSchemaContent = JSON.parse(fs.readFileSync(path.join(backupDir, 'en.default.schema.json'), 'utf8')) - expect(enSchemaContent).to.deep.equal(backupEnSchemaContent) + const schemaContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.schema.json'), 'utf8')) + const backupSchemaContent = JSON.parse(fs.readFileSync(path.join(backupDir, 'en.default.schema.json'), 'utf8')) + expect(schemaContent).to.deep.equal(backupSchemaContent) fs.rmSync(backupDir, { force: true, recursive: true }) }) @@ -65,33 +65,33 @@ describe('theme locale clean', () => { it('preserves used translations when cleaning', async () => { await runCommand(['theme', 'locale', 'clean']) - const enDefaultContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.json'), 'utf8')) - expect(enDefaultContent).to.have.nested.property('actions.add_to_cart') + const storefrontContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.json'), 'utf8')) + expect(storefrontContent).to.have.nested.property('actions.add_to_cart') - const enSchemaContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.schema.json'), 'utf8')) - expect(enSchemaContent).to.have.nested.property('section.name') - expect(enSchemaContent).to.have.nested.property('section.settings.logo_label') + const schemaContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.schema.json'), 'utf8')) + expect(schemaContent).to.have.nested.property('section.name') + expect(schemaContent).to.have.nested.property('section.settings.logo_label') }) it('can be run from a theme directory without an argument', async () => { process.chdir(testThemePath) await runCommand(['theme', 'locale', 'clean']) - const enDefaultContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.json'), 'utf8')) - expect(enDefaultContent).to.not.have.property('unused') + const storefrontContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.json'), 'utf8')) + expect(storefrontContent).to.not.have.property('unused') }) it('formats the output files when format flag is set', async () => { await runCommand(['theme', 'locale', 'clean', '--format']) - const fileContent = fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.json'), 'utf8') + const storefrontFileContent = fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.json'), 'utf8') - expect(fileContent).to.include(' "actions": {') - expect(fileContent).to.include(' "add_to_cart"') + expect(storefrontFileContent).to.include(' "actions": {') + expect(storefrontFileContent).to.include(' "add_to_cart"') - const content = JSON.parse(fileContent) - expect(content).to.not.have.property('unused') - expect(content).to.have.nested.property('actions.add_to_cart') + const storefrontContent = JSON.parse(storefrontFileContent) + expect(storefrontContent).to.not.have.property('unused') + expect(storefrontContent).to.have.nested.property('actions.add_to_cart') }) it('formats only target files when used with target flag', async () => { @@ -106,43 +106,43 @@ describe('theme locale clean', () => { path.join(backupDir, 'en.default.schema.json') ) - const schemaFile = path.join(testThemeLocalesPath, 'en.default.schema.json') - const storefrontFile = path.join(testThemeLocalesPath, 'en.default.json') + const schemaFilePath = path.join(testThemeLocalesPath, 'en.default.schema.json') + const storefrontFilePath = path.join(testThemeLocalesPath, 'en.default.json') - const schemaContent = JSON.parse(fs.readFileSync(schemaFile, 'utf8')) - const storefrontContent = JSON.parse(fs.readFileSync(storefrontFile, 'utf8')) + const schemaContent = JSON.parse(fs.readFileSync(schemaFilePath, 'utf8')) + const storefrontContent = JSON.parse(fs.readFileSync(storefrontFilePath, 'utf8')) - fs.writeFileSync(schemaFile, JSON.stringify(schemaContent)) - fs.writeFileSync(storefrontFile, JSON.stringify(storefrontContent)) + fs.writeFileSync(schemaFilePath, JSON.stringify(schemaContent)) + fs.writeFileSync(storefrontFilePath, JSON.stringify(storefrontContent)) await runCommand(['theme', 'locale', 'clean', '--format', '--target', 'schema']) - const formattedSchemaContent = fs.readFileSync(schemaFile, 'utf8') - const formattedStorefrontContent = fs.readFileSync(storefrontFile, 'utf8') + const formattedSchemaFileContent = fs.readFileSync(schemaFilePath, 'utf8') + const storefrontFileContent = fs.readFileSync(storefrontFilePath, 'utf8') - expect(formattedSchemaContent).to.include(' "section": {') - expect(formattedSchemaContent).to.include(' "name"') - expect(formattedStorefrontContent).not.to.include(' "actions": {') + expect(formattedSchemaFileContent).to.include(' "section": {') + expect(formattedSchemaFileContent).to.include(' "name"') + expect(storefrontFileContent).not.to.include(' "actions": {') fs.rmSync(backupDir, { force: true, recursive: true }) }) it('formats nested objects correctly when format flag is set', async () => { - const schemaFile = path.join(testThemeLocalesPath, 'en.default.schema.json') - const originalContent = JSON.parse(fs.readFileSync(schemaFile, 'utf8')) - fs.writeFileSync(schemaFile, JSON.stringify(originalContent)) + const schemaFilePath = path.join(testThemeLocalesPath, 'en.default.schema.json') + const originalSchemaContent = JSON.parse(fs.readFileSync(schemaFilePath, 'utf8')) + fs.writeFileSync(schemaFilePath, JSON.stringify(originalSchemaContent)) await runCommand(['theme', 'locale', 'clean', '--format']) - const formattedContent = fs.readFileSync(schemaFile, 'utf8') + const formattedSchemaFileContent = fs.readFileSync(schemaFilePath, 'utf8') - expect(formattedContent).to.include(' "section": {') - expect(formattedContent).to.include(' "name"') - expect(formattedContent).to.include(' "settings": {') - expect(formattedContent).to.include(' "logo_label"') + expect(formattedSchemaFileContent).to.include(' "section": {') + expect(formattedSchemaFileContent).to.include(' "name"') + expect(formattedSchemaFileContent).to.include(' "settings": {') + expect(formattedSchemaFileContent).to.include(' "logo_label"') - const parsedContent = JSON.parse(formattedContent) - const keys = Object.keys(parsedContent) + const formattedSchemaContent = JSON.parse(formattedSchemaFileContent) + const keys = Object.keys(formattedSchemaContent) if (keys.length > 1) { const sectionIndex = keys.indexOf('section') diff --git a/test/commands/theme/locale/sync.test.ts b/test/commands/theme/locale/sync.test.ts index 14172385..5b63ec75 100644 --- a/test/commands/theme/locale/sync.test.ts +++ b/test/commands/theme/locale/sync.test.ts @@ -29,11 +29,11 @@ describe('theme locale sync', () => { expect(fs.existsSync(path.join(testThemeLocalesPath, 'fr.json'))).to.be.true expect(fs.existsSync(path.join(testThemeLocalesPath, 'fr.schema.json'))).to.be.true - const content = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.json'), 'utf8')) - expect(content).to.have.nested.property('actions.add_to_cart') + const storefrontContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.json'), 'utf8')) + expect(storefrontContent).to.have.nested.property('actions.add_to_cart') - const frContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'fr.json'), 'utf8')) - expect(frContent).to.have.nested.property('actions.add_to_cart') + const frStorefrontContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'fr.json'), 'utf8')) + expect(frStorefrontContent).to.have.nested.property('actions.add_to_cart') }) it('syncs only schema files when target is set to schema', async () => { @@ -42,12 +42,12 @@ describe('theme locale sync', () => { await runCommand(['theme', 'locale', 'sync', '--locales-dir', localesPath, '--target', 'schema']) - const enSchemaContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.schema.json'), 'utf8')) - expect(enSchemaContent).to.have.nested.property('section.name') + const schemaContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.schema.json'), 'utf8')) + expect(schemaContent).to.have.nested.property('section.name') - const enDefaultContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.json'), 'utf8')) - const backupEnDefaultContent = JSON.parse(fs.readFileSync(path.join(backupDir, 'en.default.json'), 'utf8')) - expect(enDefaultContent).to.deep.equal(backupEnDefaultContent) + const storefrontContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.json'), 'utf8')) + const backupStorefrontContent = JSON.parse(fs.readFileSync(path.join(backupDir, 'en.default.json'), 'utf8')) + expect(storefrontContent).to.deep.equal(backupStorefrontContent) fs.rmSync(backupDir, { force: true, recursive: true }) }) @@ -58,74 +58,74 @@ describe('theme locale sync', () => { await runCommand(['theme', 'locale', 'sync', '--locales-dir', localesPath, '--target', 'storefront']) - const enDefaultContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.json'), 'utf8')) - expect(enDefaultContent).to.have.nested.property('actions.add_to_cart') + const storefrontContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.json'), 'utf8')) + expect(storefrontContent).to.have.nested.property('actions.add_to_cart') - const enSchemaContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.schema.json'), 'utf8')) - const backupEnSchemaContent = JSON.parse(fs.readFileSync(path.join(backupDir, 'en.default.schema.json'), 'utf8')) - expect(enSchemaContent).to.deep.equal(backupEnSchemaContent) + const schemaContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.schema.json'), 'utf8')) + const backupSchemaContent = JSON.parse(fs.readFileSync(path.join(backupDir, 'en.default.schema.json'), 'utf8')) + expect(schemaContent).to.deep.equal(backupSchemaContent) fs.rmSync(backupDir, { force: true, recursive: true }) }) it('adds missing translations when mode is set to add-missing', async () => { - const enDefaultPath = path.join(testThemeLocalesPath, 'en.default.json') - const enDefault = JSON.parse(fs.readFileSync(enDefaultPath, 'utf8')) - enDefault.custom = { key: 'This should not be changed' } - fs.writeFileSync(enDefaultPath, JSON.stringify(enDefault, null, 2)) + const storefrontFilePath = path.join(testThemeLocalesPath, 'en.default.json') + const storefrontContent = JSON.parse(fs.readFileSync(storefrontFilePath, 'utf8')) + storefrontContent.custom = { key: 'This should not be changed' } + fs.writeFileSync(storefrontFilePath, JSON.stringify(storefrontContent, null, 2)) - const originalAddToCartValue = enDefault.actions.add_to_cart + const originalAddToCartValue = storefrontContent.actions.add_to_cart await runCommand(['theme', 'locale', 'sync', '--locales-dir', localesPath, '--mode', 'add-missing']) - const content = JSON.parse(fs.readFileSync(enDefaultPath, 'utf8')) - expect(content.custom.key).to.equal('This should not be changed') - expect(content.actions.add_to_cart).to.equal(originalAddToCartValue) + const updatedStorefrontContent = JSON.parse(fs.readFileSync(storefrontFilePath, 'utf8')) + expect(updatedStorefrontContent.custom.key).to.equal('This should not be changed') + expect(updatedStorefrontContent.actions.add_to_cart).to.equal(originalAddToCartValue) }) it('replaces existing translations when mode is set to replace-existing', async () => { - const enDefaultPath = path.join(testThemeLocalesPath, 'en.default.json') - const enDefault = JSON.parse(fs.readFileSync(enDefaultPath, 'utf8')) - const originalAddToCartValue = enDefault.actions.add_to_cart + const storefrontFilePath = path.join(testThemeLocalesPath, 'en.default.json') + const storefrontContent = JSON.parse(fs.readFileSync(storefrontFilePath, 'utf8')) + const originalAddToCartValue = storefrontContent.actions.add_to_cart await runCommand(['theme', 'locale', 'sync', '--locales-dir', localesPath, '--mode', 'replace-existing']) - const content = JSON.parse(fs.readFileSync(enDefaultPath, 'utf8')) - expect(content.actions.add_to_cart).to.not.equal(originalAddToCartValue) + const updatedStorefrontContent = JSON.parse(fs.readFileSync(storefrontFilePath, 'utf8')) + expect(updatedStorefrontContent.actions.add_to_cart).to.not.equal(originalAddToCartValue) }) it('adds and overrides translations when mode is set to add-and-override', async () => { - const enDefaultPath = path.join(testThemeLocalesPath, 'en.default.json') - const enDefault = JSON.parse(fs.readFileSync(enDefaultPath, 'utf8')) - enDefault.custom = { key: 'This should not be changed' } + const storefrontFilePath = path.join(testThemeLocalesPath, 'en.default.json') + const storefrontContent = JSON.parse(fs.readFileSync(storefrontFilePath, 'utf8')) + storefrontContent.custom = { key: 'This should not be changed' } - const originalAddToCartValue = enDefault.actions.add_to_cart + const originalAddToCartValue = storefrontContent.actions.add_to_cart - fs.writeFileSync(enDefaultPath, JSON.stringify(enDefault, null, 2)) + fs.writeFileSync(storefrontFilePath, JSON.stringify(storefrontContent, null, 2)) await runCommand(['theme', 'locale', 'sync', '--locales-dir', localesPath, '--mode', 'add-and-override']) - const content = JSON.parse(fs.readFileSync(enDefaultPath, 'utf8')) - expect(content.custom.key).to.equal('This should not be changed') + const updatedStorefrontContent = JSON.parse(fs.readFileSync(storefrontFilePath, 'utf8')) + expect(updatedStorefrontContent.custom.key).to.equal('This should not be changed') - expect(content.actions.add_to_cart).to.not.equal(originalAddToCartValue) + expect(updatedStorefrontContent.actions.add_to_cart).to.not.equal(originalAddToCartValue) }) it('formats the output files when format flag is set', async () => { await runCommand(['theme', 'locale', 'sync', '--locales-dir', localesPath, '--format']) - const fileContent = fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.json'), 'utf8') + const storefrontFileContent = fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.json'), 'utf8') - expect(fileContent).to.include(' "actions": {') - expect(fileContent).to.include(' "add_to_cart"') + expect(storefrontFileContent).to.include(' "actions": {') + expect(storefrontFileContent).to.include(' "add_to_cart"') }) it('cleans locale files when clean flag is set', async () => { await runCommand(['theme', 'locale', 'sync', '--locales-dir', localesPath, '--clean']) - const content = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.json'), 'utf8')) - expect(content).to.not.have.property('unused') + const storefrontContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.json'), 'utf8')) + expect(storefrontContent).to.not.have.property('unused') - expect(content).to.have.nested.property('actions.add_to_cart') + expect(storefrontContent).to.have.nested.property('actions.add_to_cart') }) }) From 0b802c5d23c122ff66f5afb3f88f72d3ff6618eb Mon Sep 17 00:00:00 2001 From: Chris Berthe Date: Tue, 25 Feb 2025 18:03:32 -0500 Subject: [PATCH 33/53] chore: remove unnecessary backupDir reference in clean.test.ts --- test/commands/theme/locale/clean.test.ts | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/test/commands/theme/locale/clean.test.ts b/test/commands/theme/locale/clean.test.ts index 2cf30ca9..54781696 100644 --- a/test/commands/theme/locale/clean.test.ts +++ b/test/commands/theme/locale/clean.test.ts @@ -95,17 +95,6 @@ describe('theme locale clean', () => { }) it('formats only target files when used with target flag', async () => { - const backupDir = path.join(testThemePath, 'locales-backup') - fs.mkdirSync(backupDir, { recursive: true }) - fs.copyFileSync( - path.join(testThemeLocalesPath, 'en.default.json'), - path.join(backupDir, 'en.default.json') - ) - fs.copyFileSync( - path.join(testThemeLocalesPath, 'en.default.schema.json'), - path.join(backupDir, 'en.default.schema.json') - ) - const schemaFilePath = path.join(testThemeLocalesPath, 'en.default.schema.json') const storefrontFilePath = path.join(testThemeLocalesPath, 'en.default.json') @@ -123,8 +112,6 @@ describe('theme locale clean', () => { expect(formattedSchemaFileContent).to.include(' "section": {') expect(formattedSchemaFileContent).to.include(' "name"') expect(storefrontFileContent).not.to.include(' "actions": {') - - fs.rmSync(backupDir, { force: true, recursive: true }) }) it('formats nested objects correctly when format flag is set', async () => { From 07b0a23a57237e9d2d328206df6170d8500c87a4 Mon Sep 17 00:00:00 2001 From: Chris Berthe Date: Tue, 25 Feb 2025 18:29:45 -0500 Subject: [PATCH 34/53] chore: ensure we're testing for t_with_fallback references --- test/commands/theme/locale/clean.test.ts | 2 ++ test/commands/theme/locale/sync.test.ts | 6 ++++++ test/fixtures/locales/en.default.json | 6 +++++- test/fixtures/locales/fr.json | 6 +++++- test/fixtures/theme/locales/en.default.json | 4 ++++ test/fixtures/theme/snippets/t-with-fallback-usage.liquid | 3 +++ 6 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 test/fixtures/theme/snippets/t-with-fallback-usage.liquid diff --git a/test/commands/theme/locale/clean.test.ts b/test/commands/theme/locale/clean.test.ts index 54781696..6427c546 100644 --- a/test/commands/theme/locale/clean.test.ts +++ b/test/commands/theme/locale/clean.test.ts @@ -67,6 +67,8 @@ describe('theme locale clean', () => { const storefrontContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.json'), 'utf8')) expect(storefrontContent).to.have.nested.property('actions.add_to_cart') + expect(storefrontContent).to.have.nested.property('t_with_fallback.direct_key') + expect(storefrontContent).to.have.nested.property('t_with_fallback.variable_key') const schemaContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.schema.json'), 'utf8')) expect(schemaContent).to.have.nested.property('section.name') diff --git a/test/commands/theme/locale/sync.test.ts b/test/commands/theme/locale/sync.test.ts index 5b63ec75..f1d2b435 100644 --- a/test/commands/theme/locale/sync.test.ts +++ b/test/commands/theme/locale/sync.test.ts @@ -31,9 +31,13 @@ describe('theme locale sync', () => { const storefrontContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.json'), 'utf8')) expect(storefrontContent).to.have.nested.property('actions.add_to_cart') + expect(storefrontContent).to.have.nested.property('t_with_fallback.direct_key') + expect(storefrontContent).to.have.nested.property('t_with_fallback.variable_key') const frStorefrontContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'fr.json'), 'utf8')) expect(frStorefrontContent).to.have.nested.property('actions.add_to_cart') + expect(frStorefrontContent).to.have.nested.property('t_with_fallback.direct_key') + expect(frStorefrontContent).to.have.nested.property('t_with_fallback.variable_key') }) it('syncs only schema files when target is set to schema', async () => { @@ -127,5 +131,7 @@ describe('theme locale sync', () => { expect(storefrontContent).to.not.have.property('unused') expect(storefrontContent).to.have.nested.property('actions.add_to_cart') + expect(storefrontContent).to.have.nested.property('t_with_fallback.direct_key') + expect(storefrontContent).to.have.nested.property('t_with_fallback.variable_key') }) }) diff --git a/test/fixtures/locales/en.default.json b/test/fixtures/locales/en.default.json index 7e2fb5ca..53e09f6d 100644 --- a/test/fixtures/locales/en.default.json +++ b/test/fixtures/locales/en.default.json @@ -3,6 +3,10 @@ "add_to_cart": "Add to cart (source)" }, "additional": { - "new_key": "This is a new translation from source" + "new_key": "This is a new translation (source)" + }, + "t_with_fallback": { + "direct_key": "Direct key (source)", + "variable_key": "Variable key (source)" } } diff --git a/test/fixtures/locales/fr.json b/test/fixtures/locales/fr.json index 49574547..19c70b68 100644 --- a/test/fixtures/locales/fr.json +++ b/test/fixtures/locales/fr.json @@ -3,6 +3,10 @@ "add_to_cart": "Ajouter au panier (source)" }, "additional": { - "new_key": "Ceci est une nouvelle traduction de la source" + "new_key": "Ceci est une nouvelle traduction (source)" + }, + "t_with_fallback": { + "direct_key": "Clé directe (source)", + "variable_key": "Clé variable (source)" } } diff --git a/test/fixtures/theme/locales/en.default.json b/test/fixtures/theme/locales/en.default.json index afb3d9e6..23c96b7c 100644 --- a/test/fixtures/theme/locales/en.default.json +++ b/test/fixtures/theme/locales/en.default.json @@ -2,5 +2,9 @@ "actions": { "add_to_cart": "Add to cart" }, + "t_with_fallback": { + "direct_key": "Direct key", + "variable_key": "Variable key" + }, "unused": "This is an unused translation" } diff --git a/test/fixtures/theme/snippets/t-with-fallback-usage.liquid b/test/fixtures/theme/snippets/t-with-fallback-usage.liquid new file mode 100644 index 00000000..e8d9b730 --- /dev/null +++ b/test/fixtures/theme/snippets/t-with-fallback-usage.liquid @@ -0,0 +1,3 @@ +{%- assign translation_key = 't_with_fallback.variable_key' | t -%} +{%- render 't_with_fallback', t: translation_key -%} +{%- render 't_with_fallback', key: 't_with_fallback.direct_key' -%} From 9a56e56c36d1d8c421564b871919e4858be0d1e8 Mon Sep 17 00:00:00 2001 From: Chris Berthe Date: Wed, 26 Feb 2025 09:22:21 -0500 Subject: [PATCH 35/53] chore: use locale fixture data to test for added keys --- test/commands/theme/locale/sync.test.ts | 71 ++++++++++++++------ test/fixtures/locales/en.default.schema.json | 2 +- test/fixtures/locales/fr.schema.json | 2 +- 3 files changed, 51 insertions(+), 24 deletions(-) diff --git a/test/commands/theme/locale/sync.test.ts b/test/commands/theme/locale/sync.test.ts index f1d2b435..5b60017e 100644 --- a/test/commands/theme/locale/sync.test.ts +++ b/test/commands/theme/locale/sync.test.ts @@ -12,6 +12,9 @@ const testThemePath = path.join(fixturesPath, 'test-theme') const testThemeLocalesPath = path.join(testThemePath, 'locales') describe('theme locale sync', () => { + const sourceEnDefault = JSON.parse(fs.readFileSync(path.join(localesPath, 'en.default.json'), 'utf8')) + const sourceFr = JSON.parse(fs.readFileSync(path.join(localesPath, 'fr.json'), 'utf8')) + beforeEach(() => { fs.cpSync(themePath, testThemePath, { recursive: true }) process.chdir(testThemePath) @@ -38,27 +41,31 @@ describe('theme locale sync', () => { expect(frStorefrontContent).to.have.nested.property('actions.add_to_cart') expect(frStorefrontContent).to.have.nested.property('t_with_fallback.direct_key') expect(frStorefrontContent).to.have.nested.property('t_with_fallback.variable_key') + + expect(frStorefrontContent.actions.add_to_cart).to.equal(sourceFr.actions.add_to_cart) }) it('syncs only schema files when target is set to schema', async () => { - const backupDir = path.join(testThemePath, 'locales-backup') - fs.cpSync(testThemeLocalesPath, backupDir, { recursive: true }) + const originalStorefrontContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.json'), 'utf8')) + const sourceEnDefaultSchema = JSON.parse(fs.readFileSync(path.join(localesPath, 'en.default.schema.json'), 'utf8')) + const originalSchemaContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.schema.json'), 'utf8')) await runCommand(['theme', 'locale', 'sync', '--locales-dir', localesPath, '--target', 'schema']) const schemaContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.schema.json'), 'utf8')) - expect(schemaContent).to.have.nested.property('section.name') - const storefrontContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.json'), 'utf8')) - const backupStorefrontContent = JSON.parse(fs.readFileSync(path.join(backupDir, 'en.default.json'), 'utf8')) - expect(storefrontContent).to.deep.equal(backupStorefrontContent) + expect(schemaContent.section.name).to.equal(originalSchemaContent.section.name) + expect(schemaContent.section.settings.logo_label).to.equal(originalSchemaContent.section.settings.logo_label) - fs.rmSync(backupDir, { force: true, recursive: true }) + expect(schemaContent).to.have.nested.property('additional.new_setting') + expect(schemaContent.additional.new_setting).to.equal(sourceEnDefaultSchema.additional.new_setting) + + const storefrontContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.json'), 'utf8')) + expect(storefrontContent).to.deep.equal(originalStorefrontContent) }) it('syncs only storefront files when target is set to storefront', async () => { - const backupDir = path.join(testThemePath, 'locales-backup') - fs.cpSync(testThemeLocalesPath, backupDir, { recursive: true }) + const originalSchemaContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.schema.json'), 'utf8')) await runCommand(['theme', 'locale', 'sync', '--locales-dir', localesPath, '--target', 'storefront']) @@ -66,25 +73,22 @@ describe('theme locale sync', () => { expect(storefrontContent).to.have.nested.property('actions.add_to_cart') const schemaContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.schema.json'), 'utf8')) - const backupSchemaContent = JSON.parse(fs.readFileSync(path.join(backupDir, 'en.default.schema.json'), 'utf8')) - expect(schemaContent).to.deep.equal(backupSchemaContent) - - fs.rmSync(backupDir, { force: true, recursive: true }) + expect(schemaContent).to.deep.equal(originalSchemaContent) }) it('adds missing translations when mode is set to add-missing', async () => { const storefrontFilePath = path.join(testThemeLocalesPath, 'en.default.json') const storefrontContent = JSON.parse(fs.readFileSync(storefrontFilePath, 'utf8')) - storefrontContent.custom = { key: 'This should not be changed' } - fs.writeFileSync(storefrontFilePath, JSON.stringify(storefrontContent, null, 2)) - const originalAddToCartValue = storefrontContent.actions.add_to_cart await runCommand(['theme', 'locale', 'sync', '--locales-dir', localesPath, '--mode', 'add-missing']) const updatedStorefrontContent = JSON.parse(fs.readFileSync(storefrontFilePath, 'utf8')) - expect(updatedStorefrontContent.custom.key).to.equal('This should not be changed') + expect(updatedStorefrontContent.actions.add_to_cart).to.equal(originalAddToCartValue) + + expect(updatedStorefrontContent).to.have.nested.property('additional.new_key') + expect(updatedStorefrontContent.additional.new_key).to.equal(sourceEnDefault.additional.new_key) }) it('replaces existing translations when mode is set to replace-existing', async () => { @@ -92,34 +96,38 @@ describe('theme locale sync', () => { const storefrontContent = JSON.parse(fs.readFileSync(storefrontFilePath, 'utf8')) const originalAddToCartValue = storefrontContent.actions.add_to_cart + expect(originalAddToCartValue).to.not.equal(sourceEnDefault.actions.add_to_cart) + await runCommand(['theme', 'locale', 'sync', '--locales-dir', localesPath, '--mode', 'replace-existing']) const updatedStorefrontContent = JSON.parse(fs.readFileSync(storefrontFilePath, 'utf8')) + expect(updatedStorefrontContent.actions.add_to_cart).to.not.equal(originalAddToCartValue) + expect(updatedStorefrontContent.actions.add_to_cart).to.equal(sourceEnDefault.actions.add_to_cart) }) it('adds and overrides translations when mode is set to add-and-override', async () => { const storefrontFilePath = path.join(testThemeLocalesPath, 'en.default.json') const storefrontContent = JSON.parse(fs.readFileSync(storefrontFilePath, 'utf8')) - storefrontContent.custom = { key: 'This should not be changed' } - const originalAddToCartValue = storefrontContent.actions.add_to_cart - fs.writeFileSync(storefrontFilePath, JSON.stringify(storefrontContent, null, 2)) + expect(originalAddToCartValue).to.not.equal(sourceEnDefault.actions.add_to_cart) await runCommand(['theme', 'locale', 'sync', '--locales-dir', localesPath, '--mode', 'add-and-override']) const updatedStorefrontContent = JSON.parse(fs.readFileSync(storefrontFilePath, 'utf8')) - expect(updatedStorefrontContent.custom.key).to.equal('This should not be changed') expect(updatedStorefrontContent.actions.add_to_cart).to.not.equal(originalAddToCartValue) + expect(updatedStorefrontContent.actions.add_to_cart).to.equal(sourceEnDefault.actions.add_to_cart) + + expect(updatedStorefrontContent).to.have.nested.property('additional.new_key') + expect(updatedStorefrontContent.additional.new_key).to.equal(sourceEnDefault.additional.new_key) }) it('formats the output files when format flag is set', async () => { await runCommand(['theme', 'locale', 'sync', '--locales-dir', localesPath, '--format']) const storefrontFileContent = fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.json'), 'utf8') - expect(storefrontFileContent).to.include(' "actions": {') expect(storefrontFileContent).to.include(' "add_to_cart"') }) @@ -128,10 +136,29 @@ describe('theme locale sync', () => { await runCommand(['theme', 'locale', 'sync', '--locales-dir', localesPath, '--clean']) const storefrontContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.json'), 'utf8')) + expect(storefrontContent).to.not.have.property('unused') expect(storefrontContent).to.have.nested.property('actions.add_to_cart') expect(storefrontContent).to.have.nested.property('t_with_fallback.direct_key') expect(storefrontContent).to.have.nested.property('t_with_fallback.variable_key') }) + + it('syncs schema files with add-and-override mode', async () => { + const sourceEnDefaultSchema = JSON.parse(fs.readFileSync(path.join(localesPath, 'en.default.schema.json'), 'utf8')) + const originalSchemaContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.schema.json'), 'utf8')) + + expect(originalSchemaContent.section.name).to.not.equal(sourceEnDefaultSchema.section.name) + + await runCommand(['theme', 'locale', 'sync', '--locales-dir', localesPath, '--target', 'schema', '--mode', 'add-and-override']) + + const schemaContent = JSON.parse(fs.readFileSync(path.join(testThemeLocalesPath, 'en.default.schema.json'), 'utf8')) + + expect(schemaContent.section.name).to.not.equal(originalSchemaContent.section.name) + expect(schemaContent.section.name).to.equal(sourceEnDefaultSchema.section.name) + expect(schemaContent.section.settings.logo_label).to.equal(sourceEnDefaultSchema.section.settings.logo_label) + + expect(schemaContent).to.have.nested.property('additional.new_setting') + expect(schemaContent.additional.new_setting).to.equal(sourceEnDefaultSchema.additional.new_setting) + }) }) diff --git a/test/fixtures/locales/en.default.schema.json b/test/fixtures/locales/en.default.schema.json index a13055c1..229e45da 100644 --- a/test/fixtures/locales/en.default.schema.json +++ b/test/fixtures/locales/en.default.schema.json @@ -6,6 +6,6 @@ } }, "additional": { - "new_setting": "This is a new schema setting from source" + "new_setting": "This is a new schema setting (source)" } } diff --git a/test/fixtures/locales/fr.schema.json b/test/fixtures/locales/fr.schema.json index d437da64..d8eb62f9 100644 --- a/test/fixtures/locales/fr.schema.json +++ b/test/fixtures/locales/fr.schema.json @@ -6,6 +6,6 @@ } }, "additional": { - "new_setting": "Ceci est un nouveau paramètre de schéma de la source" + "new_setting": "Ceci est un nouveau paramètre de schéma (source)" } } From 434e17353324ceb2ff7a1e8a05425ce9534d9767 Mon Sep 17 00:00:00 2001 From: Chris Berthe Date: Wed, 26 Feb 2025 09:22:53 -0500 Subject: [PATCH 36/53] fix: ensure missing keys are added in add-missing and add-and-override modes --- src/commands/theme/locale/sync.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/commands/theme/locale/sync.ts b/src/commands/theme/locale/sync.ts index fbd976e6..b271c319 100644 --- a/src/commands/theme/locale/sync.ts +++ b/src/commands/theme/locale/sync.ts @@ -70,15 +70,19 @@ export default class Sync extends BaseCommand { translations: ThemeTranslations, sourceData: { locales: Record> } ): Promise { - const requiredLocales = extractRequiredTranslations(sourceData.locales, translations) - const options: SyncOptions = { format: this.flags[Flags.FORMAT], mode: this.flags[Flags.MODE], target: this.flags[Flags.TARGET] as CleanTarget } - await syncLocales(themeDir, requiredLocales, options) + // For add-missing and add-and-override modes, we want to include all keys from the source + // For replace-existing mode, we only want to include keys that are already used in the theme + const locales = (options.mode === 'replace-existing') + ? extractRequiredTranslations(sourceData.locales, translations) + : sourceData.locales + + await syncLocales(themeDir, locales, options) if (!this.flags[Flags.QUIET]) { this.log('Successfully synced locale files') From b1c441ef68421cc40f0fcccabf1db83e331620cd Mon Sep 17 00:00:00 2001 From: Chris Berthe Date: Wed, 26 Feb 2025 10:44:56 -0500 Subject: [PATCH 37/53] refactor: standardize locale options and centralize types --- src/commands/theme/locale/clean.ts | 7 +++--- src/commands/theme/locale/sync.ts | 16 ++++--------- src/utilities/locales.ts | 20 ++-------------- src/utilities/translations.ts | 35 +++++++++++----------------- src/utilities/types.ts | 37 ++++++++++++++++++++++++++++-- 5 files changed, 59 insertions(+), 56 deletions(-) diff --git a/src/commands/theme/locale/clean.ts b/src/commands/theme/locale/clean.ts index ce73c145..10fdf88f 100644 --- a/src/commands/theme/locale/clean.ts +++ b/src/commands/theme/locale/clean.ts @@ -10,7 +10,8 @@ import path from 'node:path' import Args from '../../../utilities/args.js' import BaseCommand from '../../../utilities/base-command.js' import Flags from '../../../utilities/flags.js' -import { CleanTarget, FormatOptions, cleanTranslations } from '../../../utilities/translations.js' +import { cleanTranslations } from '../../../utilities/translations.js' +import { CleanOptions, CleanTarget } from '../../../utilities/types.js' export default class Clean extends BaseCommand { static override args = Args.getDefinitions([ @@ -39,8 +40,8 @@ export default class Clean extends BaseCommand { const target = this.flags[Flags.TARGET] as CleanTarget const format = this.flags[Flags.FORMAT] - const options: FormatOptions = { format } - await cleanTranslations(themeDir, target, options) + const options: CleanOptions = { format, target } + await cleanTranslations(themeDir, options) if (!this.flags[Flags.QUIET]) { this.log('Successfully cleaned locale files') diff --git a/src/commands/theme/locale/sync.ts b/src/commands/theme/locale/sync.ts index b271c319..e80c21ca 100644 --- a/src/commands/theme/locale/sync.ts +++ b/src/commands/theme/locale/sync.ts @@ -11,15 +11,9 @@ import path from 'node:path' import Args from '../../../utilities/args.js' import BaseCommand from '../../../utilities/base-command.js' import Flags from '../../../utilities/flags.js' -import { SyncOptions, fetchLocaleSource, syncLocales } from '../../../utilities/locales.js' -import { - CleanTarget, - FormatOptions, - ThemeTranslations, - cleanTranslations, - extractRequiredTranslations, - getThemeTranslations -} from '../../../utilities/translations.js' +import { fetchLocaleSource, syncLocales } from '../../../utilities/locales.js' +import { cleanTranslations, extractRequiredTranslations, getThemeTranslations } from '../../../utilities/translations.js' +import { CleanOptions, CleanTarget, SyncOptions, ThemeTranslations } from '../../../utilities/types.js' export default class Sync extends BaseCommand { static override args = Args.getDefinitions([ @@ -53,8 +47,8 @@ export default class Sync extends BaseCommand { await this.syncTranslations(themeDir, translations, sourceLocales) if (this.flags[Flags.CLEAN]) { - const options: FormatOptions = { format } - await cleanTranslations(themeDir, target, options) + const options: CleanOptions = { format, target } + await cleanTranslations(themeDir, options) } } diff --git a/src/utilities/locales.ts b/src/utilities/locales.ts index 3b1829d1..bea6ba2f 100644 --- a/src/utilities/locales.ts +++ b/src/utilities/locales.ts @@ -4,24 +4,8 @@ import path from 'node:path' import { cloneTheme } from './git.js' import { flattenObject, unflattenObject } from './objects.js' -import { CleanTarget, FormatOptions, writeLocaleFile } from './translations.js' - -export interface LocaleContent { - [key: string]: Record -} - -export interface LocaleDiff { - added: Set - modified: Set - removed: Set -} - -export type SyncMode = 'add-and-override' | 'add-missing' | 'replace-existing' - -export interface SyncOptions extends FormatOptions { - mode?: SyncMode - target?: CleanTarget -} +import { writeLocaleFile } from './translations.js' +import { LocaleContent, LocaleDiff, SyncOptions } from './types.js' export async function fetchLocaleSource(source: string): Promise { if (isUrl(source)) { diff --git a/src/utilities/translations.ts b/src/utilities/translations.ts index 26850324..1a830339 100644 --- a/src/utilities/translations.ts +++ b/src/utilities/translations.ts @@ -1,19 +1,8 @@ import fs from 'node:fs' import path from 'node:path' -import { LocaleContent } from './locales.js' import { flattenObject, sortObjectKeys, unflattenObject } from './objects.js' - -export interface ThemeTranslations { - schema: Set - storefront: Set -} - -export type CleanTarget = 'all' | 'schema' | 'storefront' - -export interface FormatOptions { - format?: boolean -} +import { CleanOptions, LocaleContent, LocaleOptions, ThemeTranslations } from './types.js' const SCHEMA_DIRS = ['config', 'blocks', 'sections'] as const const STOREFRONT_DIRS = ['blocks', 'layout', 'sections', 'snippets', 'templates'] as const @@ -21,7 +10,7 @@ const STOREFRONT_DIRS = ['blocks', 'layout', 'sections', 'snippets', 'templates' export function writeLocaleFile( filePath: string, content: Record, - options?: FormatOptions + options?: LocaleOptions ): void { const formattedContent = options?.format ? sortObjectKeys(content) : content fs.writeFileSync(filePath, JSON.stringify(formattedContent, null, 2) + '\n') @@ -34,7 +23,7 @@ export function getThemeTranslations(themeDir: string): ThemeTranslations { } } -export function cleanSchemaTranslations(themeDir: string, options?: FormatOptions): void { +export function cleanSchemaTranslations(themeDir: string, options?: LocaleOptions): void { const usedKeys = scanFiles(themeDir, SCHEMA_DIRS, findSchemaKeys) const localesDir = path.join(themeDir, 'locales') const schemaFiles = fs.readdirSync(localesDir) @@ -45,7 +34,7 @@ export function cleanSchemaTranslations(themeDir: string, options?: FormatOption } } -export function cleanStorefrontTranslations(themeDir: string, options?: FormatOptions): void { +export function cleanStorefrontTranslations(themeDir: string, options?: LocaleOptions): void { const usedKeys = scanFiles(themeDir, STOREFRONT_DIRS, findStorefrontKeys) const localesDir = path.join(themeDir, 'locales') const localeFiles = fs.readdirSync(localesDir) @@ -165,7 +154,7 @@ function findVariableFallbackKeys(content: string, assignedTranslations: Map, options?: FormatOptions): void { +function cleanLocaleFile(filePath: string, usedKeys: Set, options?: LocaleOptions): void { try { const content = JSON.parse(fs.readFileSync(filePath, 'utf8')) if (!content || typeof content !== 'object') return @@ -214,23 +203,25 @@ export function extractRequiredTranslations( export async function cleanTranslations( themeDir: string, - target: CleanTarget = 'all', - options?: FormatOptions + options: CleanOptions = {} ): Promise { + const { format, target = 'all' } = options; + const formatOptions: LocaleOptions = { format }; + switch (target) { case 'schema': { - await cleanSchemaTranslations(themeDir, options) + await cleanSchemaTranslations(themeDir, formatOptions) break } case 'storefront': { - await cleanStorefrontTranslations(themeDir, options) + await cleanStorefrontTranslations(themeDir, formatOptions) break } case 'all': { - await cleanSchemaTranslations(themeDir, options) - await cleanStorefrontTranslations(themeDir, options) + await cleanSchemaTranslations(themeDir, formatOptions) + await cleanStorefrontTranslations(themeDir, formatOptions) break } } diff --git a/src/utilities/types.ts b/src/utilities/types.ts index 8d5efd37..8068d6f6 100644 --- a/src/utilities/types.ts +++ b/src/utilities/types.ts @@ -17,7 +17,7 @@ export interface TomlConfig { export interface LiquidNode { assets: string[] - body: string + body: string file: string name: string setup: string[] @@ -44,4 +44,37 @@ export interface Manifest { [name: string]: string; }; } -} \ No newline at end of file +} + +// Locale-related types +export type CleanTarget = 'all' | 'schema' | 'storefront' + +export interface LocaleOptions { + format?: boolean +} + +export interface CleanOptions extends LocaleOptions { + target?: CleanTarget +} + +export type SyncMode = 'add-and-override' | 'add-missing' | 'replace-existing' + +export interface SyncOptions extends LocaleOptions { + mode?: SyncMode + target?: CleanTarget +} + +export interface LocaleContent { + [key: string]: Record +} + +export interface LocaleDiff { + added: Set + modified: Set + removed: Set +} + +export interface ThemeTranslations { + schema: Set + storefront: Set +} From e072f301bf2099e100b3a37edb7fa65aaf0bbb7b Mon Sep 17 00:00:00 2001 From: Chris Berthe Date: Wed, 26 Feb 2025 12:12:22 -0500 Subject: [PATCH 38/53] refactor: remove translations.ts --- src/commands/theme/locale/clean.ts | 2 +- src/commands/theme/locale/sync.ts | 3 +- src/utilities/locales.ts | 228 ++++++++++++++++++++++++++++- src/utilities/translations.ts | 228 ----------------------------- 4 files changed, 227 insertions(+), 234 deletions(-) delete mode 100644 src/utilities/translations.ts diff --git a/src/commands/theme/locale/clean.ts b/src/commands/theme/locale/clean.ts index 10fdf88f..f0fc9c95 100644 --- a/src/commands/theme/locale/clean.ts +++ b/src/commands/theme/locale/clean.ts @@ -10,7 +10,7 @@ import path from 'node:path' import Args from '../../../utilities/args.js' import BaseCommand from '../../../utilities/base-command.js' import Flags from '../../../utilities/flags.js' -import { cleanTranslations } from '../../../utilities/translations.js' +import { cleanTranslations } from '../../../utilities/locales.js' import { CleanOptions, CleanTarget } from '../../../utilities/types.js' export default class Clean extends BaseCommand { diff --git a/src/commands/theme/locale/sync.ts b/src/commands/theme/locale/sync.ts index e80c21ca..316bd45e 100644 --- a/src/commands/theme/locale/sync.ts +++ b/src/commands/theme/locale/sync.ts @@ -11,8 +11,7 @@ import path from 'node:path' import Args from '../../../utilities/args.js' import BaseCommand from '../../../utilities/base-command.js' import Flags from '../../../utilities/flags.js' -import { fetchLocaleSource, syncLocales } from '../../../utilities/locales.js' -import { cleanTranslations, extractRequiredTranslations, getThemeTranslations } from '../../../utilities/translations.js' +import { cleanTranslations, extractRequiredTranslations, fetchLocaleSource, getThemeTranslations, syncLocales } from '../../../utilities/locales.js' import { CleanOptions, CleanTarget, SyncOptions, ThemeTranslations } from '../../../utilities/types.js' export default class Sync extends BaseCommand { diff --git a/src/utilities/locales.ts b/src/utilities/locales.ts index bea6ba2f..0587f06d 100644 --- a/src/utilities/locales.ts +++ b/src/utilities/locales.ts @@ -3,9 +3,8 @@ import { tmpdir } from 'node:os' import path from 'node:path' import { cloneTheme } from './git.js' -import { flattenObject, unflattenObject } from './objects.js' -import { writeLocaleFile } from './translations.js' -import { LocaleContent, LocaleDiff, SyncOptions } from './types.js' +import { flattenObject, sortObjectKeys, unflattenObject } from './objects.js' +import { CleanOptions, LocaleContent, LocaleDiff, LocaleOptions, SyncOptions, ThemeTranslations } from './types.js' export async function fetchLocaleSource(source: string): Promise { if (isUrl(source)) { @@ -147,3 +146,226 @@ function mergeTranslations( return unflattenObject(flatMerged) } + +export function writeLocaleFile( + filePath: string, + content: Record, + options?: LocaleOptions +): void { + const formattedContent = options?.format ? sortObjectKeys(content) : content + fs.writeFileSync(filePath, JSON.stringify(formattedContent, null, 2) + '\n') +} + +const SCHEMA_DIRS = ['config', 'blocks', 'sections'] as const +const STOREFRONT_DIRS = ['blocks', 'layout', 'sections', 'snippets', 'templates'] as const + +export function getThemeTranslations(themeDir: string): ThemeTranslations { + return { + schema: scanFiles(themeDir, SCHEMA_DIRS, findSchemaKeys), + storefront: scanFiles(themeDir, STOREFRONT_DIRS, findStorefrontKeys) + } +} + +export function cleanSchemaTranslations(themeDir: string, options?: LocaleOptions): void { + const usedKeys = scanFiles(themeDir, SCHEMA_DIRS, findSchemaKeys) + const localesDir = path.join(themeDir, 'locales') + const schemaFiles = fs.readdirSync(localesDir) + .filter(file => file.endsWith('.schema.json')) + + for (const file of schemaFiles) { + cleanLocaleFile(path.join(localesDir, file), usedKeys, options) + } +} + +export function cleanStorefrontTranslations(themeDir: string, options?: LocaleOptions): void { + const usedKeys = scanFiles(themeDir, STOREFRONT_DIRS, findStorefrontKeys) + const localesDir = path.join(themeDir, 'locales') + const localeFiles = fs.readdirSync(localesDir) + .filter(file => file.endsWith('.json') && !file.endsWith('.schema.json')) + + for (const file of localeFiles) { + cleanLocaleFile(path.join(localesDir, file), usedKeys, options) + } +} + +function scanFiles(themeDir: string, dirs: readonly string[], findKeys: (content: string) => Set): Set { + const usedKeys = new Set() + + for (const dir of dirs) { + const dirPath = path.join(themeDir, dir) + if (!fs.existsSync(dirPath)) continue + + const files = fs.readdirSync(dirPath) + .filter(file => file.endsWith('.liquid') || file.endsWith('.json')) + + for (const file of files) { + const content = fs.readFileSync(path.join(dirPath, file), 'utf8') + const keys = findKeys(content) + for (const key of keys) { + usedKeys.add(key) + } + } + } + + return usedKeys +} + +function findSchemaKeys(content: string): Set { + const keys = new Set() + const matches = content.match(/"t:([^"]+)"/g) || [] + + for (const match of matches) { + const key = match.match(/"t:([^"]+)"/)![1] + keys.add(key) + } + + return keys +} + +function findStorefrontKeys(content: string): Set { + const keys = new Set() + + // Standard Liquid translation patterns + const standardPatterns = [ + /{{\s*-?\s*["']([^"']+)["']\s*\|\s*t[^}]*-?\s*}}/g, + /{%\s*(?:assign|capture)\s+\w+\s*=\s*["']([^"']+)["']\s*\|\s*t[^%]*%}/g, + /(?:^|\s)["']([^"']+)["']\s*\|\s*t[^\n}]*/gm, + ] + + // Find standard translations + for (const pattern of standardPatterns) { + const matches = content.match(pattern) || [] + for (const match of matches) { + const key = match.match(/["']([^"']+)["']/)![1] + keys.add(key) + } + } + + // Combine with t_with_fallback translations + return new Set([...keys, ...findTWithFallbackKeys(content)]) +} + +function findTWithFallbackKeys(content: string): Set { + // Find translations assigned to variables first + const assignedTranslations = findAssignedTranslations(content) + + // Find both direct keys and variable-based keys + const directKeys = findDirectFallbackKeys(content) + const variableKeys = findVariableFallbackKeys(content, assignedTranslations) + + return new Set([...directKeys, ...variableKeys]) +} + +function findAssignedTranslations(content: string): Map { + const assignments = new Map() + const pattern = /{%-?\s*assign\s+([^\s=]+)\s*=\s*["']([^"']+)["']\s*\|\s*t[^%]*-?%}/g + + const matches = content.matchAll(pattern) + for (const match of matches) { + const [, varName, translationKey] = match + assignments.set(varName, translationKey) + } + + return assignments +} + +function findDirectFallbackKeys(content: string): Set { + const keys = new Set() + const pattern = /render\s+["']t_with_fallback["'][^%]*key:\s*["']([^"']+)["']/g + + const matches = content.matchAll(pattern) + for (const match of matches) { + keys.add(match[1]) + } + + return keys +} + +function findVariableFallbackKeys(content: string, assignedTranslations: Map): Set { + const keys = new Set() + const pattern = /render\s+["']t_with_fallback["'][^%]*t:\s*([^\s,}]+)/g + + const matches = content.matchAll(pattern) + for (const match of matches) { + const varName = match[1] + const translationKey = assignedTranslations.get(varName) + if (translationKey) { + keys.add(translationKey) + } + } + + return keys +} + +function cleanLocaleFile(filePath: string, usedKeys: Set, options?: LocaleOptions): void { + try { + const content = JSON.parse(fs.readFileSync(filePath, 'utf8')) + if (!content || typeof content !== 'object') return + + const flattenedContent = flattenObject(content) + const cleanedContent: Record = {} + + for (const [key, value] of Object.entries(flattenedContent)) { + const basePath = key.split('.').slice(0, -1).join('.') + if (usedKeys.has(key) || usedKeys.has(basePath)) { + cleanedContent[key] = value + } + } + + const unflattened = unflattenObject(cleanedContent) + writeLocaleFile(filePath, unflattened, options) + } catch (error) { + throw new Error(`Error processing ${path.basename(filePath)}: ${error}`) + } +} + +export function extractRequiredTranslations( + sourceLocales: Record>, + required: ThemeTranslations +): LocaleContent { + const result: LocaleContent = {} + + for (const [file, content] of Object.entries(sourceLocales)) { + const isSchema = file.endsWith('.schema.json') + const requiredKeys = isSchema ? required.schema : required.storefront + + const flatContent = flattenObject(content) + const filteredContent: Record = {} + + for (const key of requiredKeys) { + if (key in flatContent) { + filteredContent[key] = flatContent[key] + } + } + + result[file] = unflattenObject(filteredContent) + } + + return result +} + +export async function cleanTranslations( + themeDir: string, + options: CleanOptions = {} +): Promise { + const { format, target = 'all' } = options; + const formatOptions: LocaleOptions = { format }; + + switch (target) { + case 'schema': { + await cleanSchemaTranslations(themeDir, formatOptions) + break + } + + case 'storefront': { + await cleanStorefrontTranslations(themeDir, formatOptions) + break + } + + case 'all': { + await cleanSchemaTranslations(themeDir, formatOptions) + await cleanStorefrontTranslations(themeDir, formatOptions) + break + } + } +} diff --git a/src/utilities/translations.ts b/src/utilities/translations.ts deleted file mode 100644 index 1a830339..00000000 --- a/src/utilities/translations.ts +++ /dev/null @@ -1,228 +0,0 @@ -import fs from 'node:fs' -import path from 'node:path' - -import { flattenObject, sortObjectKeys, unflattenObject } from './objects.js' -import { CleanOptions, LocaleContent, LocaleOptions, ThemeTranslations } from './types.js' - -const SCHEMA_DIRS = ['config', 'blocks', 'sections'] as const -const STOREFRONT_DIRS = ['blocks', 'layout', 'sections', 'snippets', 'templates'] as const - -export function writeLocaleFile( - filePath: string, - content: Record, - options?: LocaleOptions -): void { - const formattedContent = options?.format ? sortObjectKeys(content) : content - fs.writeFileSync(filePath, JSON.stringify(formattedContent, null, 2) + '\n') -} - -export function getThemeTranslations(themeDir: string): ThemeTranslations { - return { - schema: scanFiles(themeDir, SCHEMA_DIRS, findSchemaKeys), - storefront: scanFiles(themeDir, STOREFRONT_DIRS, findStorefrontKeys) - } -} - -export function cleanSchemaTranslations(themeDir: string, options?: LocaleOptions): void { - const usedKeys = scanFiles(themeDir, SCHEMA_DIRS, findSchemaKeys) - const localesDir = path.join(themeDir, 'locales') - const schemaFiles = fs.readdirSync(localesDir) - .filter(file => file.endsWith('.schema.json')) - - for (const file of schemaFiles) { - cleanLocaleFile(path.join(localesDir, file), usedKeys, options) - } -} - -export function cleanStorefrontTranslations(themeDir: string, options?: LocaleOptions): void { - const usedKeys = scanFiles(themeDir, STOREFRONT_DIRS, findStorefrontKeys) - const localesDir = path.join(themeDir, 'locales') - const localeFiles = fs.readdirSync(localesDir) - .filter(file => file.endsWith('.json') && !file.endsWith('.schema.json')) - - for (const file of localeFiles) { - cleanLocaleFile(path.join(localesDir, file), usedKeys, options) - } -} - -function scanFiles(themeDir: string, dirs: readonly string[], findKeys: (content: string) => Set): Set { - const usedKeys = new Set() - - for (const dir of dirs) { - const dirPath = path.join(themeDir, dir) - if (!fs.existsSync(dirPath)) continue - - const files = fs.readdirSync(dirPath) - .filter(file => file.endsWith('.liquid') || file.endsWith('.json')) - - for (const file of files) { - const content = fs.readFileSync(path.join(dirPath, file), 'utf8') - const keys = findKeys(content) - for (const key of keys) { - usedKeys.add(key) - } - } - } - - return usedKeys -} - -function findSchemaKeys(content: string): Set { - const keys = new Set() - const matches = content.match(/"t:([^"]+)"/g) || [] - - for (const match of matches) { - const key = match.match(/"t:([^"]+)"/)![1] - keys.add(key) - } - - return keys -} - -function findStorefrontKeys(content: string): Set { - const keys = new Set() - - // Standard Liquid translation patterns - const standardPatterns = [ - /{{\s*-?\s*["']([^"']+)["']\s*\|\s*t[^}]*-?\s*}}/g, - /{%\s*(?:assign|capture)\s+\w+\s*=\s*["']([^"']+)["']\s*\|\s*t[^%]*%}/g, - /(?:^|\s)["']([^"']+)["']\s*\|\s*t[^\n}]*/gm, - ] - - // Find standard translations - for (const pattern of standardPatterns) { - const matches = content.match(pattern) || [] - for (const match of matches) { - const key = match.match(/["']([^"']+)["']/)![1] - keys.add(key) - } - } - - // Combine with t_with_fallback translations - return new Set([...keys, ...findTWithFallbackKeys(content)]) -} - -function findTWithFallbackKeys(content: string): Set { - // Find translations assigned to variables first - const assignedTranslations = findAssignedTranslations(content) - - // Find both direct keys and variable-based keys - const directKeys = findDirectFallbackKeys(content) - const variableKeys = findVariableFallbackKeys(content, assignedTranslations) - - return new Set([...directKeys, ...variableKeys]) -} - -function findAssignedTranslations(content: string): Map { - const assignments = new Map() - const pattern = /{%-?\s*assign\s+([^\s=]+)\s*=\s*["']([^"']+)["']\s*\|\s*t[^%]*-?%}/g - - const matches = content.matchAll(pattern) - for (const match of matches) { - const [, varName, translationKey] = match - assignments.set(varName, translationKey) - } - - return assignments -} - -function findDirectFallbackKeys(content: string): Set { - const keys = new Set() - const pattern = /render\s+["']t_with_fallback["'][^%]*key:\s*["']([^"']+)["']/g - - const matches = content.matchAll(pattern) - for (const match of matches) { - keys.add(match[1]) - } - - return keys -} - -function findVariableFallbackKeys(content: string, assignedTranslations: Map): Set { - const keys = new Set() - const pattern = /render\s+["']t_with_fallback["'][^%]*t:\s*([^\s,}]+)/g - - const matches = content.matchAll(pattern) - for (const match of matches) { - const varName = match[1] - const translationKey = assignedTranslations.get(varName) - if (translationKey) { - keys.add(translationKey) - } - } - - return keys -} - -function cleanLocaleFile(filePath: string, usedKeys: Set, options?: LocaleOptions): void { - try { - const content = JSON.parse(fs.readFileSync(filePath, 'utf8')) - if (!content || typeof content !== 'object') return - - const flattenedContent = flattenObject(content) - const cleanedContent: Record = {} - - for (const [key, value] of Object.entries(flattenedContent)) { - const basePath = key.split('.').slice(0, -1).join('.') - if (usedKeys.has(key) || usedKeys.has(basePath)) { - cleanedContent[key] = value - } - } - - const unflattened = unflattenObject(cleanedContent) - writeLocaleFile(filePath, unflattened, options) - } catch (error) { - throw new Error(`Error processing ${path.basename(filePath)}: ${error}`) - } -} - -export function extractRequiredTranslations( - sourceLocales: Record>, - required: ThemeTranslations -): LocaleContent { - const result: LocaleContent = {} - - for (const [file, content] of Object.entries(sourceLocales)) { - const isSchema = file.endsWith('.schema.json') - const requiredKeys = isSchema ? required.schema : required.storefront - - const flatContent = flattenObject(content) - const filteredContent: Record = {} - - for (const key of requiredKeys) { - if (key in flatContent) { - filteredContent[key] = flatContent[key] - } - } - - result[file] = unflattenObject(filteredContent) - } - - return result -} - -export async function cleanTranslations( - themeDir: string, - options: CleanOptions = {} -): Promise { - const { format, target = 'all' } = options; - const formatOptions: LocaleOptions = { format }; - - switch (target) { - case 'schema': { - await cleanSchemaTranslations(themeDir, formatOptions) - break - } - - case 'storefront': { - await cleanStorefrontTranslations(themeDir, formatOptions) - break - } - - case 'all': { - await cleanSchemaTranslations(themeDir, formatOptions) - await cleanStorefrontTranslations(themeDir, formatOptions) - break - } - } -} From c9a00f4fa47fde6563241fd613c9ab9be51099dc Mon Sep 17 00:00:00 2001 From: Chris Berthe Date: Wed, 26 Feb 2025 12:27:24 -0500 Subject: [PATCH 39/53] chore: locale method in logical order --- src/utilities/locales.ts | 314 +++++++++++++++++++-------------------- 1 file changed, 157 insertions(+), 157 deletions(-) diff --git a/src/utilities/locales.ts b/src/utilities/locales.ts index 0587f06d..3e25a60f 100644 --- a/src/utilities/locales.ts +++ b/src/utilities/locales.ts @@ -6,6 +6,9 @@ import { cloneTheme } from './git.js' import { flattenObject, sortObjectKeys, unflattenObject } from './objects.js' import { CleanOptions, LocaleContent, LocaleDiff, LocaleOptions, SyncOptions, ThemeTranslations } from './types.js' +const SCHEMA_DIRS = ['config', 'blocks', 'sections'] as const +const STOREFRONT_DIRS = ['blocks', 'layout', 'sections', 'snippets', 'templates'] as const + export async function fetchLocaleSource(source: string): Promise { if (isUrl(source)) { return fetchRemoteLocales(source) @@ -41,112 +44,6 @@ function loadLocalLocales(dir: string): LocaleContent { return content } -export async function syncLocales( - themeDir: string, - sourceLocales: Record>, - options?: SyncOptions -): Promise { - const localesDir = path.join(themeDir, 'locales') - const { format = false, mode = 'add-missing', target = 'all' } = options ?? {} - - const filesToSync = Object.entries(sourceLocales).filter(([file]) => { - const isSchemaFile = file.endsWith('.schema.json') - - if (target === 'schema') return isSchemaFile - if (target === 'storefront') return !isSchemaFile - return true - }) - - for (const [file, sourceContent] of filesToSync) { - const targetPath = path.join(localesDir, file) - - if (!fs.existsSync(targetPath)) { - writeLocaleFile(targetPath, sourceContent, { format }) - continue - } - - const targetContent = JSON.parse(fs.readFileSync(targetPath, 'utf8')) - const diff = diffLocales(sourceContent, targetContent) - - const mergedContent = mode === 'replace-existing' - ? replaceTranslations(sourceContent, targetContent) - : mode === 'add-missing' - ? addMissingTranslations(sourceContent, targetContent, diff) - : mergeTranslations(sourceContent, targetContent, diff) - - writeLocaleFile(targetPath, mergedContent, { format }) - } -} - -export function diffLocales(source: Record, target: Record): LocaleDiff { - const flatSource = flattenObject(source) - const flatTarget = flattenObject(target) - - return { - added: new Set(Object.keys(flatSource).filter(key => !(key in flatTarget))), - modified: new Set(Object.keys(flatSource).filter(key => - key in flatTarget && flatSource[key] !== flatTarget[key] - )), - removed: new Set(Object.keys(flatTarget).filter(key => !(key in flatSource))), - } -} - -function replaceTranslations( - source: Record, - target: Record -): Record { - const updateValues = (targetObj: Record, sourceObj: Record): Record => { - const result: Record = {} - - for (const [key, value] of Object.entries(targetObj)) { - const isNestedObject = typeof value === 'object' && value !== null - const hasSourceValue = key in sourceObj - - if (isNestedObject && hasSourceValue && typeof sourceObj[key] === 'object') { - result[key] = updateValues(value as Record, sourceObj[key] as Record) - } else { - result[key] = hasSourceValue ? sourceObj[key] : value - } - } - - return result - } - - return updateValues(target, source) -} - -function addMissingTranslations( - source: Record, - target: Record, - diff: LocaleDiff -): Record { - const merged = { ...target } - const flatContent = flattenObject(source) - const flatMerged = flattenObject(merged) - - for (const key of diff.added) { - flatMerged[key] = flatContent[key] - } - - return unflattenObject(flatMerged) -} - -function mergeTranslations( - source: Record, - target: Record, - diff: LocaleDiff -): Record { - const merged = { ...target } - const flatContent = flattenObject(source) - const flatMerged = flattenObject(merged) - - for (const key of [...diff.added, ...diff.modified]) { - flatMerged[key] = flatContent[key] - } - - return unflattenObject(flatMerged) -} - export function writeLocaleFile( filePath: string, content: Record, @@ -156,9 +53,6 @@ export function writeLocaleFile( fs.writeFileSync(filePath, JSON.stringify(formattedContent, null, 2) + '\n') } -const SCHEMA_DIRS = ['config', 'blocks', 'sections'] as const -const STOREFRONT_DIRS = ['blocks', 'layout', 'sections', 'snippets', 'templates'] as const - export function getThemeTranslations(themeDir: string): ThemeTranslations { return { schema: scanFiles(themeDir, SCHEMA_DIRS, findSchemaKeys), @@ -166,28 +60,6 @@ export function getThemeTranslations(themeDir: string): ThemeTranslations { } } -export function cleanSchemaTranslations(themeDir: string, options?: LocaleOptions): void { - const usedKeys = scanFiles(themeDir, SCHEMA_DIRS, findSchemaKeys) - const localesDir = path.join(themeDir, 'locales') - const schemaFiles = fs.readdirSync(localesDir) - .filter(file => file.endsWith('.schema.json')) - - for (const file of schemaFiles) { - cleanLocaleFile(path.join(localesDir, file), usedKeys, options) - } -} - -export function cleanStorefrontTranslations(themeDir: string, options?: LocaleOptions): void { - const usedKeys = scanFiles(themeDir, STOREFRONT_DIRS, findStorefrontKeys) - const localesDir = path.join(themeDir, 'locales') - const localeFiles = fs.readdirSync(localesDir) - .filter(file => file.endsWith('.json') && !file.endsWith('.schema.json')) - - for (const file of localeFiles) { - cleanLocaleFile(path.join(localesDir, file), usedKeys, options) - } -} - function scanFiles(themeDir: string, dirs: readonly string[], findKeys: (content: string) => Set): Set { const usedKeys = new Set() @@ -297,6 +169,54 @@ function findVariableFallbackKeys(content: string, assignedTranslations: Map { + const { format, target = 'all' } = options; + const formatOptions: LocaleOptions = { format }; + + switch (target) { + case 'schema': { + await cleanSchemaTranslations(themeDir, formatOptions) + break + } + + case 'storefront': { + await cleanStorefrontTranslations(themeDir, formatOptions) + break + } + + case 'all': { + await cleanSchemaTranslations(themeDir, formatOptions) + await cleanStorefrontTranslations(themeDir, formatOptions) + break + } + } +} + +export function cleanSchemaTranslations(themeDir: string, options?: LocaleOptions): void { + const usedKeys = scanFiles(themeDir, SCHEMA_DIRS, findSchemaKeys) + const localesDir = path.join(themeDir, 'locales') + const schemaFiles = fs.readdirSync(localesDir) + .filter(file => file.endsWith('.schema.json')) + + for (const file of schemaFiles) { + cleanLocaleFile(path.join(localesDir, file), usedKeys, options) + } +} + +export function cleanStorefrontTranslations(themeDir: string, options?: LocaleOptions): void { + const usedKeys = scanFiles(themeDir, STOREFRONT_DIRS, findStorefrontKeys) + const localesDir = path.join(themeDir, 'locales') + const localeFiles = fs.readdirSync(localesDir) + .filter(file => file.endsWith('.json') && !file.endsWith('.schema.json')) + + for (const file of localeFiles) { + cleanLocaleFile(path.join(localesDir, file), usedKeys, options) + } +} + function cleanLocaleFile(filePath: string, usedKeys: Set, options?: LocaleOptions): void { try { const content = JSON.parse(fs.readFileSync(filePath, 'utf8')) @@ -319,6 +239,112 @@ function cleanLocaleFile(filePath: string, usedKeys: Set, options?: Loca } } +export async function syncLocales( + themeDir: string, + sourceLocales: Record>, + options?: SyncOptions +): Promise { + const localesDir = path.join(themeDir, 'locales') + const { format = false, mode = 'add-missing', target = 'all' } = options ?? {} + + const filesToSync = Object.entries(sourceLocales).filter(([file]) => { + const isSchemaFile = file.endsWith('.schema.json') + + if (target === 'schema') return isSchemaFile + if (target === 'storefront') return !isSchemaFile + return true + }) + + for (const [file, sourceContent] of filesToSync) { + const targetPath = path.join(localesDir, file) + + if (!fs.existsSync(targetPath)) { + writeLocaleFile(targetPath, sourceContent, { format }) + continue + } + + const targetContent = JSON.parse(fs.readFileSync(targetPath, 'utf8')) + const diff = diffLocales(sourceContent, targetContent) + + const mergedContent = mode === 'replace-existing' + ? replaceTranslations(sourceContent, targetContent) + : mode === 'add-missing' + ? addMissingTranslations(sourceContent, targetContent, diff) + : mergeTranslations(sourceContent, targetContent, diff) + + writeLocaleFile(targetPath, mergedContent, { format }) + } +} + +export function diffLocales(source: Record, target: Record): LocaleDiff { + const flatSource = flattenObject(source) + const flatTarget = flattenObject(target) + + return { + added: new Set(Object.keys(flatSource).filter(key => !(key in flatTarget))), + modified: new Set(Object.keys(flatSource).filter(key => + key in flatTarget && flatSource[key] !== flatTarget[key] + )), + removed: new Set(Object.keys(flatTarget).filter(key => !(key in flatSource))), + } +} + +function replaceTranslations( + source: Record, + target: Record +): Record { + const updateValues = (targetObj: Record, sourceObj: Record): Record => { + const result: Record = {} + + for (const [key, value] of Object.entries(targetObj)) { + const isNestedObject = typeof value === 'object' && value !== null + const hasSourceValue = key in sourceObj + + if (isNestedObject && hasSourceValue && typeof sourceObj[key] === 'object') { + result[key] = updateValues(value as Record, sourceObj[key] as Record) + } else { + result[key] = hasSourceValue ? sourceObj[key] : value + } + } + + return result + } + + return updateValues(target, source) +} + +function addMissingTranslations( + source: Record, + target: Record, + diff: LocaleDiff +): Record { + const merged = { ...target } + const flatContent = flattenObject(source) + const flatMerged = flattenObject(merged) + + for (const key of diff.added) { + flatMerged[key] = flatContent[key] + } + + return unflattenObject(flatMerged) +} + +function mergeTranslations( + source: Record, + target: Record, + diff: LocaleDiff +): Record { + const merged = { ...target } + const flatContent = flattenObject(source) + const flatMerged = flattenObject(merged) + + for (const key of [...diff.added, ...diff.modified]) { + flatMerged[key] = flatContent[key] + } + + return unflattenObject(flatMerged) +} + export function extractRequiredTranslations( sourceLocales: Record>, required: ThemeTranslations @@ -343,29 +369,3 @@ export function extractRequiredTranslations( return result } - -export async function cleanTranslations( - themeDir: string, - options: CleanOptions = {} -): Promise { - const { format, target = 'all' } = options; - const formatOptions: LocaleOptions = { format }; - - switch (target) { - case 'schema': { - await cleanSchemaTranslations(themeDir, formatOptions) - break - } - - case 'storefront': { - await cleanStorefrontTranslations(themeDir, formatOptions) - break - } - - case 'all': { - await cleanSchemaTranslations(themeDir, formatOptions) - await cleanStorefrontTranslations(themeDir, formatOptions) - break - } - } -} From 409e5c7ed552a51cd0f9198a2646e12202e43fac Mon Sep 17 00:00:00 2001 From: Chris Berthe Date: Wed, 26 Feb 2025 12:32:33 -0500 Subject: [PATCH 40/53] chore: rename some locale methods --- src/commands/theme/locale/clean.ts | 4 ++-- src/commands/theme/locale/sync.ts | 8 ++++---- src/utilities/locales.ts | 16 ++++++++-------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/commands/theme/locale/clean.ts b/src/commands/theme/locale/clean.ts index f0fc9c95..48cb0c4e 100644 --- a/src/commands/theme/locale/clean.ts +++ b/src/commands/theme/locale/clean.ts @@ -10,7 +10,7 @@ import path from 'node:path' import Args from '../../../utilities/args.js' import BaseCommand from '../../../utilities/base-command.js' import Flags from '../../../utilities/flags.js' -import { cleanTranslations } from '../../../utilities/locales.js' +import { removeUnusedTranslations } from '../../../utilities/locales.js' import { CleanOptions, CleanTarget } from '../../../utilities/types.js' export default class Clean extends BaseCommand { @@ -41,7 +41,7 @@ export default class Clean extends BaseCommand { const format = this.flags[Flags.FORMAT] const options: CleanOptions = { format, target } - await cleanTranslations(themeDir, options) + await removeUnusedTranslations(themeDir, options) if (!this.flags[Flags.QUIET]) { this.log('Successfully cleaned locale files') diff --git a/src/commands/theme/locale/sync.ts b/src/commands/theme/locale/sync.ts index 316bd45e..f42fa9ac 100644 --- a/src/commands/theme/locale/sync.ts +++ b/src/commands/theme/locale/sync.ts @@ -11,7 +11,7 @@ import path from 'node:path' import Args from '../../../utilities/args.js' import BaseCommand from '../../../utilities/base-command.js' import Flags from '../../../utilities/flags.js' -import { cleanTranslations, extractRequiredTranslations, fetchLocaleSource, getThemeTranslations, syncLocales } from '../../../utilities/locales.js' +import { extractRequiredTranslations, getLocaleSource, getThemeTranslations, mergeLocaleFiles, removeUnusedTranslations } from '../../../utilities/locales.js' import { CleanOptions, CleanTarget, SyncOptions, ThemeTranslations } from '../../../utilities/types.js' export default class Sync extends BaseCommand { @@ -47,14 +47,14 @@ export default class Sync extends BaseCommand { if (this.flags[Flags.CLEAN]) { const options: CleanOptions = { format, target } - await cleanTranslations(themeDir, options) + await removeUnusedTranslations(themeDir, options) } } private async fetchAndAnalyzeSource(localesDir: string): Promise<{ locales: Record> }> { - const sourceLocales = await fetchLocaleSource(localesDir) + const sourceLocales = await getLocaleSource(localesDir) return { locales: sourceLocales } } @@ -75,7 +75,7 @@ export default class Sync extends BaseCommand { ? extractRequiredTranslations(sourceData.locales, translations) : sourceData.locales - await syncLocales(themeDir, locales, options) + await mergeLocaleFiles(themeDir, locales, options) if (!this.flags[Flags.QUIET]) { this.log('Successfully synced locale files') diff --git a/src/utilities/locales.ts b/src/utilities/locales.ts index 3e25a60f..f9fc4fe2 100644 --- a/src/utilities/locales.ts +++ b/src/utilities/locales.ts @@ -9,7 +9,7 @@ import { CleanOptions, LocaleContent, LocaleDiff, LocaleOptions, SyncOptions, Th const SCHEMA_DIRS = ['config', 'blocks', 'sections'] as const const STOREFRONT_DIRS = ['blocks', 'layout', 'sections', 'snippets', 'templates'] as const -export async function fetchLocaleSource(source: string): Promise { +export async function getLocaleSource(source: string): Promise { if (isUrl(source)) { return fetchRemoteLocales(source) } @@ -169,7 +169,7 @@ function findVariableFallbackKeys(content: string, assignedTranslations: Map { @@ -202,7 +202,7 @@ export function cleanSchemaTranslations(themeDir: string, options?: LocaleOption .filter(file => file.endsWith('.schema.json')) for (const file of schemaFiles) { - cleanLocaleFile(path.join(localesDir, file), usedKeys, options) + removeUnusedKeysFromFile(path.join(localesDir, file), usedKeys, options) } } @@ -213,11 +213,11 @@ export function cleanStorefrontTranslations(themeDir: string, options?: LocaleOp .filter(file => file.endsWith('.json') && !file.endsWith('.schema.json')) for (const file of localeFiles) { - cleanLocaleFile(path.join(localesDir, file), usedKeys, options) + removeUnusedKeysFromFile(path.join(localesDir, file), usedKeys, options) } } -function cleanLocaleFile(filePath: string, usedKeys: Set, options?: LocaleOptions): void { +function removeUnusedKeysFromFile(filePath: string, usedKeys: Set, options?: LocaleOptions): void { try { const content = JSON.parse(fs.readFileSync(filePath, 'utf8')) if (!content || typeof content !== 'object') return @@ -239,7 +239,7 @@ function cleanLocaleFile(filePath: string, usedKeys: Set, options?: Loca } } -export async function syncLocales( +export async function mergeLocaleFiles( themeDir: string, sourceLocales: Record>, options?: SyncOptions @@ -264,7 +264,7 @@ export async function syncLocales( } const targetContent = JSON.parse(fs.readFileSync(targetPath, 'utf8')) - const diff = diffLocales(sourceContent, targetContent) + const diff = compareLocales(sourceContent, targetContent) const mergedContent = mode === 'replace-existing' ? replaceTranslations(sourceContent, targetContent) @@ -276,7 +276,7 @@ export async function syncLocales( } } -export function diffLocales(source: Record, target: Record): LocaleDiff { +export function compareLocales(source: Record, target: Record): LocaleDiff { const flatSource = flattenObject(source) const flatTarget = flattenObject(target) From 203fdd9d952fc360e47482bd70a9c643dc4edb41 Mon Sep 17 00:00:00 2001 From: Chris Berthe Date: Wed, 26 Feb 2025 13:25:15 -0500 Subject: [PATCH 41/53] chore: add checks to see if folders/files exist --- src/utilities/locales.ts | 80 ++++++++++++++++++++++++++++------------ 1 file changed, 56 insertions(+), 24 deletions(-) diff --git a/src/utilities/locales.ts b/src/utilities/locales.ts index f9fc4fe2..93eb3d43 100644 --- a/src/utilities/locales.ts +++ b/src/utilities/locales.ts @@ -33,12 +33,21 @@ async function fetchRemoteLocales(url: string): Promise { } function loadLocalLocales(dir: string): LocaleContent { + if (!fs.existsSync(dir)) { + throw new Error(`Directory does not exist: ${dir}`) + } + const content: LocaleContent = {} const files = fs.readdirSync(dir).filter(file => file.endsWith('.json')) for (const file of files) { const filePath = path.join(dir, file) - content[file] = JSON.parse(fs.readFileSync(filePath, 'utf8')) + + try { + content[file] = JSON.parse(fs.readFileSync(filePath, 'utf8')) + } catch (error) { + throw new Error(`Failed to parse JSON file ${file}: ${error instanceof Error ? error.message : String(error)}`) + } } return content @@ -50,6 +59,12 @@ export function writeLocaleFile( options?: LocaleOptions ): void { const formattedContent = options?.format ? sortObjectKeys(content) : content + const dirPath = path.dirname(filePath) + + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }) + } + fs.writeFileSync(filePath, JSON.stringify(formattedContent, null, 2) + '\n') } @@ -71,7 +86,8 @@ function scanFiles(themeDir: string, dirs: readonly string[], findKeys: (content .filter(file => file.endsWith('.liquid') || file.endsWith('.json')) for (const file of files) { - const content = fs.readFileSync(path.join(dirPath, file), 'utf8') + const filePath = path.join(dirPath, file) + const content = fs.readFileSync(filePath, 'utf8') const keys = findKeys(content) for (const key of keys) { usedKeys.add(key) @@ -87,8 +103,10 @@ function findSchemaKeys(content: string): Set { const matches = content.match(/"t:([^"]+)"/g) || [] for (const match of matches) { - const key = match.match(/"t:([^"]+)"/)![1] - keys.add(key) + const keyMatch = match.match(/"t:([^"]+)"/) + if (keyMatch && keyMatch[1]) { + keys.add(keyMatch[1]) + } } return keys @@ -108,8 +126,10 @@ function findStorefrontKeys(content: string): Set { for (const pattern of standardPatterns) { const matches = content.match(pattern) || [] for (const match of matches) { - const key = match.match(/["']([^"']+)["']/)![1] - keys.add(key) + const keyMatch = match.match(/["']([^"']+)["']/) + if (keyMatch && keyMatch[1]) { + keys.add(keyMatch[1]) + } } } @@ -132,7 +152,7 @@ function findAssignedTranslations(content: string): Map { const assignments = new Map() const pattern = /{%-?\s*assign\s+([^\s=]+)\s*=\s*["']([^"']+)["']\s*\|\s*t[^%]*-?%}/g - const matches = content.matchAll(pattern) + const matches = [...content.matchAll(pattern)] for (const match of matches) { const [, varName, translationKey] = match assignments.set(varName, translationKey) @@ -145,7 +165,7 @@ function findDirectFallbackKeys(content: string): Set { const keys = new Set() const pattern = /render\s+["']t_with_fallback["'][^%]*key:\s*["']([^"']+)["']/g - const matches = content.matchAll(pattern) + const matches = [...content.matchAll(pattern)] for (const match of matches) { keys.add(match[1]) } @@ -157,7 +177,7 @@ function findVariableFallbackKeys(content: string, assignedTranslations: Map() const pattern = /render\s+["']t_with_fallback["'][^%]*t:\s*([^\s,}]+)/g - const matches = content.matchAll(pattern) + const matches = [...content.matchAll(pattern)] for (const match of matches) { const varName = match[1] const translationKey = assignedTranslations.get(varName) @@ -198,6 +218,11 @@ export async function removeUnusedTranslations( export function cleanSchemaTranslations(themeDir: string, options?: LocaleOptions): void { const usedKeys = scanFiles(themeDir, SCHEMA_DIRS, findSchemaKeys) const localesDir = path.join(themeDir, 'locales') + + if (!fs.existsSync(localesDir)) { + throw new Error(`Locales directory does not exist: ${localesDir}`) + } + const schemaFiles = fs.readdirSync(localesDir) .filter(file => file.endsWith('.schema.json')) @@ -209,6 +234,11 @@ export function cleanSchemaTranslations(themeDir: string, options?: LocaleOption export function cleanStorefrontTranslations(themeDir: string, options?: LocaleOptions): void { const usedKeys = scanFiles(themeDir, STOREFRONT_DIRS, findStorefrontKeys) const localesDir = path.join(themeDir, 'locales') + + if (!fs.existsSync(localesDir)) { + throw new Error(`Locales directory does not exist: ${localesDir}`) + } + const localeFiles = fs.readdirSync(localesDir) .filter(file => file.endsWith('.json') && !file.endsWith('.schema.json')) @@ -218,25 +248,23 @@ export function cleanStorefrontTranslations(themeDir: string, options?: LocaleOp } function removeUnusedKeysFromFile(filePath: string, usedKeys: Set, options?: LocaleOptions): void { - try { - const content = JSON.parse(fs.readFileSync(filePath, 'utf8')) - if (!content || typeof content !== 'object') return + if (!fs.existsSync(filePath)) return - const flattenedContent = flattenObject(content) - const cleanedContent: Record = {} + const content = JSON.parse(fs.readFileSync(filePath, 'utf8')) + if (!content || typeof content !== 'object') return - for (const [key, value] of Object.entries(flattenedContent)) { - const basePath = key.split('.').slice(0, -1).join('.') - if (usedKeys.has(key) || usedKeys.has(basePath)) { - cleanedContent[key] = value - } - } + const flattenedContent = flattenObject(content) + const cleanedContent: Record = {} - const unflattened = unflattenObject(cleanedContent) - writeLocaleFile(filePath, unflattened, options) - } catch (error) { - throw new Error(`Error processing ${path.basename(filePath)}: ${error}`) + for (const [key, value] of Object.entries(flattenedContent)) { + const basePath = key.split('.').slice(0, -1).join('.') + if (usedKeys.has(key) || usedKeys.has(basePath)) { + cleanedContent[key] = value + } } + + const unflattened = unflattenObject(cleanedContent) + writeLocaleFile(filePath, unflattened, options) } export async function mergeLocaleFiles( @@ -247,6 +275,10 @@ export async function mergeLocaleFiles( const localesDir = path.join(themeDir, 'locales') const { format = false, mode = 'add-missing', target = 'all' } = options ?? {} + if (!fs.existsSync(localesDir)) { + fs.mkdirSync(localesDir, { recursive: true }) + } + const filesToSync = Object.entries(sourceLocales).filter(([file]) => { const isSchemaFile = file.endsWith('.schema.json') From 91006f4656d562fa08bd2f4b72220474e2717fde Mon Sep 17 00:00:00 2001 From: Chris Berthe Date: Wed, 26 Feb 2025 13:44:34 -0500 Subject: [PATCH 42/53] chore: update README.md --- README.md | 176 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 148 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index d59b1a5b..fdc309f4 100644 --- a/README.md +++ b/README.md @@ -20,22 +20,26 @@ You'll need to ensure you have the following installed on your local development ### Installation Install the Shopify CLI plugin: + ```bash shopify plugins install plugin-theme-component ``` - ## List of commands -* [`shopify theme component`](#shopify-theme-component) -* [`shopify theme component clean [THEMEDIR]`](#shopify-theme-component-clean-themedir) -* [`shopify theme component copy THEMEDIR`](#shopify-theme-component-copy-themedir) -* [`shopify theme component dev [COMPONENTSELECTOR]`](#shopify-theme-component-dev-componentselector) -* [`shopify theme component install THEMEDIR [COMPONENTSELECTOR]`](#shopify-theme-component-install-themedir-componentselector) -* [`shopify theme component map THEMEDIR [COMPONENTSELECTOR]`](#shopify-theme-component-map-themedir-componentselector) -* [`shopify theme generate import-map [THEMEDIR]`](#shopify-theme-generate-import-map-themedir) -* [`shopify theme generate template-map [THEMEDIR]`](#shopify-theme-generate-template-map-themedir) + +- [`shopify theme component`](#shopify-theme-component) +- [`shopify theme component clean [THEMEDIR]`](#shopify-theme-component-clean-themedir) +- [`shopify theme component copy THEMEDIR`](#shopify-theme-component-copy-themedir) +- [`shopify theme component dev [COMPONENTSELECTOR]`](#shopify-theme-component-dev-componentselector) +- [`shopify theme component install THEMEDIR [COMPONENTSELECTOR]`](#shopify-theme-component-install-themedir-componentselector) +- [`shopify theme component map THEMEDIR [COMPONENTSELECTOR]`](#shopify-theme-component-map-themedir-componentselector) +- [`shopify theme generate import-map [THEMEDIR]`](#shopify-theme-generate-import-map-themedir) +- [`shopify theme generate template-map [THEMEDIR]`](#shopify-theme-generate-template-map-themedir) +- [`shopify theme locale [THEMEDIR]`](#shopify-theme-locale-themedir) +- [`shopify theme locale clean [THEMEDIR]`](#shopify-theme-locale-clean-themedir) +- [`shopify theme locale sync [THEMEDIR]`](#shopify-theme-locale-sync-themedir) ## `shopify theme component` @@ -66,7 +70,7 @@ ARGUMENTS THEMEDIR [default: .] path to theme directory FLAGS - -q, --[no-]quiet suppress non-essential output + -q, --[no-]quiet Suppress non-essential output DESCRIPTION Remove unused component files in a theme @@ -89,8 +93,8 @@ ARGUMENTS THEMEDIR path to theme directory FLAGS - -n, --collection-name= name of the component collection - -v, --collection-version= version of the component collection + -n, --collection-name= Name of the component collection + -v, --collection-version= Version of the component collection DESCRIPTION Copy files from a component collection into a theme @@ -115,15 +119,15 @@ ARGUMENTS COMPONENTSELECTOR [default: *] component name or names (comma-separated) or "*" for all components FLAGS - -i, --generate-import-map generate import map - -m, --generate-template-map generate template map - -n, --collection-name= name of the component collection - -s, --[no-]setup-files copy setup files to theme directory - -t, --theme-dir= [default: https://github.com/archetype-themes/explorer] directory that contains + -i, --generate-import-map Generate import map + -m, --generate-template-map Generate template map + -n, --collection-name= Name of the component collection + -s, --[no-]setup-files Copy setup files to theme directory + -t, --theme-dir= [default: https://github.com/archetype-themes/explorer] Directory that contains theme files for development - -v, --collection-version= version of the component collection - -w, --[no-]watch watch for changes in theme and component directories - -y, --[no-]preview sync changes to theme directory + -v, --collection-version= Version of the component collection + -w, --[no-]watch Watch for changes in theme and component directories + -y, --[no-]preview Sync changes to theme directory --environment= The environment to apply to the current command. --host= Set which network interface the web server listens on. The default value is 127.0.0.1. @@ -160,8 +164,8 @@ ARGUMENTS COMPONENTSELECTOR [default: *] component name or names (comma-separated) or "*" for all components FLAGS - -n, --collection-name= name of the component collection - -v, --collection-version= version of the component collection + -n, --collection-name= Name of the component collection + -v, --collection-version= Version of the component collection DESCRIPTION Runs the map, copy, clean, and generate import-map commands in sequence @@ -189,10 +193,10 @@ ARGUMENTS COMPONENTSELECTOR [default: *] component name or names (comma-separated) or "*" for all components FLAGS - -f, --ignore-conflicts ignore conflicts when mapping components - -n, --collection-name= name of the component collection - -o, --ignore-overrides ignore overrides when mapping components - -v, --collection-version= version of the component collection + -f, --ignore-conflicts Ignore conflicts when mapping components + -n, --collection-name= Name of the component collection + -o, --ignore-overrides Ignore overrides when mapping components + -v, --collection-version= Version of the component collection DESCRIPTION Generates or updates a component.manifest.json file with the component collection details and a file map @@ -219,7 +223,7 @@ ARGUMENTS THEMEDIR [default: .] path to theme directory FLAGS - -q, --[no-]quiet suppress non-essential output + -q, --[no-]quiet Suppress non-essential output DESCRIPTION Generate an import map for JavaScript files in the assets directory @@ -239,13 +243,128 @@ ARGUMENTS THEMEDIR [default: .] path to theme directory FLAGS - -q, --[no-]quiet suppress non-essential output + -q, --[no-]quiet Suppress non-essential output DESCRIPTION Generate a template map for component routes in the templates directory ``` _See code: [src/commands/theme/generate/template-map.ts](https://github.com/archetype-themes/plugin-theme-component/blob/v5.0.8/src/commands/theme/generate/template-map.ts)_ + +## `shopify theme locale [THEMEDIR]` + +Sync theme locale files with source translations + +``` +USAGE + $ shopify theme locale [THEMEDIR] [-c] [--format] [-l ] [-m + add-missing|add-and-override|replace-existing] [--target all|schema|storefront] + +ARGUMENTS + THEMEDIR [default: .] path to theme directory + +FLAGS + -c, --clean Remove unused translations from theme locale files + -l, --locales-dir= [default: https://github.com/archetype-themes/locales] Directory or repository containing + locale files + -m, --mode=