@open-assets/open-assets 0.1.12 → 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/README.md +45 -40
- package/bin/open-assets.mjs +60 -6
- package/lib/add.mjs +6 -6
- package/lib/init.mjs +1 -1
- package/lib/list.mjs +7 -9
- package/lib/localization.mjs +159 -0
- package/lib/lockfile.mjs +81 -6
- package/lib/manifest.mjs +11 -4
- package/lib/render.mjs +156 -93
- package/lib/renderer.mjs +84 -3
- package/lib/server.mjs +44 -23
- package/lib/validate.mjs +43 -10
- package/package.json +2 -2
- package/public/img/examples/cat-iq-test-icon.png +0 -0
- package/public/img/examples/cat-iq-test.png +0 -0
- package/public/img/examples/no-context-icon.png +0 -0
- package/public/img/examples/no-context.png +0 -0
- package/public/img/examples/only-recipes-icon.png +0 -0
- package/public/img/examples/only-recipes.png +0 -0
- package/public/viewer.html +35 -35
- package/skills/open-assets/SKILL.md +155 -30
package/lib/lockfile.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, existsSync } from "fs";
|
|
2
2
|
import { createHash } from "crypto";
|
|
3
|
-
import { resolve, join } from "path";
|
|
3
|
+
import { resolve, join, dirname } from "path";
|
|
4
4
|
|
|
5
5
|
const LOCK_FILENAME = "assets.lock";
|
|
6
6
|
|
|
@@ -27,25 +27,77 @@ export function writeLockfile(projectDir, lockData) {
|
|
|
27
27
|
writeFileSync(lockPath, JSON.stringify(lockData, null, 2) + "\n");
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Extract local file dependencies referenced by an HTML file.
|
|
32
|
+
* Parses src="...", href="...", and url(...) references, filtering out
|
|
33
|
+
* remote URLs and returning only paths that exist on disk.
|
|
34
|
+
* Returns a sorted, deduplicated array of absolute paths.
|
|
35
|
+
*/
|
|
36
|
+
export function extractLocalDeps(filePath) {
|
|
37
|
+
if (!existsSync(filePath)) return [];
|
|
38
|
+
const content = readFileSync(filePath, "utf-8");
|
|
39
|
+
const dir = dirname(filePath);
|
|
40
|
+
|
|
41
|
+
const refs = new Set();
|
|
42
|
+
const srcHrefPattern = /(?:src|href)=["']([^"']+)["']/gi;
|
|
43
|
+
const cssUrlPattern = /url\(["']?([^"')]+)["']?\)/gi;
|
|
44
|
+
|
|
45
|
+
for (const pattern of [srcHrefPattern, cssUrlPattern]) {
|
|
46
|
+
let match;
|
|
47
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
48
|
+
refs.add(match[1]);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const localPaths = [];
|
|
53
|
+
for (const ref of refs) {
|
|
54
|
+
if (/^(https?:\/\/|data:|\/\/|#)/.test(ref)) continue;
|
|
55
|
+
if (!ref) continue;
|
|
56
|
+
const abs = resolve(dir, ref);
|
|
57
|
+
if (existsSync(abs)) {
|
|
58
|
+
localPaths.push(abs);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return [...new Set(localPaths)].sort();
|
|
63
|
+
}
|
|
64
|
+
|
|
30
65
|
/**
|
|
31
66
|
* Compute SHA256 checksum of a file's contents.
|
|
67
|
+
* For .html files, includes the content of all local dependencies
|
|
68
|
+
* (images, SVGs, etc.) referenced via src/href/url() attributes.
|
|
32
69
|
* Returns "sha256:<hex>" or null if file doesn't exist.
|
|
33
70
|
*/
|
|
34
71
|
export function computeChecksum(filePath) {
|
|
35
72
|
if (!existsSync(filePath)) return null;
|
|
36
73
|
const content = readFileSync(filePath);
|
|
74
|
+
|
|
75
|
+
if (filePath.endsWith(".html")) {
|
|
76
|
+
const deps = extractLocalDeps(filePath);
|
|
77
|
+
if (deps.length > 0) {
|
|
78
|
+
const hash = createHash("sha256");
|
|
79
|
+
hash.update(content);
|
|
80
|
+
for (const dep of deps) {
|
|
81
|
+
hash.update("\0");
|
|
82
|
+
hash.update(readFileSync(dep));
|
|
83
|
+
}
|
|
84
|
+
return `sha256:${hash.digest("hex")}`;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
37
88
|
const hash = createHash("sha256").update(content).digest("hex");
|
|
38
89
|
return `sha256:${hash}`;
|
|
39
90
|
}
|
|
40
91
|
|
|
41
92
|
/**
|
|
42
|
-
* Check if an asset variant is up-to-date based on its stored
|
|
43
|
-
* 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.
|
|
44
95
|
*/
|
|
45
|
-
export function isUpToDate(lockData, assetKey, variantKey, currentChecksum, outputPath) {
|
|
96
|
+
export function isUpToDate(lockData, assetKey, variantKey, currentChecksum, outputPath, exportChecksum) {
|
|
46
97
|
const entry = lockData.assets?.[assetKey]?.[variantKey];
|
|
47
98
|
if (!entry) return false;
|
|
48
99
|
if (entry.sourceChecksum !== currentChecksum) return false;
|
|
100
|
+
if (exportChecksum && entry.exportChecksum !== exportChecksum) return false;
|
|
49
101
|
if (!existsSync(resolve(outputPath))) return false;
|
|
50
102
|
return true;
|
|
51
103
|
}
|
|
@@ -53,13 +105,36 @@ export function isUpToDate(lockData, assetKey, variantKey, currentChecksum, outp
|
|
|
53
105
|
/**
|
|
54
106
|
* Record an export in the lockfile data (in memory — call writeLockfile to persist).
|
|
55
107
|
*/
|
|
56
|
-
export function recordExport(lockData, assetKey, variantKey, checksum, outputPath) {
|
|
108
|
+
export function recordExport(lockData, assetKey, variantKey, checksum, outputPath, exportChecksum) {
|
|
57
109
|
if (!lockData.assets[assetKey]) {
|
|
58
110
|
lockData.assets[assetKey] = {};
|
|
59
111
|
}
|
|
60
|
-
|
|
112
|
+
const entry = {
|
|
61
113
|
sourceChecksum: checksum,
|
|
62
114
|
outputPath,
|
|
63
115
|
exportedAt: new Date().toISOString(),
|
|
64
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}`;
|
|
65
140
|
}
|
package/lib/manifest.mjs
CHANGED
|
@@ -2,10 +2,17 @@ import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
|
2
2
|
import { resolve } from "path";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Get all export sizes from a collection.
|
|
5
|
+
* Get all export sizes from a collection (entries without a type field).
|
|
6
6
|
*/
|
|
7
7
|
export function flatSizes(collection) {
|
|
8
|
-
return collection.export || [];
|
|
8
|
+
return (collection.export || []).filter((e) => !e.type);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Get all output actions from a collection (entries with a type field).
|
|
13
|
+
*/
|
|
14
|
+
export function flatOutputs(collection) {
|
|
15
|
+
return (collection.export || []).filter((e) => e.type);
|
|
9
16
|
}
|
|
10
17
|
|
|
11
18
|
/**
|
|
@@ -77,7 +84,7 @@ export function filterTemplates(templates, templateName) {
|
|
|
77
84
|
/**
|
|
78
85
|
* Determine which sizes to render for a collection given CLI options.
|
|
79
86
|
* @param {object} collection - The collection from the config
|
|
80
|
-
* @param {{ width?: string|number, height?: string|number, size?: string,
|
|
87
|
+
* @param {{ width?: string|number, height?: string|number, size?: string, force?: boolean, platform?: string }} opts
|
|
81
88
|
* @returns {{ sizes: object[], warning?: string }}
|
|
82
89
|
*/
|
|
83
90
|
export function resolveRenderSizes(collection, opts = {}) {
|
|
@@ -109,7 +116,7 @@ export function resolveRenderSizes(collection, opts = {}) {
|
|
|
109
116
|
};
|
|
110
117
|
}
|
|
111
118
|
|
|
112
|
-
if (opts.
|
|
119
|
+
if (opts.force) {
|
|
113
120
|
const sizes = flatSizes(collection);
|
|
114
121
|
return { sizes };
|
|
115
122
|
}
|
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
|
-
import { filterCollections, filterTemplates, resolveRenderSizes } from "./manifest.mjs";
|
|
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 } from "./manife
|
|
|
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,11 +26,11 @@ import { filterCollections, filterTemplates, resolveRenderSizes } from "./manife
|
|
|
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
|
|
30
|
-
* @param {boolean} [options.
|
|
31
|
-
* @param {boolean} [options.force] - Re-render even if unchanged
|
|
33
|
+
* @param {boolean} [options.force] - Export at all sizes and re-render (ignores cache)
|
|
32
34
|
* @param {string} [options.output] - Output directory path
|
|
33
35
|
* @param {boolean} [options.quiet] - Suppress logs
|
|
34
36
|
* @param {RenderDeps} deps - Injected dependencies
|
|
@@ -67,7 +69,7 @@ export async function renderAssets(projectDir, manifest, options, deps, log) {
|
|
|
67
69
|
width: options.width,
|
|
68
70
|
height: options.height,
|
|
69
71
|
size: options.size,
|
|
70
|
-
|
|
72
|
+
force: options.force,
|
|
71
73
|
});
|
|
72
74
|
|
|
73
75
|
if (warning) {
|
|
@@ -80,115 +82,176 @@ export async function renderAssets(projectDir, manifest, options, deps, log) {
|
|
|
80
82
|
continue;
|
|
81
83
|
}
|
|
82
84
|
|
|
83
|
-
|
|
85
|
+
// Resolve locales for this collection
|
|
86
|
+
let locales = [null]; // null = no localization
|
|
87
|
+
let localizations = null;
|
|
84
88
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
+
}
|
|
88
100
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
101
|
+
const singleTemplate = templates.length === 1;
|
|
102
|
+
const multipleSizes = sizesToRender.length > 1 || options.force;
|
|
103
|
+
// Single-template collections use flat layout with entry.name as filename
|
|
104
|
+
// Multi-template collections use subdirs per size
|
|
105
|
+
const useSubDirs = multipleSizes && !singleTemplate;
|
|
106
|
+
const hasLocales = locales.length > 0 && locales[0] !== null;
|
|
93
107
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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;
|
|
97
112
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
? entry.outFile.split(".").pop().toLowerCase()
|
|
102
|
-
: "png";
|
|
103
|
-
const format = outExt === "jpg" || outExt === "jpeg" ? "jpeg" : "png";
|
|
104
|
-
const defaultExt = format === "jpeg" ? "jpg" : "png";
|
|
105
|
-
|
|
106
|
-
const outPath = entry.outFile
|
|
107
|
-
? resolve(projectDir, entry.outFile.replace(/\{template\}/g, template.name))
|
|
108
|
-
: join(sizeDir, `${template.name}.${defaultExt}`);
|
|
109
|
-
|
|
110
|
-
const assetKey = `${col.id}/${template.name}`;
|
|
111
|
-
const variantKey = entry.name;
|
|
112
|
-
const srcPath = resolve(projectDir, template.src);
|
|
113
|
-
const checksum = deps.computeChecksum(srcPath);
|
|
114
|
-
|
|
115
|
-
// Skip if unchanged
|
|
116
|
-
if (
|
|
117
|
-
!options.force &&
|
|
118
|
-
checksum &&
|
|
119
|
-
deps.isUpToDate(lockData, assetKey, variantKey, checksum, outPath)
|
|
120
|
-
) {
|
|
121
|
-
_log(
|
|
122
|
-
` Skipping ${template.name} at ${w}x${h} (unchanged)`
|
|
123
|
-
);
|
|
124
|
-
skipped++;
|
|
125
|
-
continue;
|
|
126
|
-
}
|
|
113
|
+
if (locale) {
|
|
114
|
+
_log(`\n Locale: ${locale} (${direction})`);
|
|
115
|
+
}
|
|
127
116
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
117
|
+
for (const entry of sizesToRender) {
|
|
118
|
+
const w = entry.size.width;
|
|
119
|
+
const h = entry.size.height;
|
|
131
120
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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);
|
|
135
131
|
}
|
|
132
|
+
mkdirSync(sizeDir, { recursive: true });
|
|
136
133
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
mkdirSync(resolve(projectDir, join(outPath, "..")), { recursive: true });
|
|
134
|
+
if (sizesToRender.length > 1) {
|
|
135
|
+
_log(`\n ${entry.label} (${w}x${h})`);
|
|
140
136
|
}
|
|
141
137
|
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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}`);
|
|
162
222
|
}
|
|
163
|
-
_log(` → ${outPath}`);
|
|
164
223
|
}
|
|
165
224
|
}
|
|
166
225
|
|
|
167
226
|
// Handle outputs (xcode, copy-source, etc.)
|
|
168
|
-
|
|
169
|
-
|
|
227
|
+
const outputs = flatOutputs(col);
|
|
228
|
+
if (outputs.length > 0 && options.force) {
|
|
229
|
+
for (const output of outputs) {
|
|
170
230
|
if (output.type === "xcode") {
|
|
171
231
|
_log(`\n Exporting to Xcode: ${output.path}`);
|
|
172
232
|
const path = await deps.runXcodeOutput(projectDir, col, output);
|
|
173
233
|
results.push({ collection: col.id, type: "xcode", path });
|
|
174
234
|
} else if (output.type === "copy-source") {
|
|
175
235
|
const format = output.format || "svg";
|
|
176
|
-
const copyDir = join(outputDir, col.id
|
|
236
|
+
const copyDir = join(outputDir, col.id);
|
|
177
237
|
mkdirSync(copyDir, { recursive: true });
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
238
|
+
const matchingTemplates = templates.filter((t) => t.src.endsWith(`.${format}`));
|
|
239
|
+
for (const template of matchingTemplates) {
|
|
240
|
+
const srcPath = resolve(projectDir, template.src);
|
|
241
|
+
// Use entry name when single match, template name when multiple
|
|
242
|
+
const baseName = output.name && matchingTemplates.length === 1
|
|
243
|
+
? output.name
|
|
244
|
+
: template.name;
|
|
245
|
+
const destPath = join(copyDir, `${baseName}.${format}`);
|
|
246
|
+
if (existsSync(srcPath)) {
|
|
247
|
+
copyFileSync(srcPath, destPath);
|
|
248
|
+
results.push({
|
|
249
|
+
collection: col.id,
|
|
250
|
+
template: template.name,
|
|
251
|
+
type: `copy-${format}`,
|
|
252
|
+
path: destPath,
|
|
253
|
+
});
|
|
254
|
+
_log(` → ${destPath}`);
|
|
192
255
|
}
|
|
193
256
|
}
|
|
194
257
|
}
|
package/lib/renderer.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { launch } from "puppeteer";
|
|
2
2
|
import { resolve } from "path";
|
|
3
|
-
import { writeFileSync, mkdirSync } from "fs";
|
|
3
|
+
import { writeFileSync, mkdirSync, readdirSync, unlinkSync } from "fs";
|
|
4
4
|
|
|
5
5
|
let browser = null;
|
|
6
6
|
|
|
@@ -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 }} [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;
|
|
@@ -56,11 +101,22 @@ export async function renderScreenshot(
|
|
|
56
101
|
await new Promise((r) => setTimeout(r, 200));
|
|
57
102
|
}
|
|
58
103
|
|
|
104
|
+
// Handle background: transparent by default for PNG, or use specified color
|
|
105
|
+
const bg = options.background;
|
|
106
|
+
if (bg) {
|
|
107
|
+
await page.evaluate((color) => {
|
|
108
|
+
document.documentElement.style.backgroundColor = color;
|
|
109
|
+
}, bg);
|
|
110
|
+
}
|
|
111
|
+
|
|
59
112
|
const format = options.format || "png";
|
|
60
113
|
const screenshotOpts = {
|
|
61
114
|
type: format,
|
|
62
115
|
clip: { x: 0, y: 0, width, height },
|
|
63
116
|
};
|
|
117
|
+
if (format === "png" && !bg) {
|
|
118
|
+
screenshotOpts.omitBackground = true;
|
|
119
|
+
}
|
|
64
120
|
if (format === "jpeg" && options.quality) {
|
|
65
121
|
screenshotOpts.quality = options.quality;
|
|
66
122
|
}
|
|
@@ -82,14 +138,39 @@ export async function runXcodeOutput(projectDir, collection, outputConfig) {
|
|
|
82
138
|
|
|
83
139
|
const outputDir = resolve(projectDir, outputConfig.path);
|
|
84
140
|
mkdirSync(outputDir, { recursive: true });
|
|
85
|
-
const outputPath = resolve(outputDir, "AppIcon.png");
|
|
86
141
|
|
|
87
142
|
const sw = collection.sourceSize.width;
|
|
88
143
|
const sh = collection.sourceSize.height;
|
|
144
|
+
const filename = "AppIcon.png";
|
|
145
|
+
const outputPath = resolve(outputDir, filename);
|
|
146
|
+
|
|
147
|
+
// Remove stale images that don't match the current output filename
|
|
148
|
+
for (const entry of readdirSync(outputDir)) {
|
|
149
|
+
if (entry !== filename && /\.(png|jpg|jpeg)$/i.test(entry)) {
|
|
150
|
+
unlinkSync(resolve(outputDir, entry));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
89
153
|
|
|
90
154
|
const buffer = await renderScreenshot(projectDir, template.src, sw, sh, sw, sh);
|
|
91
155
|
writeFileSync(outputPath, buffer);
|
|
92
156
|
|
|
157
|
+
const contentsJson = {
|
|
158
|
+
images: [
|
|
159
|
+
{
|
|
160
|
+
filename,
|
|
161
|
+
idiom: "universal",
|
|
162
|
+
platform: "ios",
|
|
163
|
+
size: `${sw}x${sh}`,
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
info: {
|
|
167
|
+
author: "xcode",
|
|
168
|
+
version: 1,
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
const contentsPath = resolve(outputDir, "Contents.json");
|
|
172
|
+
writeFileSync(contentsPath, JSON.stringify(contentsJson, null, 2) + "\n");
|
|
173
|
+
|
|
93
174
|
console.log(`Updated: ${outputPath}`);
|
|
94
175
|
return outputPath;
|
|
95
176
|
}
|