@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.
@@ -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
+ }
@@ -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;