@open-assets/open-assets 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -89,6 +89,7 @@ program
89
89
  .option("--tag <tag>", "Render only collections with this tag")
90
90
  .option("--template <name>", "Render only the template with this name")
91
91
  .option("--size <name>", "Use a named export size from config")
92
+ .option("--locale <code>", "Render only this locale (e.g. en, ar, ja)")
92
93
  .option("--width <px>", "Output width in pixels (custom size)")
93
94
  .option("--height <px>", "Output height in pixels (custom size)")
94
95
  .option("-f, --force", "Export at every size and re-render all assets (ignores cache)")
@@ -0,0 +1,159 @@
1
+ import { readFileSync, existsSync } from "fs";
2
+ import { resolve } from "path";
3
+
4
+ /**
5
+ * Locales that use right-to-left script direction.
6
+ * Includes base language codes — region variants (e.g. "ar-SA") are
7
+ * detected by matching the base language.
8
+ */
9
+ const RTL_LANGUAGES = new Set([
10
+ "ar", // Arabic
11
+ "he", // Hebrew
12
+ "fa", // Persian (Farsi)
13
+ "ur", // Urdu
14
+ "yi", // Yiddish
15
+ "ps", // Pashto
16
+ "sd", // Sindhi
17
+ "ku", // Kurdish (Sorani)
18
+ "dv", // Divehi (Maldivian)
19
+ "ug", // Uyghur
20
+ ]);
21
+
22
+ /**
23
+ * Check if a locale uses right-to-left text direction.
24
+ * Matches exact locale codes and base language (e.g. "ar-SA" → "ar").
25
+ */
26
+ export function isRtl(locale) {
27
+ if (RTL_LANGUAGES.has(locale)) return true;
28
+ const base = locale.split("-")[0];
29
+ return RTL_LANGUAGES.has(base);
30
+ }
31
+
32
+ /**
33
+ * Get text direction for a locale: "rtl" or "ltr".
34
+ */
35
+ export function getDirection(locale) {
36
+ return isRtl(locale) ? "rtl" : "ltr";
37
+ }
38
+
39
+ /**
40
+ * Load and parse a localizations JSON file (iOS .xcstrings-inspired format).
41
+ *
42
+ * Expected format:
43
+ * {
44
+ * "sourceLanguage": "en",
45
+ * "strings": {
46
+ * "key_name": {
47
+ * "localizations": {
48
+ * "en": { "value": "Hello" },
49
+ * "ar": { "value": "مرحبا" }
50
+ * }
51
+ * }
52
+ * }
53
+ * }
54
+ */
55
+ export function loadLocalizations(projectDir, localizationsPath) {
56
+ const fullPath = resolve(projectDir, localizationsPath);
57
+ if (!existsSync(fullPath)) {
58
+ throw new Error(`Localizations file not found: ${localizationsPath}`);
59
+ }
60
+ try {
61
+ return JSON.parse(readFileSync(fullPath, "utf-8"));
62
+ } catch (err) {
63
+ throw new Error(`Invalid JSON in ${localizationsPath}: ${err.message}`);
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Resolve all localized string values for a given locale.
69
+ * Falls back: exact locale → base language → source language.
70
+ *
71
+ * Returns a flat { key: value } map.
72
+ */
73
+ export function resolveStrings(localizations, locale) {
74
+ const strings = {};
75
+ const sourceLanguage = localizations.sourceLanguage || "en";
76
+
77
+ for (const [key, entry] of Object.entries(localizations.strings || {})) {
78
+ const l10ns = entry.localizations || {};
79
+ const value =
80
+ l10ns[locale]?.value ??
81
+ l10ns[locale.split("-")[0]]?.value ??
82
+ l10ns[sourceLanguage]?.value;
83
+ if (value !== undefined) {
84
+ strings[key] = value;
85
+ }
86
+ }
87
+
88
+ return strings;
89
+ }
90
+
91
+ /**
92
+ * Collect all unique locales defined across all string entries.
93
+ * If filterLocales is provided, returns only those that exist in the data.
94
+ * Results are sorted alphabetically.
95
+ */
96
+ export function getLocales(localizations, filterLocales) {
97
+ const allLocales = new Set();
98
+ for (const entry of Object.values(localizations.strings || {})) {
99
+ for (const locale of Object.keys(entry.localizations || {})) {
100
+ allLocales.add(locale);
101
+ }
102
+ }
103
+
104
+ if (filterLocales && filterLocales.length > 0) {
105
+ return filterLocales.filter((l) => allLocales.has(l));
106
+ }
107
+
108
+ return [...allLocales].sort();
109
+ }
110
+
111
+ /**
112
+ * JavaScript snippet injected into the Puppeteer page to perform
113
+ * localization substitution on the live DOM.
114
+ *
115
+ * Replaces {{key}} placeholders in text nodes with localized values.
116
+ * Processes {{n:NUMBER}} patterns in values for locale-aware number formatting.
117
+ * Sets lang and dir attributes on <html>.
118
+ */
119
+ export function buildLocalizationScript(strings, locale, direction) {
120
+ return `
121
+ (function() {
122
+ document.documentElement.lang = ${JSON.stringify(locale)};
123
+
124
+ const strings = ${JSON.stringify(strings)};
125
+ const locale = ${JSON.stringify(locale)};
126
+ const dir = ${JSON.stringify(direction)};
127
+ const formatter = new Intl.NumberFormat(locale);
128
+
129
+ function processValue(value) {
130
+ return value.replace(/\\{\\{n:(\\d+(?:\\.\\d+)?)\\}\\}/g, function(_, num) {
131
+ return formatter.format(parseFloat(num));
132
+ });
133
+ }
134
+
135
+ const walker = document.createTreeWalker(
136
+ document.documentElement,
137
+ NodeFilter.SHOW_TEXT
138
+ );
139
+ while (walker.nextNode()) {
140
+ var node = walker.currentNode;
141
+ var text = node.textContent;
142
+ var changed = false;
143
+ text = text.replace(/\\{\\{([a-zA-Z_][\\w.]*)\\}\\}/g, function(match, key) {
144
+ if (strings[key] !== undefined) {
145
+ changed = true;
146
+ return processValue(strings[key]);
147
+ }
148
+ return match;
149
+ });
150
+ if (changed) {
151
+ node.textContent = text;
152
+ if (dir === "rtl") {
153
+ node.parentElement.dir = dir;
154
+ }
155
+ }
156
+ }
157
+ })();
158
+ `;
159
+ }
package/lib/lockfile.mjs CHANGED
@@ -90,13 +90,14 @@ export function computeChecksum(filePath) {
90
90
  }
91
91
 
92
92
  /**
93
- * Check if an asset variant is up-to-date based on its stored checksum.
94
- * Returns true if the stored checksum matches and the output file exists.
93
+ * Check if an asset variant is up-to-date based on its stored checksums.
94
+ * Returns true if both source and export config checksums match and the output file exists.
95
95
  */
96
- export function isUpToDate(lockData, assetKey, variantKey, currentChecksum, outputPath) {
96
+ export function isUpToDate(lockData, assetKey, variantKey, currentChecksum, outputPath, exportChecksum) {
97
97
  const entry = lockData.assets?.[assetKey]?.[variantKey];
98
98
  if (!entry) return false;
99
99
  if (entry.sourceChecksum !== currentChecksum) return false;
100
+ if (exportChecksum && entry.exportChecksum !== exportChecksum) return false;
100
101
  if (!existsSync(resolve(outputPath))) return false;
101
102
  return true;
102
103
  }
@@ -104,13 +105,36 @@ export function isUpToDate(lockData, assetKey, variantKey, currentChecksum, outp
104
105
  /**
105
106
  * Record an export in the lockfile data (in memory — call writeLockfile to persist).
106
107
  */
107
- export function recordExport(lockData, assetKey, variantKey, checksum, outputPath) {
108
+ export function recordExport(lockData, assetKey, variantKey, checksum, outputPath, exportChecksum) {
108
109
  if (!lockData.assets[assetKey]) {
109
110
  lockData.assets[assetKey] = {};
110
111
  }
111
- lockData.assets[assetKey][variantKey] = {
112
+ const entry = {
112
113
  sourceChecksum: checksum,
113
114
  outputPath,
114
115
  exportedAt: new Date().toISOString(),
115
116
  };
117
+ if (exportChecksum) {
118
+ entry.exportChecksum = exportChecksum;
119
+ }
120
+ lockData.assets[assetKey][variantKey] = entry;
121
+ }
122
+
123
+ /**
124
+ * Compute a checksum for the export configuration of a variant.
125
+ * Captures size dimensions, outFile path, and locale so that config changes trigger re-export.
126
+ */
127
+ export function computeExportChecksum(entry, locale) {
128
+ const config = {
129
+ width: entry.size.width,
130
+ height: entry.size.height,
131
+ };
132
+ if (entry.outFile) {
133
+ config.outFile = entry.outFile;
134
+ }
135
+ if (locale) {
136
+ config.locale = locale;
137
+ }
138
+ const hash = createHash("sha256").update(JSON.stringify(config)).digest("hex");
139
+ return `sha256:${hash}`;
116
140
  }
package/lib/render.mjs CHANGED
@@ -1,6 +1,8 @@
1
1
  import { resolve, join } from "path";
2
2
  import { mkdirSync, writeFileSync, copyFileSync, existsSync } from "fs";
3
3
  import { filterCollections, filterTemplates, resolveRenderSizes, flatOutputs } from "./manifest.mjs";
4
+ import { computeExportChecksum } from "./lockfile.mjs";
5
+ import { loadLocalizations, resolveStrings, getLocales, getDirection } from "./localization.mjs";
4
6
 
5
7
  /**
6
8
  * @typedef {object} RenderDeps
@@ -10,8 +12,8 @@ import { filterCollections, filterTemplates, resolveRenderSizes, flatOutputs } f
10
12
  * @property {(projectDir: string) => object} readLockfile
11
13
  * @property {(projectDir: string, data: object) => void} writeLockfile
12
14
  * @property {(filePath: string) => string|null} computeChecksum
13
- * @property {(lockData: object, assetKey: string, variantKey: string, checksum: string, outputPath: string) => boolean} isUpToDate
14
- * @property {(lockData: object, assetKey: string, variantKey: string, checksum: string, outputPath: string) => void} recordExport
15
+ * @property {(lockData: object, assetKey: string, variantKey: string, checksum: string, outputPath: string, exportChecksum?: string) => boolean} isUpToDate
16
+ * @property {(lockData: object, assetKey: string, variantKey: string, checksum: string, outputPath: string, exportChecksum?: string) => void} recordExport
15
17
  */
16
18
 
17
19
  /**
@@ -24,6 +26,7 @@ import { filterCollections, filterTemplates, resolveRenderSizes, flatOutputs } f
24
26
  * @param {string} [options.tag] - Filter by tag
25
27
  * @param {string} [options.template] - Filter by template name/label
26
28
  * @param {string} [options.size] - Named export size
29
+ * @param {string} [options.locale] - Filter to a single locale
27
30
  * @param {string} [options.format] - Output format override (png or jpg)
28
31
  * @param {string|number} [options.width] - Custom width
29
32
  * @param {string|number} [options.height] - Custom height
@@ -79,97 +82,144 @@ export async function renderAssets(projectDir, manifest, options, deps, log) {
79
82
  continue;
80
83
  }
81
84
 
85
+ // Resolve locales for this collection
86
+ let locales = [null]; // null = no localization
87
+ let localizations = null;
88
+
89
+ if (col.localizations) {
90
+ localizations = loadLocalizations(projectDir, col.localizations);
91
+ locales = getLocales(localizations, col.locales);
92
+ if (options.locale) {
93
+ locales = locales.filter((l) => l === options.locale);
94
+ if (locales.length === 0) {
95
+ _log(` Warning: locale "${options.locale}" not found in collection "${col.id}", skipping`);
96
+ continue;
97
+ }
98
+ }
99
+ }
100
+
82
101
  const singleTemplate = templates.length === 1;
83
102
  const multipleSizes = sizesToRender.length > 1 || options.force;
84
103
  // Single-template collections use flat layout with entry.name as filename
85
104
  // Multi-template collections use subdirs per size
86
105
  const useSubDirs = multipleSizes && !singleTemplate;
106
+ const hasLocales = locales.length > 0 && locales[0] !== null;
87
107
 
88
- for (const entry of sizesToRender) {
89
- const w = entry.size.width;
90
- const h = entry.size.height;
108
+ for (const locale of locales) {
109
+ const strings = locale ? resolveStrings(localizations, locale) : null;
110
+ const direction = locale ? getDirection(locale) : null;
111
+ const localization = locale ? { strings, locale, direction } : undefined;
91
112
 
92
- const sizeDir = useSubDirs
93
- ? join(outputDir, col.id, entry.name)
94
- : join(outputDir, col.id);
95
- mkdirSync(sizeDir, { recursive: true });
96
-
97
- if (sizesToRender.length > 1) {
98
- _log(`\n ${entry.label} (${w}x${h})`);
113
+ if (locale) {
114
+ _log(`\n Locale: ${locale} (${direction})`);
99
115
  }
100
116
 
101
- for (const template of templates) {
102
- // Determine output format from outFile extension or default to png
103
- const outExt = entry.outFile
104
- ? entry.outFile.split(".").pop().toLowerCase()
105
- : "png";
106
- const format = outExt === "jpg" || outExt === "jpeg" ? "jpeg" : "png";
107
- const defaultExt = format === "jpeg" ? "jpg" : "png";
108
-
109
- // Single-template: use entry.name as filename (e.g. logo-1024.png)
110
- // Multi-template: use template.name as filename (e.g. 01-hero.png)
111
- const fileName = singleTemplate && multipleSizes
112
- ? `${entry.name}.${defaultExt}`
113
- : `${template.name}.${defaultExt}`;
114
-
115
- const outPath = entry.outFile
116
- ? resolve(projectDir, entry.outFile.replace(/\{template\}/g, template.name))
117
- : join(sizeDir, fileName);
118
-
119
- const assetKey = `${col.id}/${template.name}`;
120
- const variantKey = entry.name;
121
- const srcPath = resolve(projectDir, template.src);
122
- const checksum = deps.computeChecksum(srcPath);
123
-
124
- // Skip if unchanged
125
- if (
126
- !options.force &&
127
- checksum &&
128
- deps.isUpToDate(lockData, assetKey, variantKey, checksum, outPath)
129
- ) {
130
- _log(
131
- ` Skipping ${template.name} at ${w}x${h} (unchanged)`
132
- );
133
- skipped++;
134
- continue;
135
- }
136
-
137
- _log(
138
- ` Rendering ${template.name} at ${w}x${h}...`
139
- );
117
+ for (const entry of sizesToRender) {
118
+ const w = entry.size.width;
119
+ const h = entry.size.height;
140
120
 
141
- const isSvg = template.src.endsWith(".svg");
142
- if (isSvg && w === sourceW && h === sourceH) {
143
- copyFileSync(srcPath, outPath.replace(`.${defaultExt}`, ".svg"));
121
+ // Build output directory: insert locale level when localizations are active
122
+ let sizeDir;
123
+ if (hasLocales) {
124
+ sizeDir = useSubDirs
125
+ ? join(outputDir, col.id, locale, entry.name)
126
+ : join(outputDir, col.id, locale);
127
+ } else {
128
+ sizeDir = useSubDirs
129
+ ? join(outputDir, col.id, entry.name)
130
+ : join(outputDir, col.id);
144
131
  }
132
+ mkdirSync(sizeDir, { recursive: true });
145
133
 
146
- // Ensure output directory exists when using outFile
147
- if (entry.outFile) {
148
- mkdirSync(resolve(projectDir, join(outPath, "..")), { recursive: true });
134
+ if (sizesToRender.length > 1) {
135
+ _log(`\n ${entry.label} (${w}x${h})`);
149
136
  }
150
137
 
151
- const buffer = await deps.renderScreenshot(
152
- projectDir,
153
- template.src,
154
- w,
155
- h,
156
- sourceW,
157
- sourceH,
158
- { format, background: col.background }
159
- );
160
- writeFileSync(outPath, buffer);
161
- results.push({
162
- collection: col.id,
163
- template: template.name,
164
- path: outPath,
165
- width: w,
166
- height: h,
167
- size: buffer.length,
168
- });
169
- if (checksum) {
170
- deps.recordExport(lockData, assetKey, variantKey, checksum, outPath);
138
+ for (const template of templates) {
139
+ // Determine output format from outFile extension or default to png
140
+ const outExt = entry.outFile
141
+ ? entry.outFile.split(".").pop().toLowerCase()
142
+ : "png";
143
+ const format = outExt === "jpg" || outExt === "jpeg" ? "jpeg" : "png";
144
+ const defaultExt = format === "jpeg" ? "jpg" : "png";
145
+
146
+ // Single-template: use entry.name as filename (e.g. logo-1024.png)
147
+ // Multi-template: use template.name as filename (e.g. 01-hero.png)
148
+ const fileName = singleTemplate && multipleSizes
149
+ ? `${entry.name}.${defaultExt}`
150
+ : `${template.name}.${defaultExt}`;
151
+
152
+ let outPath;
153
+ if (entry.outFile) {
154
+ let resolved = entry.outFile.replace(/\{template\}/g, template.name);
155
+ if (locale) {
156
+ resolved = resolved.replace(/\{locale\}/g, locale);
157
+ }
158
+ outPath = resolve(projectDir, resolved);
159
+ } else {
160
+ outPath = join(sizeDir, fileName);
161
+ }
162
+
163
+ // Asset key and variant key include locale for cache isolation
164
+ const assetKey = locale
165
+ ? `${col.id}/${locale}/${template.name}`
166
+ : `${col.id}/${template.name}`;
167
+ const variantKey = entry.name;
168
+ const srcPath = resolve(projectDir, template.src);
169
+ const checksum = deps.computeChecksum(srcPath);
170
+ const exportCksum = computeExportChecksum(entry, locale);
171
+
172
+ // Skip if unchanged
173
+ if (
174
+ !options.force &&
175
+ checksum &&
176
+ deps.isUpToDate(lockData, assetKey, variantKey, checksum, outPath, exportCksum)
177
+ ) {
178
+ _log(
179
+ ` Skipping ${template.name}${locale ? ` [${locale}]` : ""} at ${w}x${h} (unchanged)`
180
+ );
181
+ skipped++;
182
+ continue;
183
+ }
184
+
185
+ _log(
186
+ ` Rendering ${template.name}${locale ? ` [${locale}]` : ""} at ${w}x${h}...`
187
+ );
188
+
189
+ const isSvg = template.src.endsWith(".svg");
190
+ if (isSvg && w === sourceW && h === sourceH) {
191
+ copyFileSync(srcPath, outPath.replace(`.${defaultExt}`, ".svg"));
192
+ }
193
+
194
+ // Ensure output directory exists when using outFile
195
+ if (entry.outFile) {
196
+ mkdirSync(resolve(projectDir, join(outPath, "..")), { recursive: true });
197
+ }
198
+
199
+ const buffer = await deps.renderScreenshot(
200
+ projectDir,
201
+ template.src,
202
+ w,
203
+ h,
204
+ sourceW,
205
+ sourceH,
206
+ { format, background: col.background, localization }
207
+ );
208
+ writeFileSync(outPath, buffer);
209
+ results.push({
210
+ collection: col.id,
211
+ template: template.name,
212
+ locale: locale || undefined,
213
+ path: outPath,
214
+ width: w,
215
+ height: h,
216
+ size: buffer.length,
217
+ });
218
+ if (checksum) {
219
+ deps.recordExport(lockData, assetKey, variantKey, checksum, outPath, exportCksum);
220
+ }
221
+ _log(` → ${outPath}`);
171
222
  }
172
- _log(` → ${outPath}`);
173
223
  }
174
224
  }
175
225
 
package/lib/renderer.mjs CHANGED
@@ -27,7 +27,7 @@ export async function closeBrowser() {
27
27
  * @param {number} height
28
28
  * @param {number} sourceW
29
29
  * @param {number} sourceH
30
- * @param {{ format?: "png"|"jpeg", quality?: number, background?: string }} [options]
30
+ * @param {{ format?: "png"|"jpeg", quality?: number, background?: string, localization?: { strings: object, locale: string, direction: string } }} [options]
31
31
  */
32
32
  export async function renderScreenshot(
33
33
  projectDir,
@@ -49,6 +49,51 @@ export async function renderScreenshot(
49
49
  await page.goto(`file://${filePath}`, { waitUntil: "networkidle0" });
50
50
  await new Promise((r) => setTimeout(r, 500));
51
51
 
52
+ // Inject localized strings and set lang/dir before rendering
53
+ if (options.localization) {
54
+ const { strings, locale, direction } = options.localization;
55
+ await page.evaluate((strings, locale, dir) => {
56
+ // Set lang on root for font selection; do NOT set dir on root
57
+ // because it flips the entire CSS layout (flexbox, margins, etc.)
58
+ // which breaks fixed-pixel marketing layouts.
59
+ document.documentElement.lang = locale;
60
+
61
+ const formatter = new Intl.NumberFormat(locale);
62
+
63
+ function processValue(value) {
64
+ return value.replace(/\{\{n:(\d+(?:\.\d+)?)\}\}/g, (_, num) => {
65
+ return formatter.format(parseFloat(num));
66
+ });
67
+ }
68
+
69
+ const walker = document.createTreeWalker(
70
+ document.documentElement,
71
+ NodeFilter.SHOW_TEXT
72
+ );
73
+ while (walker.nextNode()) {
74
+ const node = walker.currentNode;
75
+ let text = node.textContent;
76
+ let changed = false;
77
+ text = text.replace(/\{\{([a-zA-Z_][\w.]*)\}\}/g, (match, key) => {
78
+ if (strings[key] !== undefined) {
79
+ changed = true;
80
+ return processValue(strings[key]);
81
+ }
82
+ return match;
83
+ });
84
+ if (changed) {
85
+ node.textContent = text;
86
+ // Apply text direction on the parent element so RTL text
87
+ // renders correctly without flipping the overall page layout.
88
+ if (dir === "rtl") {
89
+ node.parentElement.dir = dir;
90
+ }
91
+ }
92
+ }
93
+ }, strings, locale, direction);
94
+ await new Promise((r) => setTimeout(r, 200));
95
+ }
96
+
52
97
  if (scale !== 1) {
53
98
  await page.evaluate((z) => {
54
99
  document.documentElement.style.zoom = z;
package/lib/validate.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "fs";
2
2
  import { resolve } from "path";
3
3
  import { flatSizes, flatOutputs } from "./manifest.mjs";
4
+ import { loadLocalizations, getLocales } from "./localization.mjs";
4
5
 
5
6
  /**
6
7
  * Validate a config object and check that referenced files exist.
@@ -82,6 +83,35 @@ export function validateConfig(manifest, projectDir) {
82
83
  ok(`Collection "${col.id}" has ${sizes.length} export size(s)`);
83
84
  }
84
85
 
86
+ // Validate localizations if configured
87
+ if (col.localizations) {
88
+ const l10nPath = resolve(projectDir, col.localizations);
89
+ if (!existsSync(l10nPath)) {
90
+ fail(`Collection "${col.id}" localizations file not found: ${col.localizations}`);
91
+ } else {
92
+ try {
93
+ const l10nData = loadLocalizations(projectDir, col.localizations);
94
+ if (!l10nData.sourceLanguage) {
95
+ fail(`Collection "${col.id}" localizations missing "sourceLanguage"`);
96
+ }
97
+ if (!l10nData.strings || Object.keys(l10nData.strings).length === 0) {
98
+ fail(`Collection "${col.id}" localizations has no strings defined`);
99
+ } else {
100
+ const locales = getLocales(l10nData, col.locales);
101
+ ok(`Collection "${col.id}" localizations: ${Object.keys(l10nData.strings).length} key(s), ${locales.length} locale(s)`);
102
+ if (col.locales) {
103
+ const missing = col.locales.filter((l) => !locales.includes(l));
104
+ for (const m of missing) {
105
+ fail(`Collection "${col.id}" requests locale "${m}" but it is not defined in ${col.localizations}`);
106
+ }
107
+ }
108
+ }
109
+ } catch (err) {
110
+ fail(`Collection "${col.id}" localizations error: ${err.message}`);
111
+ }
112
+ }
113
+ }
114
+
85
115
  // Validate outputs (export entries with a type field)
86
116
  const outputs = flatOutputs(col);
87
117
  for (const output of outputs) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-assets/open-assets",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Dev server and export tool for app screenshots, icons, and logos. Like Storybook for marketing assets.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -194,6 +194,7 @@ Export entries with a `type` field are post-render actions (run with `--force`):
194
194
  | `--collection <id>` | Render only the collection with this ID |
195
195
  | `--template <name>` | Render only the template with this name |
196
196
  | `--size <name>` | Use a named export size from config |
197
+ | `--locale <code>` | Render only this locale (e.g. `en`, `ar`, `ja`) |
197
198
  | `-f, --force` | Export at every size and re-render all (ignores cache) |
198
199
  | `-o, --output <dir>` | Output directory (default: `./exports`) |
199
200
  | `--json` | Output results as JSON |
@@ -335,6 +336,132 @@ The `render` command maintains a `assets.lock` file with SHA256 checksums of sou
335
336
 
336
337
  Note: only source HTML/SVG files are checksummed. Changes to referenced assets (images in publicDir, compiled CSS) will not trigger re-renders — use `--force` when those change.
337
338
 
339
+ ## Localization (i18n)
340
+
341
+ Collections can be localized so that templates render in multiple languages. When localizations are configured, each template is rendered once per locale, producing locale-specific exports with proper text direction (LTR/RTL).
342
+
343
+ ### Localizations file
344
+
345
+ Create a JSON file (iOS `.xcstrings`-inspired format) that maps string keys to per-locale values:
346
+
347
+ ```json
348
+ {
349
+ "sourceLanguage": "en",
350
+ "strings": {
351
+ "hero_title": {
352
+ "localizations": {
353
+ "en": { "value": "Track your tax days" },
354
+ "ar": { "value": "تتبع أيام الضرائب الخاصة بك" },
355
+ "ja": { "value": "税金の日を追跡する" },
356
+ "de": { "value": "Verfolge deine Steuertage" }
357
+ }
358
+ },
359
+ "day_count_label": {
360
+ "localizations": {
361
+ "en": { "value": "You have {{n:183}} days remaining" },
362
+ "ar": { "value": "لديك {{n:183}} يوم متبقي" },
363
+ "de": { "value": "Sie haben {{n:183}} Tage übrig" }
364
+ }
365
+ }
366
+ }
367
+ }
368
+ ```
369
+
370
+ **Number formatting:** Use `{{n:NUMBER}}` inside localized values for locale-aware number formatting (e.g., `1,234` in English, `1.234` in German). Numbers are formatted using `Intl.NumberFormat` for the target locale.
371
+
372
+ ### Wiring localizations to a collection
373
+
374
+ Add `localizations` (path to the JSON file) and optionally `locales` (filter to specific locales) to a collection in `assets.json`:
375
+
376
+ ```json
377
+ {
378
+ "id": "screenshots",
379
+ "label": "App Store Screenshots",
380
+ "sourceSize": { "width": 440, "height": 956 },
381
+ "localizations": "localizations.json",
382
+ "locales": ["en", "ar", "ja", "de"],
383
+ "templates": [
384
+ { "src": "assets/screenshots/01-hero.html", "name": "01-hero", "label": "Hero" }
385
+ ],
386
+ "export": [
387
+ { "name": "iphone-6.9", "label": "iPhone 6.9\"", "size": { "width": 1320, "height": 2868 } }
388
+ ]
389
+ }
390
+ ```
391
+
392
+ | Field | Type | Description |
393
+ |-------|------|-------------|
394
+ | `localizations` | string | Path to the localizations JSON file (relative to project root) |
395
+ | `locales` | string[] | Optional filter — render only these locales. Omit to render all locales in the file. |
396
+
397
+ ### Using placeholders in templates
398
+
399
+ Templates use `{{key}}` mustache-style placeholders that get replaced with localized values before rendering:
400
+
401
+ ```html
402
+ <h1>{{hero_title}}</h1>
403
+ <p>{{day_count_label}}</p>
404
+ ```
405
+
406
+ The source language values serve as the default/fallback content visible during development. At render time, placeholders are substituted with the localized value for each target locale.
407
+
408
+ ### RTL support
409
+
410
+ Right-to-left locales (Arabic, Hebrew, Persian, Urdu, etc.) are automatically detected. For RTL locales, the renderer sets `dir="rtl"` and `lang` on the `<html>` element before taking the screenshot.
411
+
412
+ **Design templates with RTL in mind:**
413
+ - Use `logical` CSS properties (`margin-inline-start` instead of `margin-left`, `padding-inline-end` instead of `padding-right`)
414
+ - Use flexbox `gap` instead of directional margins between items
415
+ - Avoid hardcoded `text-align: left` — use `text-align: start` instead
416
+ - For layouts that must flip in RTL, use `[dir="rtl"]` CSS selectors:
417
+ ```css
418
+ .arrow { transform: rotate(0deg); }
419
+ [dir="rtl"] .arrow { transform: rotate(180deg); }
420
+ ```
421
+
422
+ ### Output structure with locales
423
+
424
+ When localizations are active, locale codes are inserted into the output path:
425
+
426
+ ```
427
+ exports/
428
+ screenshots/
429
+ en/
430
+ iphone-6.9/
431
+ 01-hero.png
432
+ ar/
433
+ iphone-6.9/
434
+ 01-hero.png
435
+ ja/
436
+ iphone-6.9/
437
+ 01-hero.png
438
+ ```
439
+
440
+ With `outFile`, use the `{locale}` template variable:
441
+ ```json
442
+ { "outFile": "output/{locale}/{template}.png" }
443
+ ```
444
+
445
+ ### CLI
446
+
447
+ ```bash
448
+ # Render all locales for a collection
449
+ open-assets render --collection screenshots --force
450
+
451
+ # Render only Arabic
452
+ open-assets render --collection screenshots --force --locale ar
453
+
454
+ # Render a single template in Japanese
455
+ open-assets render --collection screenshots --template 01-hero --locale ja --force
456
+ ```
457
+
458
+ ### Locale fallback order
459
+
460
+ When resolving a string for a locale:
461
+ 1. Exact match (e.g., `es-419`)
462
+ 2. Base language (e.g., `es`)
463
+ 3. Source language (e.g., `en`)
464
+
338
465
  ## File Structure Convention
339
466
 
340
467
  ```