@shop-on/theme-system 0.5.0 → 0.6.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/artifact.mjs +64 -5
- package/dist/check.d.ts +4 -1
- package/dist/check.mjs +85 -7
- package/package.json +3 -3
package/dist/artifact.mjs
CHANGED
|
@@ -11,6 +11,10 @@ const CONFIG_DIRS_TO_MIRROR = [
|
|
|
11
11
|
"locales",
|
|
12
12
|
];
|
|
13
13
|
|
|
14
|
+
// `src/ai/` (the theme's guide and recipes for the editor tools) lands at
|
|
15
|
+
// `config/ai/`; anything `aiManifest` refuses fails the build.
|
|
16
|
+
const AI_DIR = "ai";
|
|
17
|
+
|
|
14
18
|
const PRESET_HANDLE_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
15
19
|
const PRESET_HANDLE_MAX = 64;
|
|
16
20
|
const SEMVER_RE = /^\d+\.\d+\.\d+$/;
|
|
@@ -91,7 +95,15 @@ async function readThemeConfig(themeConfigPath, requiredDefaultLocale) {
|
|
|
91
95
|
if (requiredDefaultLocale && !cfg.locales.includes(requiredDefaultLocale)) {
|
|
92
96
|
fail(`theme.config.json#locales must include "${requiredDefaultLocale}"`);
|
|
93
97
|
}
|
|
94
|
-
|
|
98
|
+
// The hosts a developer's theme may reach beyond Lite's API (theme.json#egress).
|
|
99
|
+
// Only the shape is checked here; the theme store owns the host rules.
|
|
100
|
+
if (
|
|
101
|
+
cfg.egress !== undefined &&
|
|
102
|
+
!(Array.isArray(cfg.egress) && cfg.egress.every((host) => typeof host === "string"))
|
|
103
|
+
) {
|
|
104
|
+
fail(`theme.config.json#egress must be an array of hostnames`);
|
|
105
|
+
}
|
|
106
|
+
return { locales: cfg.locales, egress: cfg.egress };
|
|
95
107
|
}
|
|
96
108
|
|
|
97
109
|
async function readReleaseNotes(releaseNotesPath, { requireReleaseNotes, warn }) {
|
|
@@ -241,6 +253,49 @@ async function mirrorConfig({ srcDir, outputConfig, configDirsToMirror }) {
|
|
|
241
253
|
}
|
|
242
254
|
}
|
|
243
255
|
|
|
256
|
+
async function readSchemaDir(dir) {
|
|
257
|
+
if (!(await exists(dir))) return [];
|
|
258
|
+
const schemas = [];
|
|
259
|
+
for (const file of (await readdir(dir)).sort()) {
|
|
260
|
+
if (!file.endsWith(".json") || file.startsWith("._")) continue;
|
|
261
|
+
const schema = await readJson(path.join(dir, file), `${path.basename(dir)}/${file}`);
|
|
262
|
+
schemas.push({ ...schema, type: file.replace(/\.json$/, "") });
|
|
263
|
+
}
|
|
264
|
+
return schemas;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// `src/ai/` under the rule every host and the upload apply, then each recipe
|
|
268
|
+
// against the theme's own schemas: a recipe that names a section, block or
|
|
269
|
+
// setting the theme doesn't have fails the build, as a missing locale does.
|
|
270
|
+
// Loaded only for a theme that ships `src/ai/`: the reader is TypeScript,
|
|
271
|
+
// which the in-repo source of this package reaches only through a TS-aware
|
|
272
|
+
// loader.
|
|
273
|
+
async function mirrorAi(srcDir, outputConfig) {
|
|
274
|
+
const aiSrc = path.join(srcDir, AI_DIR);
|
|
275
|
+
if (!(await exists(aiSrc))) return 0;
|
|
276
|
+
const { readThemeAiDir } = await import("@shop-on/theme-contract/ai-files");
|
|
277
|
+
const { checkRecipe } = await import("@shop-on/theme-contract/editor-tools");
|
|
278
|
+
const ai = await readThemeAiDir(aiSrc);
|
|
279
|
+
if (ai.skipped.length) fail(`src/ai/${ai.skipped[0].reason}`);
|
|
280
|
+
const schemas = {
|
|
281
|
+
sections: await readSchemaDir(path.join(srcDir, "sections")),
|
|
282
|
+
blocks: await readSchemaDir(path.join(srcDir, "blocks")),
|
|
283
|
+
};
|
|
284
|
+
for (const recipe of ai.recipes) {
|
|
285
|
+
const bad = checkRecipe(recipe, schemas);
|
|
286
|
+
if (bad) fail(`src/ai/recipes/${recipe.handle}.json: ${bad}`);
|
|
287
|
+
}
|
|
288
|
+
const out = path.join(outputConfig, AI_DIR);
|
|
289
|
+
await mkdir(out, { recursive: true });
|
|
290
|
+
if (ai.recipes.length) await mkdir(path.join(out, "recipes"), { recursive: true });
|
|
291
|
+
if (ai.guide) await writeFile(path.join(out, "guide.md"), ai.guide);
|
|
292
|
+
for (const recipe of ai.recipes) {
|
|
293
|
+
const file = path.join("recipes", `${recipe.handle}.json`);
|
|
294
|
+
await copyFile(path.join(aiSrc, file), path.join(out, file));
|
|
295
|
+
}
|
|
296
|
+
return ai.recipes.length;
|
|
297
|
+
}
|
|
298
|
+
|
|
244
299
|
async function listFiles(dir) {
|
|
245
300
|
const files = [];
|
|
246
301
|
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
@@ -275,8 +330,9 @@ async function checkServerBundle(serverDir) {
|
|
|
275
330
|
return bytes;
|
|
276
331
|
}
|
|
277
332
|
|
|
278
|
-
async function writeThemeJson(outputThemeJson, version, presets, locales) {
|
|
279
|
-
const payload =
|
|
333
|
+
async function writeThemeJson(outputThemeJson, version, presets, locales, egress) {
|
|
334
|
+
const payload =
|
|
335
|
+
JSON.stringify({ version, presets, available_locales: locales, egress }, null, 2) + "\n";
|
|
280
336
|
await writeFile(outputThemeJson, payload, "utf8");
|
|
281
337
|
}
|
|
282
338
|
|
|
@@ -320,7 +376,7 @@ export async function buildThemeArtifact(options) {
|
|
|
320
376
|
warn: options.warn,
|
|
321
377
|
});
|
|
322
378
|
|
|
323
|
-
const locales = await readThemeConfig(themeConfigPath, options.requiredDefaultLocale);
|
|
379
|
+
const { locales, egress } = await readThemeConfig(themeConfigPath, options.requiredDefaultLocale);
|
|
324
380
|
options.log?.(`locales: [${locales.join(", ")}]`);
|
|
325
381
|
|
|
326
382
|
const { handles: presets, synthesizeDefault } = await resolvePresets(srcListings, srcDir);
|
|
@@ -329,6 +385,9 @@ export async function buildThemeArtifact(options) {
|
|
|
329
385
|
await mirrorConfig({ srcDir, outputConfig, configDirsToMirror });
|
|
330
386
|
options.log?.(`mirrored config + schemas → .output/config/`);
|
|
331
387
|
|
|
388
|
+
const recipes = await mirrorAi(srcDir, outputConfig);
|
|
389
|
+
if (recipes > 0) options.log?.(`checked ${recipes} recipe(s) against the theme's schemas`);
|
|
390
|
+
|
|
332
391
|
await mkdir(outputConfig, { recursive: true });
|
|
333
392
|
if (synthesizeDefault) {
|
|
334
393
|
await mkdir(path.join(outputListings, presets[0]), { recursive: true });
|
|
@@ -340,7 +399,7 @@ export async function buildThemeArtifact(options) {
|
|
|
340
399
|
options.log?.(`mirrored ${presets.length} preset(s) → .output/config/listings/`);
|
|
341
400
|
}
|
|
342
401
|
|
|
343
|
-
await writeThemeJson(outputThemeJson, version, presets, locales);
|
|
402
|
+
await writeThemeJson(outputThemeJson, version, presets, locales, egress);
|
|
344
403
|
options.log?.(`wrote .output/theme.json`);
|
|
345
404
|
|
|
346
405
|
const copiedReleaseNotes = await copyReleaseNotes(outputReleaseNotes, releaseNotes);
|
package/dist/check.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ export type ThemeCheckRule =
|
|
|
10
10
|
| "theme-info"
|
|
11
11
|
| "release-notes-version"
|
|
12
12
|
| "scheme-contrast"
|
|
13
|
+
| "closest-provider"
|
|
13
14
|
| "invalid-suppression";
|
|
14
15
|
|
|
15
16
|
export type ThemeCheckFinding = {
|
|
@@ -20,6 +21,8 @@ export type ThemeCheckFinding = {
|
|
|
20
21
|
message: string;
|
|
21
22
|
/** The recipe the rule enforces, relative to the theme root. */
|
|
22
23
|
docs: string;
|
|
24
|
+
/** Printed, but doesn't fail the run. */
|
|
25
|
+
warning?: true;
|
|
23
26
|
};
|
|
24
27
|
|
|
25
28
|
/** `{ [file]: { [rule]: count } }`. */
|
|
@@ -27,7 +30,7 @@ export type ThemeCheckBaseline = Record<string, Partial<Record<ThemeCheckRule, n
|
|
|
27
30
|
|
|
28
31
|
export const BASELINE_FILE: "theme-check.baseline.json";
|
|
29
32
|
|
|
30
|
-
export const RULES: Record<ThemeCheckRule, { docs: string; release?: true }>;
|
|
33
|
+
export const RULES: Record<ThemeCheckRule, { docs: string; release?: true; warning?: true }>;
|
|
31
34
|
|
|
32
35
|
export function checkTheme(options: {
|
|
33
36
|
themeRoot: string | URL;
|
package/dist/check.mjs
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
// --baseline <file> counts to tolerate (default: theme-check.baseline.json)
|
|
10
10
|
// --write-baseline record today's findings as the baseline and exit 0
|
|
11
11
|
//
|
|
12
|
+
// A `warning` rule's findings print with the rest but don't fail the run.
|
|
13
|
+
//
|
|
12
14
|
// A finding in a `.tsx` can be suppressed on the line above it, with a reason:
|
|
13
15
|
// // theme-check-disable-next-line literal-color: white over the hero image
|
|
14
16
|
// {/* theme-check-disable-next-line raw-internal-link: works before hydration */}
|
|
@@ -17,6 +19,7 @@ import fs from "node:fs";
|
|
|
17
19
|
import path from "node:path";
|
|
18
20
|
import { fileURLToPath } from "node:url";
|
|
19
21
|
import { parseArgs, styleText } from "node:util";
|
|
22
|
+
import { PICKER_RESOURCES } from "@shop-on/theme-contract/closest-scope";
|
|
20
23
|
import { contrastRatio, parseColor } from "@shop-on/theme-runtime/color";
|
|
21
24
|
import { findMissingLocaleKeys } from "./check-locales.mjs";
|
|
22
25
|
|
|
@@ -40,6 +43,10 @@ export const RULES = {
|
|
|
40
43
|
"theme-info": { docs: "recipes/quality.md#completeness", release: true },
|
|
41
44
|
"release-notes-version": { docs: "SKILL.md#shipping-a-version" },
|
|
42
45
|
"scheme-contrast": { docs: "recipes/styling.md#color-schemes" },
|
|
46
|
+
"closest-provider": {
|
|
47
|
+
docs: "recipes/dynamic-sources.md#closest-resources--closest_",
|
|
48
|
+
warning: true,
|
|
49
|
+
},
|
|
43
50
|
"invalid-suppression": { docs: "recipes/quality.md#suppressing-a-finding" },
|
|
44
51
|
};
|
|
45
52
|
|
|
@@ -429,6 +436,73 @@ const CHECKS = {
|
|
|
429
436
|
}
|
|
430
437
|
return out;
|
|
431
438
|
},
|
|
439
|
+
|
|
440
|
+
"closest-provider": (ctx) => {
|
|
441
|
+
const out = [];
|
|
442
|
+
for (const { file } of ctx.schemas) {
|
|
443
|
+
const source = ctx.text(file);
|
|
444
|
+
const schema = ctx.json(file);
|
|
445
|
+
const settings = Array.isArray(schema.settings) ? schema.settings : [];
|
|
446
|
+
const provides =
|
|
447
|
+
schema.provides && typeof schema.provides === "object" ? schema.provides : {};
|
|
448
|
+
const providesAt = Math.max(source.indexOf('"provides"'), 0);
|
|
449
|
+
const warn = (line, message) => out.push({ file, line, rule: "closest-provider", message });
|
|
450
|
+
|
|
451
|
+
const pickers = {};
|
|
452
|
+
for (const setting of settings) {
|
|
453
|
+
const resource = PICKER_RESOURCES[setting?.type];
|
|
454
|
+
if (resource && setting.id) (pickers[resource] ??= []).push(setting.id);
|
|
455
|
+
}
|
|
456
|
+
for (const [resource, [first, ...rest]] of Object.entries(pickers)) {
|
|
457
|
+
if (!rest.length || provides[resource]) continue;
|
|
458
|
+
for (const id of rest) {
|
|
459
|
+
warn(
|
|
460
|
+
jsonLine(source, "id", id),
|
|
461
|
+
`only "${first}" provides ${resource} to closest_* sources; a second ${resource} picker ("${id}") never does`,
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const staticBlocks = new Set(
|
|
467
|
+
(Array.isArray(schema.blocks) ? schema.blocks : [])
|
|
468
|
+
.filter((block) => block?.static && typeof block.type === "string")
|
|
469
|
+
.map((block) => block.type.replace(/^_/, "")),
|
|
470
|
+
);
|
|
471
|
+
const settingIds = new Set(settings.map((setting) => setting?.id));
|
|
472
|
+
const listedBy = new Map();
|
|
473
|
+
for (const [resource, decl] of Object.entries(provides)) {
|
|
474
|
+
const key = new RegExp(`"${escapeRegExp(resource)}"\\s*:`).exec(source.slice(providesAt));
|
|
475
|
+
const at = providesAt + (key?.index ?? 0);
|
|
476
|
+
// `$theme` (THEME_FED in @shop-on/theme-contract): a list the theme
|
|
477
|
+
// feeds itself, with no picker to name.
|
|
478
|
+
if (decl?.from !== "$theme" && !settingIds.has(decl?.from)) {
|
|
479
|
+
warn(
|
|
480
|
+
jsonLine(source, "from", decl?.from, at),
|
|
481
|
+
`provides.${resource}.from "${decl?.from}" names no setting on this schema; it provides nothing`,
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
for (const id of Array.isArray(decl?.each) ? decl.each : []) {
|
|
485
|
+
const line = lineAt(source, Math.max(source.indexOf(`"${id}"`, at), 0));
|
|
486
|
+
if (!staticBlocks.has(id)) {
|
|
487
|
+
warn(
|
|
488
|
+
line,
|
|
489
|
+
`provides.${resource}.each "${id}" names no static block on this schema; it scopes nothing`,
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
const other = listedBy.get(id);
|
|
493
|
+
if (other) {
|
|
494
|
+
warn(
|
|
495
|
+
line,
|
|
496
|
+
`static block "${id}" is in both provides.${other}.each and provides.${resource}.each; a subtree takes one list, and the other's closest_* bindings show their literals`,
|
|
497
|
+
);
|
|
498
|
+
} else {
|
|
499
|
+
listedBy.set(id, resource);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return out;
|
|
505
|
+
},
|
|
432
506
|
};
|
|
433
507
|
|
|
434
508
|
/** `// theme-check-disable-next-line <rule>[, <rule>]: <reason>` in any
|
|
@@ -477,6 +551,7 @@ export function checkTheme({ themeRoot, release = false }) {
|
|
|
477
551
|
...f,
|
|
478
552
|
file: path.relative(root, f.file).split(path.sep).join("/"),
|
|
479
553
|
docs: `${SKILL}/${RULES[f.rule].docs}`,
|
|
554
|
+
...(RULES[f.rule].warning && { warning: true }),
|
|
480
555
|
}))
|
|
481
556
|
.sort(
|
|
482
557
|
(a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.rule.localeCompare(b.rule),
|
|
@@ -501,7 +576,7 @@ export function applyBaseline(findings, baseline) {
|
|
|
501
576
|
return findings.filter(({ file, rule }) => counts[file][rule] > (baseline[file]?.[rule] ?? 0));
|
|
502
577
|
}
|
|
503
578
|
|
|
504
|
-
/** The CLI: prints findings, returns `false` when any is left. */
|
|
579
|
+
/** The CLI: prints findings, returns `false` when any but a warning is left. */
|
|
505
580
|
export function runThemeCheck({ themeRoot, argv = process.argv.slice(2) }) {
|
|
506
581
|
const { values } = parseArgs({
|
|
507
582
|
args: argv,
|
|
@@ -534,14 +609,16 @@ export function runThemeCheck({ themeRoot, argv = process.argv.slice(2) }) {
|
|
|
534
609
|
const fresh = applyBaseline(findings, baseline);
|
|
535
610
|
const known = findings.filter((f) => !fresh.includes(f));
|
|
536
611
|
const baselined = known.length;
|
|
612
|
+
const problems = fresh.filter((f) => !f.warning).length;
|
|
613
|
+
const warnings = fresh.length - problems;
|
|
537
614
|
|
|
538
615
|
if (values.json) {
|
|
539
616
|
console.log(JSON.stringify({ findings: fresh, baselined }, null, 2));
|
|
540
|
-
return
|
|
617
|
+
return problems === 0;
|
|
541
618
|
}
|
|
542
619
|
// styleText drops the color when stdout isn't a terminal or NO_COLOR is set.
|
|
543
620
|
const print = (f, dim = false) => {
|
|
544
|
-
const line = `${styleText("cyan", `${f.file}:${f.line}`)} ${styleText("yellow", f.rule)} ${f.message}`;
|
|
621
|
+
const line = `${styleText("cyan", `${f.file}:${f.line}`)} ${styleText("yellow", f.rule)}${f.warning ? " (warning)" : ""} ${f.message}`;
|
|
545
622
|
console.log(dim ? styleText("dim", line) : line);
|
|
546
623
|
};
|
|
547
624
|
for (const f of fresh) print(f);
|
|
@@ -559,9 +636,10 @@ export function runThemeCheck({ themeRoot, argv = process.argv.slice(2) }) {
|
|
|
559
636
|
);
|
|
560
637
|
}
|
|
561
638
|
}
|
|
562
|
-
const
|
|
563
|
-
|
|
564
|
-
|
|
639
|
+
const warned = warnings ? `, ${warnings} warning${warnings === 1 ? "" : "s"}` : "";
|
|
640
|
+
const summary = problems
|
|
641
|
+
? styleText(["red", "bold"], `✖ ${problems} problem${problems === 1 ? "" : "s"}${warned}`)
|
|
642
|
+
: styleText(["green", "bold"], `✓ no problems${warned}`);
|
|
565
643
|
const knownNote = baselined
|
|
566
644
|
? styleText(
|
|
567
645
|
"dim",
|
|
@@ -569,5 +647,5 @@ export function runThemeCheck({ themeRoot, argv = process.argv.slice(2) }) {
|
|
|
569
647
|
)
|
|
570
648
|
: "";
|
|
571
649
|
console.log(`\n${summary}${knownNote}`);
|
|
572
|
-
return
|
|
650
|
+
return problems === 0;
|
|
573
651
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shop-on/theme-system",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"license": "SEE LICENSE IN dist/LICENSE.md",
|
|
5
5
|
"files": [
|
|
6
6
|
"dist"
|
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
}
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@shop-on/theme-contract": "0.
|
|
44
|
-
"@shop-on/theme-runtime": "0.
|
|
43
|
+
"@shop-on/theme-contract": "0.6.0",
|
|
44
|
+
"@shop-on/theme-runtime": "0.6.0"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@types/node": "26.6.5",
|