@shop-on/theme-system 0.0.0-stage → 0.1.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/dist/LICENSE.md +39 -0
- package/dist/artifact.d.ts +26 -0
- package/dist/artifact.mjs +351 -0
- package/dist/check-locales.d.ts +23 -0
- package/dist/check-locales.mjs +147 -0
- package/dist/check.d.ts +45 -0
- package/dist/check.mjs +573 -0
- package/dist/defaults.d.ts +41 -0
- package/dist/defaults.mjs +54 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/port-audit.d.ts +13 -0
- package/dist/port-audit.mjs +134 -0
- package/dist/prebuild.d.ts +19 -0
- package/dist/prebuild.mjs +104 -0
- package/dist/prose-coverage.d.ts +20 -0
- package/dist/prose-coverage.js +215 -0
- package/dist/schema-loaders.d.ts +24 -0
- package/dist/schema-loaders.js +109 -0
- package/dist/settings-data.d.ts +11 -0
- package/dist/settings-data.mjs +66 -0
- package/package.json +52 -3
- package/README.md +0 -3
package/dist/LICENSE.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Cody Theme Development License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Cody LLC. All rights reserved except as granted below.
|
|
4
|
+
|
|
5
|
+
"The Software" is this package, its source, and its documentation. "The
|
|
6
|
+
Platform" is Cody, the commerce platform operated by Cody LLC.
|
|
7
|
+
|
|
8
|
+
## 1. Grant
|
|
9
|
+
|
|
10
|
+
You may use, copy and modify the Software, and distribute modified copies, solely
|
|
11
|
+
to develop, test, and distribute themes, apps and other extensions that run on
|
|
12
|
+
the Platform.
|
|
13
|
+
|
|
14
|
+
## 2. Restrictions
|
|
15
|
+
|
|
16
|
+
You may not:
|
|
17
|
+
|
|
18
|
+
1. use the Software, or anything derived from it, with any commerce platform,
|
|
19
|
+
storefront or hosting service other than the Platform;
|
|
20
|
+
2. use the Software to build a product that competes with the Platform;
|
|
21
|
+
3. sublicense the Software, or distribute it other than as part of a theme,
|
|
22
|
+
app or extension for the Platform;
|
|
23
|
+
4. remove or alter this license or any copyright notice in the Software.
|
|
24
|
+
|
|
25
|
+
## 3. Your work
|
|
26
|
+
|
|
27
|
+
Themes, apps and extensions you build with the Software are yours. The parts of
|
|
28
|
+
the Software they include stay under this license.
|
|
29
|
+
|
|
30
|
+
## 4. Termination
|
|
31
|
+
|
|
32
|
+
This license ends automatically if you break any of its terms. You must then
|
|
33
|
+
stop using the Software and delete your copies of it.
|
|
34
|
+
|
|
35
|
+
## 5. No warranty
|
|
36
|
+
|
|
37
|
+
The Software is provided "as is", without warranty of any kind. Cody LLC is not
|
|
38
|
+
liable for any claim, damages or other liability arising from the Software or
|
|
39
|
+
its use.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type BuildThemeArtifactOptions = {
|
|
2
|
+
themeRoot: string | URL;
|
|
3
|
+
srcDir?: string | URL;
|
|
4
|
+
outputDir?: string | URL;
|
|
5
|
+
requiredDefaultLocale?: string;
|
|
6
|
+
requireReleaseNotes?: boolean;
|
|
7
|
+
configDirsToMirror?: string[];
|
|
8
|
+
log?: (message: string) => void;
|
|
9
|
+
warn?: (message: string) => void;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type BuildThemeArtifactResult = {
|
|
13
|
+
version: string;
|
|
14
|
+
presets: string[];
|
|
15
|
+
availableLocales: string[];
|
|
16
|
+
copiedReleaseNotes: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** `.output/server` past this fails postbuild. */
|
|
20
|
+
export const SERVER_BUDGET_BYTES: number;
|
|
21
|
+
|
|
22
|
+
export class ThemeArtifactError extends Error {}
|
|
23
|
+
|
|
24
|
+
export function buildThemeArtifact(
|
|
25
|
+
options: BuildThemeArtifactOptions,
|
|
26
|
+
): Promise<BuildThemeArtifactResult>;
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { access, copyFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
const CONFIG_DIRS_TO_MIRROR = [
|
|
6
|
+
"templates",
|
|
7
|
+
"sections",
|
|
8
|
+
"blocks",
|
|
9
|
+
"section-groups",
|
|
10
|
+
"layouts",
|
|
11
|
+
"locales",
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const PRESET_HANDLE_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
15
|
+
const PRESET_HANDLE_MAX = 64;
|
|
16
|
+
const SEMVER_RE = /^\d+\.\d+\.\d+$/;
|
|
17
|
+
|
|
18
|
+
// Every file under `.output/server` is shipped to a storefront isolate on each
|
|
19
|
+
// cold start. A full-featured theme is ~4.8 MB; past this, something leaked.
|
|
20
|
+
export const SERVER_BUDGET_BYTES = 6 * 1024 * 1024;
|
|
21
|
+
const TEST_FILE_RE = /\.test\b/;
|
|
22
|
+
|
|
23
|
+
// A theme with no `src/listings/` (or an empty one) ships a single implicit
|
|
24
|
+
// preset under this handle — the base templates with no overrides.
|
|
25
|
+
const DEFAULT_PRESET_HANDLE = "default";
|
|
26
|
+
|
|
27
|
+
// Housekeeping files that may sit beside preset directories (e.g. a `.gitkeep`
|
|
28
|
+
// keeping an otherwise-empty `src/listings/` tracked in git). Ignored by the
|
|
29
|
+
// scan so they neither count as presets nor trip the "must be a directory" guard.
|
|
30
|
+
const LISTING_IGNORE = new Set([".gitkeep", ".DS_Store"]);
|
|
31
|
+
const isIgnoredListingEntry = (name) => LISTING_IGNORE.has(name) || name.startsWith("._");
|
|
32
|
+
|
|
33
|
+
export class ThemeArtifactError extends Error {
|
|
34
|
+
constructor(message) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = "ThemeArtifactError";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function pathFromRoot(root) {
|
|
41
|
+
if (root instanceof URL) return fileURLToPath(root);
|
|
42
|
+
return String(root);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function fail(message) {
|
|
46
|
+
throw new ThemeArtifactError(message);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function exists(p) {
|
|
50
|
+
try {
|
|
51
|
+
await access(p);
|
|
52
|
+
return true;
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function readJson(filePath, description) {
|
|
59
|
+
let raw;
|
|
60
|
+
try {
|
|
61
|
+
raw = await readFile(filePath, "utf8");
|
|
62
|
+
} catch (err) {
|
|
63
|
+
fail(`could not read ${description} at ${filePath}: ${err.message}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(raw);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
fail(`${description} is not valid JSON: ${err.message}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function readVersion(pkgPath) {
|
|
74
|
+
const pkg = await readJson(pkgPath, "package.json");
|
|
75
|
+
const version = pkg.version;
|
|
76
|
+
if (typeof version !== "string" || !SEMVER_RE.test(version)) {
|
|
77
|
+
fail(`package.json version '${version}' is not valid semver — expected X.Y.Z (e.g. 1.0.0)`);
|
|
78
|
+
}
|
|
79
|
+
return version;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function readThemeConfig(themeConfigPath, requiredDefaultLocale) {
|
|
83
|
+
if (!(await exists(themeConfigPath))) {
|
|
84
|
+
fail(`theme.config.json not found at ${themeConfigPath}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const cfg = await readJson(themeConfigPath, "theme.config.json");
|
|
88
|
+
if (!Array.isArray(cfg.locales) || cfg.locales.length === 0) {
|
|
89
|
+
fail(`theme.config.json#locales must be a non-empty array`);
|
|
90
|
+
}
|
|
91
|
+
if (requiredDefaultLocale && !cfg.locales.includes(requiredDefaultLocale)) {
|
|
92
|
+
fail(`theme.config.json#locales must include "${requiredDefaultLocale}"`);
|
|
93
|
+
}
|
|
94
|
+
return cfg.locales;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function readReleaseNotes(releaseNotesPath, { requireReleaseNotes, warn }) {
|
|
98
|
+
if (!(await exists(releaseNotesPath))) {
|
|
99
|
+
const message = `release-notes.md not found at ${releaseNotesPath} — artifact will be missing canonical release notes`;
|
|
100
|
+
if (requireReleaseNotes) fail(message);
|
|
101
|
+
warn?.(message);
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
return readFile(releaseNotesPath, "utf8");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function validatePresetHandle(name) {
|
|
108
|
+
if (name.length < 1 || name.length > PRESET_HANDLE_MAX) {
|
|
109
|
+
fail(
|
|
110
|
+
`preset handle '${name}' has invalid length ${name.length} — must be 1..${PRESET_HANDLE_MAX} chars`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
if (!PRESET_HANDLE_RE.test(name)) {
|
|
114
|
+
fail(
|
|
115
|
+
`preset handle '${name}' is invalid — must be lowercase kebab-case ASCII (a-z, 0-9, hyphen), no leading/trailing hyphen`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// The orchestrator normalizes settings-data preset keys the same way when it
|
|
121
|
+
// matches them against the artifact's handle at boot — keep the two in sync.
|
|
122
|
+
function normalizePresetKey(value) {
|
|
123
|
+
return value
|
|
124
|
+
.normalize("NFC")
|
|
125
|
+
.toLowerCase()
|
|
126
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
127
|
+
.replace(/^-+|-+$/g, "")
|
|
128
|
+
.replace(/-{2,}/g, "-");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// The synthesized preset's handle must line up with `config/settings-data.json`:
|
|
132
|
+
// at boot the orchestrator flattens the settings `presets` wrapper by the
|
|
133
|
+
// artifact's declared handle, so a wrapper the editor keyed (e.g. "kito") paired
|
|
134
|
+
// with a hardcoded "default" handle fails every install. When the wrapper
|
|
135
|
+
// exists, derive the handle from it — the key `current.preset` selects, or the
|
|
136
|
+
// sole key. A theme with no settings-data (or a flat, wrapper-less one) keeps
|
|
137
|
+
// "default": the orchestrator skips flattening entirely for those.
|
|
138
|
+
async function synthesizedPresetHandle(srcDir) {
|
|
139
|
+
const settingsPath = path.join(srcDir, "config", "settings-data.json");
|
|
140
|
+
if (!(await exists(settingsPath))) return DEFAULT_PRESET_HANDLE;
|
|
141
|
+
|
|
142
|
+
const data = await readJson(settingsPath, "settings-data.json");
|
|
143
|
+
const presets = data?.presets;
|
|
144
|
+
if (!presets || typeof presets !== "object" || Array.isArray(presets)) {
|
|
145
|
+
return DEFAULT_PRESET_HANDLE;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const keys = Object.keys(presets);
|
|
149
|
+
const current = data?.current?.preset;
|
|
150
|
+
const selected =
|
|
151
|
+
(typeof current === "string" && keys.includes(current) ? current : null) ??
|
|
152
|
+
(keys.length === 1 ? keys[0] : null);
|
|
153
|
+
if (!selected) {
|
|
154
|
+
fail(
|
|
155
|
+
`src/listings/ is absent but settings-data.json holds ${keys.length} presets and current.preset selects none — cannot name the synthesized preset`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const handle = normalizePresetKey(selected);
|
|
160
|
+
validatePresetHandle(handle);
|
|
161
|
+
return handle;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Resolve the theme's presets. A theme with no `src/listings/` (or an empty
|
|
165
|
+
// one) ships a single implicit preset instead of failing: the orchestrator's
|
|
166
|
+
// preset materializer deep-merges `listings/<handle>/` over the base templates,
|
|
167
|
+
// so an empty preset dir renders the base unchanged, and the API validator only
|
|
168
|
+
// requires the declared handle to have a matching (possibly empty) directory.
|
|
169
|
+
// Returns `{ handles, synthesizeDefault }` — when synthesizing, the caller
|
|
170
|
+
// creates an empty `.output/config/listings/<handle>/` instead of mirroring
|
|
171
|
+
// `src/listings/`, with the handle taken from settings-data's presets wrapper
|
|
172
|
+
// (see synthesizedPresetHandle) or "default" when there is none.
|
|
173
|
+
async function resolvePresets(srcListings, srcDir) {
|
|
174
|
+
if (!(await exists(srcListings))) {
|
|
175
|
+
return { handles: [await synthesizedPresetHandle(srcDir)], synthesizeDefault: true };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let entries;
|
|
179
|
+
try {
|
|
180
|
+
entries = await readdir(srcListings, { withFileTypes: true });
|
|
181
|
+
} catch (err) {
|
|
182
|
+
fail(`could not read src/listings/: ${err.message}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const handles = [];
|
|
186
|
+
for (const entry of entries) {
|
|
187
|
+
if (isIgnoredListingEntry(entry.name)) continue;
|
|
188
|
+
if (!entry.isDirectory()) {
|
|
189
|
+
fail(
|
|
190
|
+
`src/listings/${entry.name} is not a directory — only preset directories are allowed under src/listings/`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
validatePresetHandle(entry.name);
|
|
194
|
+
handles.push(entry.name);
|
|
195
|
+
}
|
|
196
|
+
if (handles.length === 0) {
|
|
197
|
+
return { handles: [await synthesizedPresetHandle(srcDir)], synthesizeDefault: true };
|
|
198
|
+
}
|
|
199
|
+
handles.sort();
|
|
200
|
+
return { handles, synthesizeDefault: false };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function copyDir(src, dest, { filter } = {}) {
|
|
204
|
+
await mkdir(dest, { recursive: true });
|
|
205
|
+
const entries = await readdir(src, { withFileTypes: true });
|
|
206
|
+
for (const entry of entries) {
|
|
207
|
+
const srcPath = path.join(src, entry.name);
|
|
208
|
+
const destPath = path.join(dest, entry.name);
|
|
209
|
+
if (entry.isDirectory()) {
|
|
210
|
+
await copyDir(srcPath, destPath, { filter });
|
|
211
|
+
} else if (entry.isFile()) {
|
|
212
|
+
if (filter && !filter(entry.name)) continue;
|
|
213
|
+
await copyFile(srcPath, destPath);
|
|
214
|
+
} else {
|
|
215
|
+
fail(`unsupported entry type at ${srcPath} — only regular files and directories allowed`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const isJsonFile = (name) => name.endsWith(".json");
|
|
221
|
+
|
|
222
|
+
async function mirrorConfig({ srcDir, outputConfig, configDirsToMirror }) {
|
|
223
|
+
await mkdir(outputConfig, { recursive: true });
|
|
224
|
+
|
|
225
|
+
for (const file of ["settings-schema.json", "settings-data.json"]) {
|
|
226
|
+
const src = path.join(srcDir, "config", file);
|
|
227
|
+
if (!(await exists(src))) {
|
|
228
|
+
if (file === "settings-schema.json") {
|
|
229
|
+
fail(`required src/config/${file} is missing`);
|
|
230
|
+
}
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
await copyFile(src, path.join(outputConfig, file));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
for (const dir of configDirsToMirror) {
|
|
237
|
+
const src = path.join(srcDir, dir);
|
|
238
|
+
if (await exists(src)) {
|
|
239
|
+
await copyDir(src, path.join(outputConfig, dir), { filter: isJsonFile });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function listFiles(dir) {
|
|
245
|
+
const files = [];
|
|
246
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
247
|
+
const full = path.join(dir, entry.name);
|
|
248
|
+
if (entry.isDirectory()) files.push(...(await listFiles(full)));
|
|
249
|
+
else files.push(full);
|
|
250
|
+
}
|
|
251
|
+
return files;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// The server bundle's two leaks: a test file (it registers as a phantom type and
|
|
255
|
+
// pulls in the vitest runtime) and anything that pushes it over the budget.
|
|
256
|
+
async function checkServerBundle(serverDir) {
|
|
257
|
+
if (!(await exists(serverDir))) return null;
|
|
258
|
+
const files = await listFiles(serverDir);
|
|
259
|
+
const tests = files.filter((file) => TEST_FILE_RE.test(path.basename(file)));
|
|
260
|
+
if (tests.length) {
|
|
261
|
+
fail(
|
|
262
|
+
`.output/server holds test files — a test glob leaked into the server build: ${tests
|
|
263
|
+
.map((file) => path.relative(serverDir, file))
|
|
264
|
+
.join(", ")}`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
let bytes = 0;
|
|
268
|
+
for (const file of files) bytes += (await stat(file)).size;
|
|
269
|
+
if (bytes > SERVER_BUDGET_BYTES) {
|
|
270
|
+
const mb = (n) => `${(n / 1024 / 1024).toFixed(2)} MB`;
|
|
271
|
+
fail(
|
|
272
|
+
`.output/server is ${mb(bytes)}, over the ${mb(SERVER_BUDGET_BYTES)} budget — look for a browser-only library in .output/server/_libs`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
return bytes;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function writeThemeJson(outputThemeJson, version, presets, locales) {
|
|
279
|
+
const payload = JSON.stringify({ version, presets, available_locales: locales }, null, 2) + "\n";
|
|
280
|
+
await writeFile(outputThemeJson, payload, "utf8");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function copyReleaseNotes(outputReleaseNotes, body) {
|
|
284
|
+
if (body == null) return false;
|
|
285
|
+
await writeFile(outputReleaseNotes, body, "utf8");
|
|
286
|
+
return true;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export async function buildThemeArtifact(options) {
|
|
290
|
+
const themeRoot = path.resolve(pathFromRoot(options.themeRoot));
|
|
291
|
+
const srcDir = options.srcDir
|
|
292
|
+
? path.resolve(pathFromRoot(options.srcDir))
|
|
293
|
+
: path.join(themeRoot, "src");
|
|
294
|
+
const outputDir = options.outputDir
|
|
295
|
+
? path.resolve(pathFromRoot(options.outputDir))
|
|
296
|
+
: path.join(themeRoot, ".output");
|
|
297
|
+
const outputConfig = path.join(outputDir, "config");
|
|
298
|
+
const srcListings = path.join(srcDir, "listings");
|
|
299
|
+
const outputListings = path.join(outputConfig, "listings");
|
|
300
|
+
const releaseNotesPath = path.join(themeRoot, "release-notes.md");
|
|
301
|
+
const outputReleaseNotes = path.join(outputDir, "release-notes.md");
|
|
302
|
+
const outputThemeJson = path.join(outputDir, "theme.json");
|
|
303
|
+
const themeConfigPath = path.join(themeRoot, "theme.config.json");
|
|
304
|
+
const configDirsToMirror = options.configDirsToMirror ?? CONFIG_DIRS_TO_MIRROR;
|
|
305
|
+
|
|
306
|
+
if (!(await exists(outputDir))) {
|
|
307
|
+
fail(`.output/ does not exist — run \`vite build\` before postbuild`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const serverBytes = await checkServerBundle(path.join(outputDir, "server"));
|
|
311
|
+
if (serverBytes !== null) {
|
|
312
|
+
options.log?.(`server bundle: ${(serverBytes / 1024 / 1024).toFixed(2)} MB`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const version = await readVersion(path.join(themeRoot, "package.json"));
|
|
316
|
+
options.log?.(`version: ${version}`);
|
|
317
|
+
|
|
318
|
+
const releaseNotes = await readReleaseNotes(releaseNotesPath, {
|
|
319
|
+
requireReleaseNotes: options.requireReleaseNotes ?? false,
|
|
320
|
+
warn: options.warn,
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
const locales = await readThemeConfig(themeConfigPath, options.requiredDefaultLocale);
|
|
324
|
+
options.log?.(`locales: [${locales.join(", ")}]`);
|
|
325
|
+
|
|
326
|
+
const { handles: presets, synthesizeDefault } = await resolvePresets(srcListings, srcDir);
|
|
327
|
+
options.log?.(`presets: [${presets.join(", ")}]`);
|
|
328
|
+
|
|
329
|
+
await mirrorConfig({ srcDir, outputConfig, configDirsToMirror });
|
|
330
|
+
options.log?.(`mirrored config + schemas → .output/config/`);
|
|
331
|
+
|
|
332
|
+
await mkdir(outputConfig, { recursive: true });
|
|
333
|
+
if (synthesizeDefault) {
|
|
334
|
+
await mkdir(path.join(outputListings, presets[0]), { recursive: true });
|
|
335
|
+
options.log?.(
|
|
336
|
+
`no src/listings/ — synthesized "${presets[0]}" preset (base templates, no overrides)`,
|
|
337
|
+
);
|
|
338
|
+
} else {
|
|
339
|
+
await copyDir(srcListings, outputListings);
|
|
340
|
+
options.log?.(`mirrored ${presets.length} preset(s) → .output/config/listings/`);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
await writeThemeJson(outputThemeJson, version, presets, locales);
|
|
344
|
+
options.log?.(`wrote .output/theme.json`);
|
|
345
|
+
|
|
346
|
+
const copiedReleaseNotes = await copyReleaseNotes(outputReleaseNotes, releaseNotes);
|
|
347
|
+
if (copiedReleaseNotes) options.log?.(`wrote .output/release-notes.md`);
|
|
348
|
+
|
|
349
|
+
options.log?.(`done`);
|
|
350
|
+
return { version, presets, availableLocales: locales, copiedReleaseNotes };
|
|
351
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type CheckThemeLocalesOptions = {
|
|
2
|
+
themeRoot: string | URL;
|
|
3
|
+
};
|
|
4
|
+
|
|
5
|
+
export type MissingLocaleKey = {
|
|
6
|
+
locale: string;
|
|
7
|
+
/** The catalog file the key is missing from, e.g. `en.default.schema.json`. */
|
|
8
|
+
catalog: string;
|
|
9
|
+
/** `t:group.key` for a schema key, `ns.key` (or `a|b.key`) for a runtime one. */
|
|
10
|
+
key: string;
|
|
11
|
+
/** Absolute path of the first file that asks for the key. */
|
|
12
|
+
file: string;
|
|
13
|
+
line: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function findMissingLocaleKeys(options: CheckThemeLocalesOptions): {
|
|
17
|
+
locales: string[];
|
|
18
|
+
schemaKeyCount: number;
|
|
19
|
+
runtimeKeyCount: number;
|
|
20
|
+
missing: MissingLocaleKey[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function checkThemeLocales(options: CheckThemeLocalesOptions): boolean;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Every translation key a theme asks for must exist in every catalog.
|
|
2
|
+
//
|
|
3
|
+
// Two vocabularies, two failure modes, and only one of them is loud:
|
|
4
|
+
// - a schema `t:` key renders its raw name in the editor. Visible, ugly, cheap
|
|
5
|
+
// to spot.
|
|
6
|
+
// - a RUNTIME key throws at render (`Could not resolve … in messages for
|
|
7
|
+
// locale`) on whichever page happens to use it. That can ship, because the
|
|
8
|
+
// compiler never sees a string passed to `t()`.
|
|
9
|
+
//
|
|
10
|
+
// A theme wires it as `scripts/check-locales.mjs` → `pnpm -C themes/<name> check:locales`.
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
15
|
+
function walk(dir, match, out = []) {
|
|
16
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
17
|
+
const full = path.join(dir, entry.name);
|
|
18
|
+
if (entry.isDirectory()) walk(full, match, out);
|
|
19
|
+
else if (match.test(entry.name)) out.push(full);
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function read(file) {
|
|
25
|
+
return JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function lookup(catalog, dotted) {
|
|
29
|
+
let node = catalog;
|
|
30
|
+
for (const part of dotted.split(".")) {
|
|
31
|
+
if (typeof node !== "object" || node === null || !(part in node)) return undefined;
|
|
32
|
+
node = node[part];
|
|
33
|
+
}
|
|
34
|
+
return typeof node === "string" ? node : undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function lineAt(source, index) {
|
|
38
|
+
return source.slice(0, index).split("\n").length;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Every key the theme asks for that some catalog lacks. Each key carries the
|
|
42
|
+
* first place it is asked for, so a report can point at the caller. */
|
|
43
|
+
export function findMissingLocaleKeys({ themeRoot }) {
|
|
44
|
+
const root = themeRoot instanceof URL ? fileURLToPath(themeRoot) : String(themeRoot);
|
|
45
|
+
const srcDir = path.join(root, "src");
|
|
46
|
+
|
|
47
|
+
const locales = fs
|
|
48
|
+
.readdirSync(path.join(srcDir, "locales"))
|
|
49
|
+
.filter((f) => f.endsWith(".default.json"))
|
|
50
|
+
.map((f) => f.replace(".default.json", ""));
|
|
51
|
+
|
|
52
|
+
// --- schema keys: `"t:group.key"` anywhere in a section/block/config JSON ---
|
|
53
|
+
const schemaKeys = new Map();
|
|
54
|
+
for (const file of walk(srcDir, /\.json$/)) {
|
|
55
|
+
if (file.includes(`${path.sep}locales${path.sep}`)) continue;
|
|
56
|
+
const source = fs.readFileSync(file, "utf-8");
|
|
57
|
+
for (const match of source.matchAll(/"t:([a-z0-9_.]+)"/g)) {
|
|
58
|
+
if (!schemaKeys.has(match[1])) {
|
|
59
|
+
schemaKeys.set(match[1], { file, line: lineAt(source, match.index) });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// --- runtime keys: `const t = useTranslations("ns")` … `t("key")` ------------
|
|
65
|
+
// Each translator local is bound to the namespace(s) it was created with, so a
|
|
66
|
+
// key is checked against THOSE rather than every namespace in the file. Matching
|
|
67
|
+
// the local by name matters: a file that calls its translator `content` or
|
|
68
|
+
// `a11y` rather than `t` used to be skipped in silence, and a missing RUNTIME
|
|
69
|
+
// key throws at render.
|
|
70
|
+
//
|
|
71
|
+
// A name declared twice in one file — two components in `header-drawer.tsx` each
|
|
72
|
+
// have their own `t` — keeps both namespaces and the key may resolve under
|
|
73
|
+
// either. Telling those two `t`s apart needs a scope, which needs a parser; the
|
|
74
|
+
// bug worth catching is a key that exists under NO namespace it could have come
|
|
75
|
+
// from, and this still catches that.
|
|
76
|
+
const runtimeKeys = new Map();
|
|
77
|
+
for (const file of walk(srcDir, /\.tsx?$/)) {
|
|
78
|
+
if (file.endsWith(".test.ts") || file.endsWith(".test.tsx")) continue;
|
|
79
|
+
const source = fs.readFileSync(file, "utf-8");
|
|
80
|
+
const bindings = new Map();
|
|
81
|
+
for (const [, name, ns] of source.matchAll(
|
|
82
|
+
/(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*useTranslations\(\s*"([^"]+)"\s*\)/g,
|
|
83
|
+
)) {
|
|
84
|
+
if (!bindings.has(name)) bindings.set(name, new Set());
|
|
85
|
+
bindings.get(name).add(ns);
|
|
86
|
+
}
|
|
87
|
+
if (bindings.size === 0) continue;
|
|
88
|
+
for (const [name, namespaces] of bindings) {
|
|
89
|
+
const calls = new RegExp(`\\b${name}\\(\\s*"([a-z0-9_.]+)"`, "g");
|
|
90
|
+
for (const match of source.matchAll(calls)) {
|
|
91
|
+
const entry = JSON.stringify([[...namespaces], match[1]]);
|
|
92
|
+
if (!runtimeKeys.has(entry)) {
|
|
93
|
+
runtimeKeys.set(entry, { file, line: lineAt(source, match.index) });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const missing = [];
|
|
100
|
+
for (const locale of locales) {
|
|
101
|
+
const catalog = `${locale}.default.schema.json`;
|
|
102
|
+
const schema = read(path.join(srcDir, "locales", catalog));
|
|
103
|
+
for (const [key, at] of schemaKeys) {
|
|
104
|
+
if (lookup(schema, key) === undefined) {
|
|
105
|
+
missing.push({ locale, catalog, key: `t:${key}`, ...at });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
for (const locale of locales) {
|
|
110
|
+
const catalog = `${locale}.default.json`;
|
|
111
|
+
const runtime = read(path.join(srcDir, "locales", catalog));
|
|
112
|
+
for (const [entry, at] of runtimeKeys) {
|
|
113
|
+
const [namespaces, key] = JSON.parse(entry);
|
|
114
|
+
if (!namespaces.some((ns) => lookup(runtime, `${ns}.${key}`) !== undefined)) {
|
|
115
|
+
missing.push({ locale, catalog, key: `${namespaces.join("|")}.${key}`, ...at });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { locales, schemaKeyCount: schemaKeys.size, runtimeKeyCount: runtimeKeys.size, missing };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Logs per-catalog coverage and every missing key; returns `false` when any
|
|
124
|
+
* catalog is missing one. */
|
|
125
|
+
export function checkThemeLocales({ themeRoot }) {
|
|
126
|
+
const { locales, schemaKeyCount, runtimeKeyCount, missing } = findMissingLocaleKeys({
|
|
127
|
+
themeRoot,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const report = (label, total, catalog) => {
|
|
131
|
+
for (const locale of locales) {
|
|
132
|
+
const keys = missing
|
|
133
|
+
.filter((m) => m.catalog === catalog(locale))
|
|
134
|
+
.map((m) => m.key)
|
|
135
|
+
.sort();
|
|
136
|
+
console.log(`${locale} ${label} ${total - keys.length}/${total}`);
|
|
137
|
+
for (const key of keys) console.log(` MISSING ${key}`);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
report("schema: ", schemaKeyCount, (locale) => `${locale}.default.schema.json`);
|
|
141
|
+
report("runtime:", runtimeKeyCount, (locale) => `${locale}.default.json`);
|
|
142
|
+
|
|
143
|
+
if (missing.length) {
|
|
144
|
+
console.error("\nA missing runtime key throws at render; a missing schema key shows raw.");
|
|
145
|
+
}
|
|
146
|
+
return missing.length === 0;
|
|
147
|
+
}
|
package/dist/check.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export type ThemeCheckRule =
|
|
2
|
+
| "locales-complete"
|
|
3
|
+
| "literal-color"
|
|
4
|
+
| "hardcoded-buyer-string"
|
|
5
|
+
| "raw-internal-link"
|
|
6
|
+
| "tenant-picker-default"
|
|
7
|
+
| "section-presets"
|
|
8
|
+
| "schema-label-key"
|
|
9
|
+
| "t-key-in-tsx"
|
|
10
|
+
| "theme-info"
|
|
11
|
+
| "release-notes-version"
|
|
12
|
+
| "scheme-contrast"
|
|
13
|
+
| "invalid-suppression";
|
|
14
|
+
|
|
15
|
+
export type ThemeCheckFinding = {
|
|
16
|
+
/** Relative to the theme root, `/`-separated. */
|
|
17
|
+
file: string;
|
|
18
|
+
line: number;
|
|
19
|
+
rule: ThemeCheckRule;
|
|
20
|
+
message: string;
|
|
21
|
+
/** The recipe the rule enforces, relative to the theme root. */
|
|
22
|
+
docs: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** `{ [file]: { [rule]: count } }`. */
|
|
26
|
+
export type ThemeCheckBaseline = Record<string, Partial<Record<ThemeCheckRule, number>>>;
|
|
27
|
+
|
|
28
|
+
export const BASELINE_FILE: "theme-check.baseline.json";
|
|
29
|
+
|
|
30
|
+
export const RULES: Record<ThemeCheckRule, { docs: string; release?: true }>;
|
|
31
|
+
|
|
32
|
+
export function checkTheme(options: {
|
|
33
|
+
themeRoot: string | URL;
|
|
34
|
+
/** Also run the rules only a hand-off needs (`theme-info`). */
|
|
35
|
+
release?: boolean;
|
|
36
|
+
}): ThemeCheckFinding[];
|
|
37
|
+
|
|
38
|
+
export function toBaseline(findings: ThemeCheckFinding[]): ThemeCheckBaseline;
|
|
39
|
+
|
|
40
|
+
export function applyBaseline(
|
|
41
|
+
findings: ThemeCheckFinding[],
|
|
42
|
+
baseline: ThemeCheckBaseline,
|
|
43
|
+
): ThemeCheckFinding[];
|
|
44
|
+
|
|
45
|
+
export function runThemeCheck(options: { themeRoot: string | URL; argv?: string[] }): boolean;
|