@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.
- package/bin/open-assets.mjs +1 -0
- package/lib/localization.mjs +159 -0
- package/lib/lockfile.mjs +29 -5
- package/lib/render.mjs +128 -78
- package/lib/renderer.mjs +46 -1
- package/lib/validate.mjs +30 -0
- package/package.json +1 -1
- package/skills/open-assets/SKILL.md +127 -0
package/bin/open-assets.mjs
CHANGED
|
@@ -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
|
|
94
|
-
* Returns true if
|
|
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
|
-
|
|
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
|
|
89
|
-
const
|
|
90
|
-
const
|
|
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
|
-
|
|
93
|
-
|
|
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
|
|
102
|
-
|
|
103
|
-
const
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
147
|
-
|
|
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
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
@@ -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
|
```
|