@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 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
- return cfg.locales;
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 = JSON.stringify({ version, presets, available_locales: locales }, null, 2) + "\n";
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 fresh.length === 0;
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 summary = fresh.length
563
- ? styleText(["red", "bold"], `✖ ${fresh.length} problem${fresh.length === 1 ? "" : "s"}`)
564
- : styleText(["green", "bold"], "✓ no problems");
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 fresh.length === 0;
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.5.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.5.0",
44
- "@shop-on/theme-runtime": "0.5.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",