@open-assets/open-assets 0.2.0 → 0.4.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/server.mjs CHANGED
@@ -2,6 +2,7 @@ import { createServer } from "http";
2
2
  import { readFileSync, existsSync, statSync, watch } from "fs";
3
3
  import { resolve, join, extname } from "path";
4
4
  import { fileURLToPath } from "url";
5
+ import { loadLocalizations, resolveStrings, getLocales, getDirection } from "./localization.mjs";
5
6
 
6
7
  const __dirname = resolve(fileURLToPath(import.meta.url), "..");
7
8
  const VIEWER_PATH = join(__dirname, "..", "public", "viewer.html");
@@ -146,7 +147,7 @@ export async function startServer(projectDir, options = {}, renderer) {
146
147
  // API: render screenshot (unified for all collection types)
147
148
  if (req.method === "POST" && pathname === "/api/render-screenshot") {
148
149
  try {
149
- const { file, width, height, sourceW, sourceH, background } = JSON.parse(
150
+ const { file, width, height, sourceW, sourceH, background, localization } = JSON.parse(
150
151
  await readBody(req)
151
152
  );
152
153
  if (!file || !width || !height) {
@@ -157,8 +158,8 @@ export async function startServer(projectDir, options = {}, renderer) {
157
158
  }
158
159
  const sw = sourceW || width;
159
160
  const sh = sourceH || height;
160
- log(`Rendering ${file} at ${width}x${height} (source ${sw}x${sh})...`);
161
- const buffer = await renderScreenshot(projectDir, file, width, height, sw, sh, { background });
161
+ log(`Rendering ${file} at ${width}x${height} (source ${sw}x${sh})${localization ? ` [${localization.locale}]` : ''}...`);
162
+ const buffer = await renderScreenshot(projectDir, file, width, height, sw, sh, { background, localization });
162
163
  res.writeHead(200, {
163
164
  "Content-Type": "image/png",
164
165
  "Content-Length": buffer.length,
@@ -172,6 +173,32 @@ export async function startServer(projectDir, options = {}, renderer) {
172
173
  return;
173
174
  }
174
175
 
176
+ // API: get localization data for a collection
177
+ if (req.method === "GET" && pathname === "/api/localizations") {
178
+ try {
179
+ const colId = url.searchParams.get("collection");
180
+ if (!colId) {
181
+ res.writeHead(400, { "Content-Type": "application/json" });
182
+ return res.end(JSON.stringify({ ok: false, error: "Missing collection param" }));
183
+ }
184
+ const col = manifest.collections.find((c) => c.id === colId);
185
+ if (!col || !col.localizations) {
186
+ res.writeHead(404, { "Content-Type": "application/json" });
187
+ return res.end(JSON.stringify({ ok: false, error: "Collection has no localizations" }));
188
+ }
189
+ const data = loadLocalizations(projectDir, col.localizations);
190
+ const locales = getLocales(data, col.locales);
191
+ const defaultLocale = manifest.defaultLocalization || data.sourceLanguage || "en";
192
+ res.writeHead(200, { "Content-Type": "application/json" });
193
+ res.end(JSON.stringify({ ok: true, data, locales, defaultLocale }));
194
+ } catch (err) {
195
+ console.error(err);
196
+ res.writeHead(500, { "Content-Type": "application/json" });
197
+ res.end(JSON.stringify({ ok: false, error: err.message }));
198
+ }
199
+ return;
200
+ }
201
+
175
202
  // API: run xcode output for a collection
176
203
  if (req.method === "POST" && pathname === "/api/run-output") {
177
204
  try {
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.4.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": {
@@ -80,6 +80,14 @@
80
80
 
81
81
  .hint { position: fixed; bottom: 12px; left: 50%; transform: translateX(-50%); font-size: 11px; color: #555; z-index: 100; pointer-events: none; }
82
82
  .hint kbd { display: inline-block; padding: 1px 5px; border: 1px solid #444; border-radius: 3px; background: #1a1a1a; font-family: inherit; font-size: 10px; color: #888; }
83
+
84
+ /* Locale picker */
85
+ .locale-picker { display: none; align-items: center; gap: 6px; margin-left: 8px; }
86
+ .locale-picker.visible { display: flex; }
87
+ .locale-picker label { font-size: 11px; color: #666; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
88
+ .locale-select { padding: 4px 8px; border-radius: 5px; border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.06); color: #ccc; font-size: 12px; font-weight: 600; font-family: inherit; cursor: pointer; appearance: none; -webkit-appearance: none; padding-right: 22px; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' fill='none'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23888' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 6px center; }
89
+ .locale-select:hover { border-color: rgba(255,255,255,0.25); color: #fff; }
90
+ .locale-select:focus { outline: none; border-color: rgba(255,255,255,0.3); }
83
91
  </style>
84
92
  </head>
85
93
  <body>
@@ -88,6 +96,10 @@
88
96
  <img class="toolbar-icon" src="/img/icon/32/icon.png" alt="" />
89
97
  <h1 id="app-title">Loading...</h1>
90
98
  <div class="segmented" id="tab-bar"></div>
99
+ <div class="locale-picker" id="locale-picker">
100
+ <label>Locale</label>
101
+ <select class="locale-select" id="locale-select"></select>
102
+ </div>
91
103
  </div>
92
104
  <div class="toolbar-right">
93
105
  <button class="toolbar-btn" id="zoom-out" title="Zoom out">−</button>
@@ -118,6 +130,9 @@
118
130
  // ---- State ----
119
131
  let manifest = null;
120
132
  let activeCollection = null;
133
+ let localeData = null; // raw localizations JSON (xcstrings format)
134
+ let availableLocales = []; // locale codes for active collection
135
+ let activeLocale = null; // currently selected locale (null = no localization)
121
136
 
122
137
  // ---- DOM refs ----
123
138
  const canvas = document.getElementById('canvas');
@@ -127,6 +142,8 @@
127
142
  const exportDropdown = document.getElementById('export-dropdown');
128
143
  const exportToggle = document.getElementById('export-toggle');
129
144
  const toast = document.getElementById('toast');
145
+ const localePicker = document.getElementById('locale-picker');
146
+ const localeSelect = document.getElementById('locale-select');
130
147
 
131
148
  // ---- Zoom / Pan ----
132
149
  let scale = 1, panX = 0, panY = 0, isPanning = false, startX = 0, startY = 0, startPanX = 0, startPanY = 0;
@@ -161,12 +178,158 @@
161
178
  function showToast(msg) { toast.textContent = msg; toast.classList.add('show'); }
162
179
  function hideToast() { toast.classList.remove('show'); }
163
180
 
181
+ // ---- Localization helpers ----
182
+
183
+ const RTL_LANGUAGES = new Set(['ar','he','fa','ur','yi','ps','sd','ku','dv','ug']);
184
+
185
+ function isRtl(locale) {
186
+ if (RTL_LANGUAGES.has(locale)) return true;
187
+ return RTL_LANGUAGES.has(locale.split('-')[0]);
188
+ }
189
+
190
+ function resolveStrings(data, locale) {
191
+ const strings = {};
192
+ const sourceLang = data.sourceLanguage || 'en';
193
+ for (const [key, entry] of Object.entries(data.strings || {})) {
194
+ const l10ns = entry.localizations || {};
195
+ const value = l10ns[locale]?.value ?? l10ns[locale.split('-')[0]]?.value ?? l10ns[sourceLang]?.value;
196
+ if (value !== undefined) strings[key] = value;
197
+ }
198
+ return strings;
199
+ }
200
+
201
+ function getLocalization() {
202
+ if (!activeLocale || !localeData) return undefined;
203
+ return {
204
+ strings: resolveStrings(localeData, activeLocale),
205
+ locale: activeLocale,
206
+ direction: isRtl(activeLocale) ? 'rtl' : 'ltr',
207
+ };
208
+ }
209
+
210
+ async function loadLocalizationsForCollection(col) {
211
+ if (!col.localizations) {
212
+ localeData = null;
213
+ availableLocales = [];
214
+ activeLocale = null;
215
+ localePicker.classList.remove('visible');
216
+ return;
217
+ }
218
+ try {
219
+ const res = await fetch(`/api/localizations?collection=${encodeURIComponent(col.id)}`);
220
+ const json = await res.json();
221
+ if (!json.ok) throw new Error(json.error);
222
+ localeData = json.data;
223
+ availableLocales = json.locales;
224
+ const defaultLocale = json.defaultLocale;
225
+
226
+ // Restore from URL param, or use defaultLocalization, or first available
227
+ const params = new URLSearchParams(window.location.search);
228
+ const urlLocale = params.get('locale');
229
+ if (urlLocale && availableLocales.includes(urlLocale)) {
230
+ activeLocale = urlLocale;
231
+ } else if (availableLocales.includes(defaultLocale)) {
232
+ activeLocale = defaultLocale;
233
+ } else {
234
+ activeLocale = availableLocales[0] || null;
235
+ }
236
+
237
+ buildLocalePicker();
238
+ localePicker.classList.add('visible');
239
+ } catch (err) {
240
+ console.error('Failed to load localizations:', err);
241
+ localeData = null;
242
+ availableLocales = [];
243
+ activeLocale = null;
244
+ localePicker.classList.remove('visible');
245
+ }
246
+ }
247
+
248
+ function buildLocalePicker() {
249
+ localeSelect.innerHTML = '';
250
+ for (const locale of availableLocales) {
251
+ const opt = document.createElement('option');
252
+ opt.value = locale;
253
+ opt.textContent = locale;
254
+ if (locale === activeLocale) opt.selected = true;
255
+ localeSelect.appendChild(opt);
256
+ }
257
+ }
258
+
259
+ localeSelect.addEventListener('change', () => {
260
+ activeLocale = localeSelect.value;
261
+ // Update URL without reload
262
+ const params = new URLSearchParams(window.location.search);
263
+ params.set('locale', activeLocale);
264
+ history.replaceState(null, '', '?' + params.toString());
265
+ applyLocalizationToIframes();
266
+ });
267
+
268
+ function applyLocalizationToIframes() {
269
+ const l10n = getLocalization();
270
+ if (!l10n) return;
271
+ const iframes = inner.querySelectorAll('iframe');
272
+ for (const iframe of iframes) {
273
+ applyLocalizationToIframe(iframe, l10n);
274
+ }
275
+ }
276
+
277
+ function applyLocalizationToIframe(iframe, l10n) {
278
+ if (!l10n) return;
279
+ try {
280
+ const doc = iframe.contentDocument || iframe.contentWindow?.document;
281
+ if (!doc || !doc.documentElement) return;
282
+
283
+ // Reload iframe to get fresh {{key}} placeholders, then inject
284
+ const src = iframe.dataset.file || iframe.getAttribute('src')?.split('?')[0];
285
+ if (!src) return;
286
+
287
+ iframe.onload = function() {
288
+ try {
289
+ const freshDoc = iframe.contentDocument || iframe.contentWindow?.document;
290
+ if (!freshDoc) return;
291
+ injectLocalization(freshDoc, l10n);
292
+ } catch (e) { /* cross-origin or timing */ }
293
+ iframe.onload = null;
294
+ };
295
+ iframe.src = src + '?_l=' + Date.now();
296
+ } catch (e) { /* ignore */ }
297
+ }
298
+
299
+ function injectLocalization(doc, l10n) {
300
+ const { strings, locale, direction } = l10n;
301
+ doc.documentElement.lang = locale;
302
+
303
+ const formatter = new Intl.NumberFormat(locale);
304
+ function processValue(value) {
305
+ return value.replace(/\{\{n:(\d+(?:\.\d+)?)\}\}/g, (_, num) => formatter.format(parseFloat(num)));
306
+ }
307
+
308
+ const walker = doc.createTreeWalker(doc.documentElement, NodeFilter.SHOW_TEXT);
309
+ while (walker.nextNode()) {
310
+ const node = walker.currentNode;
311
+ let text = node.textContent;
312
+ let changed = false;
313
+ text = text.replace(/\{\{([a-zA-Z_][\w.]*)\}\}/g, (match, key) => {
314
+ if (strings[key] !== undefined) { changed = true; return processValue(strings[key]); }
315
+ return match;
316
+ });
317
+ if (changed) {
318
+ node.textContent = text;
319
+ if (direction === 'rtl') node.parentElement.dir = direction;
320
+ }
321
+ }
322
+ }
323
+
164
324
  // ---- Render screenshot via Puppeteer server ----
165
325
  async function renderScreenshot(file, width, height, sourceW, sourceH) {
326
+ const body = { file, width, height, sourceW, sourceH };
327
+ const l10n = getLocalization();
328
+ if (l10n) body.localization = l10n;
166
329
  const res = await fetch('/api/render-screenshot', {
167
330
  method: 'POST',
168
331
  headers: { 'Content-Type': 'application/json' },
169
- body: JSON.stringify({ file, width, height, sourceW, sourceH }),
332
+ body: JSON.stringify(body),
170
333
  });
171
334
  if (!res.ok) throw new Error(`Server error: ${res.status}`);
172
335
  return await res.blob();
@@ -233,6 +396,15 @@
233
396
  iframe.style.width = sw + 'px';
234
397
  iframe.style.height = sh + 'px';
235
398
 
399
+ // Apply localization after iframe loads
400
+ const l10n = getLocalization();
401
+ if (l10n) {
402
+ iframe.addEventListener('load', function onLoad() {
403
+ try { injectLocalization(iframe.contentDocument || iframe.contentWindow.document, l10n); } catch(e) {}
404
+ iframe.removeEventListener('load', onLoad);
405
+ });
406
+ }
407
+
236
408
  border.appendChild(iframe);
237
409
  slot.appendChild(border);
238
410
  }
@@ -462,6 +634,7 @@
462
634
  activeCollection = manifest.collections.find((c) => c.id === colId) || manifest.collections[0];
463
635
 
464
636
  buildTabBar();
637
+ await loadLocalizationsForCollection(activeCollection);
465
638
  buildCanvas();
466
639
  buildExportDropdown();
467
640
  requestAnimationFrame(fitAll);
@@ -489,9 +662,16 @@
489
662
 
490
663
  // Reload iframes whose src matches the changed file
491
664
  const iframes = inner.querySelectorAll('iframe');
665
+ const l10n = getLocalization();
492
666
  for (const iframe of iframes) {
493
667
  const src = iframe.dataset.file || iframe.getAttribute('src')?.split('?')[0];
494
668
  if (src && changedFile.endsWith(src.replace(/^\//, ''))) {
669
+ if (l10n) {
670
+ iframe.onload = function() {
671
+ try { injectLocalization(iframe.contentDocument || iframe.contentWindow.document, l10n); } catch(e) {}
672
+ iframe.onload = null;
673
+ };
674
+ }
495
675
  iframe.src = src + bust;
496
676
  }
497
677
  }
@@ -510,7 +690,15 @@
510
690
  if (['css', 'js', 'mjs', 'json', 'woff', 'woff2', 'ttf'].includes(ext)) {
511
691
  for (const iframe of iframes) {
512
692
  const src = iframe.dataset.file || iframe.getAttribute('src')?.split('?')[0];
513
- if (src) iframe.src = src + bust;
693
+ if (src) {
694
+ if (l10n) {
695
+ iframe.onload = function() {
696
+ try { injectLocalization(iframe.contentDocument || iframe.contentWindow.document, l10n); } catch(e) {}
697
+ iframe.onload = null;
698
+ };
699
+ }
700
+ iframe.src = src + bust;
701
+ }
514
702
  }
515
703
  }
516
704
  }
@@ -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,144 @@ 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`. You can also set `defaultLocalization` at the root level of `assets.json` to control which locale is selected by default in the dev server's locale picker (falls back to `sourceLanguage` from the localizations file, then `"en"`):
375
+
376
+ ```json
377
+ {
378
+ "version": 1,
379
+ "name": "My App",
380
+ "defaultLocalization": "en",
381
+ "collections": [
382
+ {
383
+ "id": "screenshots",
384
+ "label": "App Store Screenshots",
385
+ "sourceSize": { "width": 440, "height": 956 },
386
+ "localizations": "localizations.json",
387
+ "locales": ["en", "ar", "ja", "de"],
388
+ "templates": [
389
+ { "src": "assets/screenshots/01-hero.html", "name": "01-hero", "label": "Hero" }
390
+ ],
391
+ "export": [
392
+ { "name": "iphone-6.9", "label": "iPhone 6.9\"", "size": { "width": 1320, "height": 2868 } }
393
+ ]
394
+ }
395
+ ]
396
+ }
397
+ ```
398
+
399
+ | Field | Type | Description |
400
+ |-------|------|-------------|
401
+ | `defaultLocalization` | string | Root-level. Default locale for the dev server locale picker. Falls back to `sourceLanguage` then `"en"`. |
402
+ | `localizations` | string | Path to the localizations JSON file (relative to project root) |
403
+ | `locales` | string[] | Optional filter — render only these locales. Omit to render all locales in the file. |
404
+
405
+ ### Dev server locale picker
406
+
407
+ When a collection has `localizations` configured, the dev server shows a locale picker dropdown in the toolbar. Selecting a locale injects the localized strings into all template iframes in real time. The selected locale is also persisted in the URL (`?locale=ar`) and used for exports — exported PNGs will include the localized text for the active locale.
408
+
409
+ ### Using placeholders in templates
410
+
411
+ Templates use `{{key}}` mustache-style placeholders that get replaced with localized values before rendering:
412
+
413
+ ```html
414
+ <h1>{{hero_title}}</h1>
415
+ <p>{{day_count_label}}</p>
416
+ ```
417
+
418
+ 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.
419
+
420
+ ### RTL support
421
+
422
+ 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.
423
+
424
+ **Design templates with RTL in mind:**
425
+ - Use `logical` CSS properties (`margin-inline-start` instead of `margin-left`, `padding-inline-end` instead of `padding-right`)
426
+ - Use flexbox `gap` instead of directional margins between items
427
+ - Avoid hardcoded `text-align: left` — use `text-align: start` instead
428
+ - For layouts that must flip in RTL, use `[dir="rtl"]` CSS selectors:
429
+ ```css
430
+ .arrow { transform: rotate(0deg); }
431
+ [dir="rtl"] .arrow { transform: rotate(180deg); }
432
+ ```
433
+
434
+ ### Output structure with locales
435
+
436
+ When localizations are active, locale codes are inserted into the output path:
437
+
438
+ ```
439
+ exports/
440
+ screenshots/
441
+ en/
442
+ iphone-6.9/
443
+ 01-hero.png
444
+ ar/
445
+ iphone-6.9/
446
+ 01-hero.png
447
+ ja/
448
+ iphone-6.9/
449
+ 01-hero.png
450
+ ```
451
+
452
+ With `outFile`, use the `{locale}` template variable:
453
+ ```json
454
+ { "outFile": "output/{locale}/{template}.png" }
455
+ ```
456
+
457
+ ### CLI
458
+
459
+ ```bash
460
+ # Render all locales for a collection
461
+ open-assets render --collection screenshots --force
462
+
463
+ # Render only Arabic
464
+ open-assets render --collection screenshots --force --locale ar
465
+
466
+ # Render a single template in Japanese
467
+ open-assets render --collection screenshots --template 01-hero --locale ja --force
468
+ ```
469
+
470
+ ### Locale fallback order
471
+
472
+ When resolving a string for a locale:
473
+ 1. Exact match (e.g., `es-419`)
474
+ 2. Base language (e.g., `es`)
475
+ 3. Source language (e.g., `en`)
476
+
338
477
  ## File Structure Convention
339
478
 
340
479
  ```