@shop-on/theme-contract 0.4.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.
Files changed (45) hide show
  1. package/dist/ai-files.d.ts +18 -0
  2. package/dist/ai-files.js +47 -0
  3. package/dist/block-allowlist.d.ts +27 -0
  4. package/dist/block-allowlist.js +21 -0
  5. package/dist/closest-scope.d.ts +51 -0
  6. package/dist/closest-scope.js +66 -0
  7. package/dist/document.d.ts +13 -0
  8. package/dist/document.js +19 -5
  9. package/dist/editor-tools/apply.d.ts +85 -0
  10. package/dist/editor-tools/apply.js +544 -0
  11. package/dist/editor-tools/args.d.ts +153 -0
  12. package/dist/editor-tools/args.js +145 -0
  13. package/dist/editor-tools/coerce.d.ts +43 -0
  14. package/dist/editor-tools/coerce.js +200 -0
  15. package/dist/editor-tools/digest.d.ts +75 -0
  16. package/dist/editor-tools/digest.js +99 -0
  17. package/dist/editor-tools/index.d.ts +21 -0
  18. package/dist/editor-tools/index.js +24 -0
  19. package/dist/editor-tools/recipes.d.ts +122 -0
  20. package/dist/editor-tools/recipes.js +192 -0
  21. package/dist/fonts.d.ts +9 -0
  22. package/dist/fonts.js +11 -0
  23. package/dist/google-fonts.generated.d.ts +7 -0
  24. package/dist/google-fonts.generated.js +753 -0
  25. package/dist/index.d.ts +15 -6
  26. package/dist/index.js +15 -6
  27. package/dist/link-url.d.ts +9 -0
  28. package/dist/link-url.js +33 -0
  29. package/dist/metafield-binding.d.ts +89 -9
  30. package/dist/metafield-binding.js +254 -43
  31. package/dist/ops.d.ts +85 -0
  32. package/dist/ops.js +442 -0
  33. package/dist/richtext.d.ts +23 -0
  34. package/dist/richtext.js +115 -1
  35. package/dist/schemas.d.ts +329 -0
  36. package/dist/schemas.js +62 -8
  37. package/dist/section-scope.d.ts +17 -0
  38. package/dist/section-scope.js +22 -0
  39. package/dist/store-path.d.ts +9 -0
  40. package/dist/store-path.js +13 -1
  41. package/dist/template-limits.d.ts +34 -0
  42. package/dist/template-limits.js +71 -0
  43. package/dist/video-url.d.ts +10 -0
  44. package/dist/video-url.js +36 -0
  45. package/package.json +8 -1
@@ -0,0 +1,18 @@
1
+ import { AiEntry, LoadedRecipe } from "./editor-tools/recipes.js";
2
+ //#region src/ai-files.d.ts
3
+ /** Every entry `aiManifest` judges: the top level, and `recipes/` when it is
4
+ * a directory. Nothing deeper is listed: a subdir is refused as itself. */
5
+ export declare function listAiDir(dir: string): Promise<AiEntry[]>;
6
+ export interface ThemeAiFiles {
7
+ guide: string;
8
+ recipes: LoadedRecipe[];
9
+ /** Every path left out, and why: refused by the rule, or a recipe that didn't parse. */
10
+ skipped: {
11
+ path: string;
12
+ reason: string;
13
+ }[];
14
+ /** The bytes read, guide and recipes. */
15
+ bytes: number;
16
+ }
17
+ export declare function readThemeAiDir(dir: string): Promise<ThemeAiFiles>;
18
+ //#endregion
@@ -0,0 +1,47 @@
1
+ import { aiManifest, loadRecipes } from "./editor-tools/recipes.js";
2
+ import { readFile, readdir, stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ //#region src/ai-files.ts
5
+ async function entriesOf(dir, prefix) {
6
+ const dirents = await readdir(dir, { withFileTypes: true }).catch((err) => {
7
+ if (err.code === "ENOENT") return [];
8
+ throw err;
9
+ });
10
+ const entries = [];
11
+ for (const d of dirents) {
12
+ const rel = prefix + d.name;
13
+ const kind = d.isFile() ? "file" : d.isDirectory() ? "dir" : "other";
14
+ const bytes = kind === "file" ? (await stat(path.join(dir, d.name))).size : 0;
15
+ entries.push({
16
+ path: rel,
17
+ kind,
18
+ bytes
19
+ });
20
+ }
21
+ return entries;
22
+ }
23
+ /** Every entry `aiManifest` judges: the top level, and `recipes/` when it is
24
+ * a directory. Nothing deeper is listed: a subdir is refused as itself. */
25
+ async function listAiDir(dir) {
26
+ const top = await entriesOf(dir, "");
27
+ const recipes = top.some((e) => e.path === "recipes" && e.kind === "dir") ? await entriesOf(path.join(dir, "recipes"), "recipes/") : [];
28
+ return [...top, ...recipes];
29
+ }
30
+ async function readThemeAiDir(dir) {
31
+ const manifest = aiManifest(await listAiDir(dir));
32
+ const read = (rel) => readFile(path.join(dir, rel), "utf-8");
33
+ const [guide, bodies] = await Promise.all([manifest.guide ? read(manifest.guide.path) : "", Promise.all(manifest.recipes.map((e) => read(e.path)))]);
34
+ const files = Object.fromEntries(manifest.recipes.map((e, i) => [e.path.slice(8), bodies[i]]));
35
+ const loaded = loadRecipes(files);
36
+ return {
37
+ guide,
38
+ recipes: loaded.recipes,
39
+ skipped: [...manifest.refused, ...loaded.skipped.map(({ name, reason }) => ({
40
+ path: `recipes/${name}`,
41
+ reason: `recipes/${reason}`
42
+ }))],
43
+ bytes: Buffer.byteLength(guide) + bodies.reduce((n, b) => n + Buffer.byteLength(b), 0)
44
+ };
45
+ }
46
+ //#endregion
47
+ export { listAiDir, readThemeAiDir };
@@ -0,0 +1,27 @@
1
+ //#region src/block-allowlist.d.ts
2
+ type ContainerSchema = {
3
+ blocks?: {
4
+ type: string;
5
+ static?: boolean;
6
+ }[];
7
+ } | undefined;
8
+ /**
9
+ * The block types that may be inserted into a container: its schema's
10
+ * non-static `blocks`, without the `@app` wildcard. A container that lists
11
+ * `@app` at `depth` 0 (a section) or 1 (a block directly in a section) also
12
+ * takes every installed app block (the editor-loader serves those as
13
+ * `source:"app"` block schemas); deeper, `@app` means nothing, as publish lets
14
+ * no app block stand there.
15
+ *
16
+ * Every insert path (picker, paste, AI, the editor tools) goes through this,
17
+ * so none can offer a type publish would drop or the storefront would fail to
18
+ * render.
19
+ */
20
+ export declare function allowedChildBlockTypes(containerSchema: ContainerSchema, blockSchemas: readonly {
21
+ type?: string;
22
+ source?: "app";
23
+ }[], { depth }: {
24
+ depth: number;
25
+ }): string[];
26
+ //#endregion
27
+ export {};
@@ -0,0 +1,21 @@
1
+ import { acceptsAppBlocks } from "./document.js";
2
+ //#region src/block-allowlist.ts
3
+ /**
4
+ * The block types that may be inserted into a container: its schema's
5
+ * non-static `blocks`, without the `@app` wildcard. A container that lists
6
+ * `@app` at `depth` 0 (a section) or 1 (a block directly in a section) also
7
+ * takes every installed app block (the editor-loader serves those as
8
+ * `source:"app"` block schemas); deeper, `@app` means nothing, as publish lets
9
+ * no app block stand there.
10
+ *
11
+ * Every insert path (picker, paste, AI, the editor tools) goes through this,
12
+ * so none can offer a type publish would drop or the storefront would fail to
13
+ * render.
14
+ */
15
+ function allowedChildBlockTypes(containerSchema, blockSchemas, { depth }) {
16
+ const types = (containerSchema?.blocks ?? []).filter((b) => !b.static && b.type !== "@app").map((b) => b.type);
17
+ if (depth > 1 || !containerSchema || !acceptsAppBlocks(containerSchema)) return types;
18
+ return [...types, ...blockSchemas.flatMap((b) => b.source === "app" && b.type ? [b.type] : [])];
19
+ }
20
+ //#endregion
21
+ export { allowedChildBlockTypes };
@@ -0,0 +1,51 @@
1
+ import { ClosestResource, Provides } from "./schemas.js";
2
+ //#region src/closest-scope.d.ts
3
+ /** A node's providers: single ones inferred from its pickers, list ones
4
+ * declared under `provides`. `each` absent ⇒ single. */
5
+ export type Providers = Partial<Record<ClosestResource, {
6
+ from: string;
7
+ each?: readonly string[];
8
+ }>>;
9
+ /** One link of a root-first node chain: the node's providers and the id it
10
+ * hangs under in its parent (a section's instance id, a block's id). The
11
+ * root link stands for the route — its providers name the route resources
12
+ * the page has — and its key is never read. */
13
+ export interface ScopeLink {
14
+ providers: Providers;
15
+ key: string;
16
+ }
17
+ export interface ClosestScope {
18
+ /** Index into the chain of the providing link; 0 is the route. */
19
+ index: number;
20
+ decl: {
21
+ from: string;
22
+ each?: readonly string[];
23
+ };
24
+ }
25
+ export declare const PICKER_RESOURCES: Readonly<Record<string, ClosestResource>>;
26
+ /** The resources a schema provides: the FIRST picker of each resource type in
27
+ * schema order (Shopify's rule — `closest.product` is the nearest `product`
28
+ * setting), overridden per resource by an explicit `provides` entry. A second
29
+ * picker of the same type never provides; `provides` is how a schema says
30
+ * which one does. */
31
+ export declare function providersOf(schema: {
32
+ settings?: ReadonlyArray<{
33
+ type?: string;
34
+ id?: string;
35
+ }>;
36
+ provides?: Provides;
37
+ } | undefined): Providers;
38
+ /** The nearest link that provides `resource` to the LAST link of `chain`,
39
+ * or null when none does (the binding keeps its literal; a token blanks).
40
+ *
41
+ * - A single provider (a picker) scopes itself and every descendant.
42
+ * - An `each` provider scopes only the subtrees rooted at the block ids it
43
+ * names — never itself, never its other children.
44
+ * - The nearest provider wins per resource, even when its picker is unset:
45
+ * an empty `featured-product` shows literals rather than borrowing the
46
+ * route product from further up.
47
+ * - A mount carries ONE item id (the nearest `each`'s), so an `each` scope
48
+ * for one resource that sits outside a nearer `each` for another cannot
49
+ * be resolved per item: null, not the outer list's items. */
50
+ export declare function closestScopeAt(chain: readonly ScopeLink[], resource: ClosestResource): ClosestScope | null;
51
+ //#endregion
@@ -0,0 +1,66 @@
1
+ //#region src/closest-scope.ts
2
+ const PICKER_RESOURCES = {
3
+ product: "product",
4
+ collection: "collection",
5
+ page: "page",
6
+ blog: "blog",
7
+ article: "article"
8
+ };
9
+ /** The resources a schema provides: the FIRST picker of each resource type in
10
+ * schema order (Shopify's rule — `closest.product` is the nearest `product`
11
+ * setting), overridden per resource by an explicit `provides` entry. A second
12
+ * picker of the same type never provides; `provides` is how a schema says
13
+ * which one does. */
14
+ function providersOf(schema) {
15
+ const out = {};
16
+ for (const setting of schema?.settings ?? []) {
17
+ const resource = setting.type ? PICKER_RESOURCES[setting.type] : void 0;
18
+ if (resource && setting.id && !out[resource]) out[resource] = { from: setting.id };
19
+ }
20
+ for (const [resource, decl] of Object.entries(schema?.provides ?? {})) out[resource] = decl;
21
+ return out;
22
+ }
23
+ /** True when `link` (at chain index `i`) scopes the next link through an
24
+ * `each` declaration for some resource — i.e. the next link is one of the
25
+ * per-item subtrees the list renders. */
26
+ function eachChildAt(chain, i) {
27
+ const next = chain[i + 1];
28
+ if (!next) return false;
29
+ for (const decl of Object.values(chain[i].providers)) if (decl?.each?.includes(next.key)) return true;
30
+ return false;
31
+ }
32
+ /** The nearest link that provides `resource` to the LAST link of `chain`,
33
+ * or null when none does (the binding keeps its literal; a token blanks).
34
+ *
35
+ * - A single provider (a picker) scopes itself and every descendant.
36
+ * - An `each` provider scopes only the subtrees rooted at the block ids it
37
+ * names — never itself, never its other children.
38
+ * - The nearest provider wins per resource, even when its picker is unset:
39
+ * an empty `featured-product` shows literals rather than borrowing the
40
+ * route product from further up.
41
+ * - A mount carries ONE item id (the nearest `each`'s), so an `each` scope
42
+ * for one resource that sits outside a nearer `each` for another cannot
43
+ * be resolved per item: null, not the outer list's items. */
44
+ function closestScopeAt(chain, resource) {
45
+ const last = chain.length - 1;
46
+ let nearestEach = -1;
47
+ for (let i = last - 1; i >= 0; i--) if (eachChildAt(chain, i)) {
48
+ nearestEach = i;
49
+ break;
50
+ }
51
+ for (let i = last; i >= 0; i--) {
52
+ const decl = chain[i].providers[resource];
53
+ if (!decl) continue;
54
+ if (!decl.each) return {
55
+ index: i,
56
+ decl
57
+ };
58
+ if (i < last && decl.each.includes(chain[i + 1].key)) return i === nearestEach ? {
59
+ index: i,
60
+ decl
61
+ } : null;
62
+ }
63
+ return null;
64
+ }
65
+ //#endregion
66
+ export { PICKER_RESOURCES, closestScopeAt, providersOf };
@@ -21,6 +21,19 @@ export interface CollectedTypes {
21
21
  layouts: Set<string>;
22
22
  }
23
23
  export declare function defaultSettings(schemaDefs?: SchemaSetting[]): Record<string, unknown>;
24
+ /** The `blocks` entry a section or block schema lists to accept app blocks
25
+ * (Shopify's `{ "type": "@app" }`). Honoured on a section, and on a block that
26
+ * is a direct child of a section, so an app block is never deeper than
27
+ * depth 2. */
28
+ export declare const APP_BLOCK_WILDCARD = "@app";
29
+ /** The most block ancestors an app block's container may have: 0 for a
30
+ * section, 1 for a block directly in a section. */
31
+ export declare const MAX_APP_BLOCK_CONTAINER_DEPTH = 1;
32
+ export declare function acceptsAppBlocks(schema: {
33
+ blocks?: {
34
+ type: string;
35
+ }[];
36
+ }): boolean;
24
37
  export declare function staticChildId(childType: string, parentType: string): string;
25
38
  export declare function staticBlockId(type: string): string;
26
39
  export declare function buildStaticChildren(args: {
package/dist/document.js CHANGED
@@ -4,6 +4,17 @@ function defaultSettings(schemaDefs) {
4
4
  for (const st of schemaDefs ?? []) if (st.id && st.default != null) out[st.id] = st.default;
5
5
  return out;
6
6
  }
7
+ /** The `blocks` entry a section or block schema lists to accept app blocks
8
+ * (Shopify's `{ "type": "@app" }`). Honoured on a section, and on a block that
9
+ * is a direct child of a section, so an app block is never deeper than
10
+ * depth 2. */
11
+ const APP_BLOCK_WILDCARD = "@app";
12
+ /** The most block ancestors an app block's container may have: 0 for a
13
+ * section, 1 for a block directly in a section. */
14
+ const MAX_APP_BLOCK_CONTAINER_DEPTH = 1;
15
+ function acceptsAppBlocks(schema) {
16
+ return schema.blocks?.some((b) => b.type === "@app") ?? false;
17
+ }
7
18
  function staticChildId(childType, parentType) {
8
19
  const cleanChild = childType.replace(/^_/, "");
9
20
  const cleanParent = parentType.replace(/^_/, "");
@@ -17,7 +28,7 @@ function buildStaticChildren(args) {
17
28
  const blocks = {};
18
29
  const block_order = [];
19
30
  for (const childDef of args.childDefs) {
20
- if (!childDef.static) continue;
31
+ if (!childDef.static || childDef.type === "@app") continue;
21
32
  const childSchema = lookupSchema(args.getBlockSchema, childDef.type);
22
33
  const id = staticChildId(childDef.type, args.parentType);
23
34
  const block = {
@@ -92,6 +103,7 @@ function buildPresetBlock(blockDef, getBlockSchema, idFor, parentType) {
92
103
  block_order.push(...statics.block_order);
93
104
  }
94
105
  for (const childDef of blockDef.blocks ?? []) {
106
+ if (childDef.type === "@app") continue;
95
107
  const child = buildPresetBlock(childDef, getBlockSchema, idFor, blockDef.type);
96
108
  blocks[child.id] = child.block;
97
109
  if (!child.isStatic) block_order.push(child.id);
@@ -140,6 +152,7 @@ function buildSectionInstance(args) {
140
152
  const block_order = [];
141
153
  const blockDefs = preset?.blocks ?? sectionSchema?.blocks ?? [];
142
154
  for (const blockDef of blockDefs) {
155
+ if (blockDef.type === "@app") continue;
143
156
  const built = buildPresetBlock(blockDef, getBlockSchema, idFor);
144
157
  blocks[built.id] = built.block;
145
158
  if (!built.isStatic) block_order.push(built.id);
@@ -157,11 +170,11 @@ function buildSectionInstance(args) {
157
170
  function selectRenderableSections(sections, order) {
158
171
  const out = [];
159
172
  for (const instanceId of order) {
160
- const section = sections[instanceId];
173
+ const section = Object.hasOwn(sections, instanceId) ? sections[instanceId] : void 0;
161
174
  if (!section || section.disabled) continue;
162
175
  const blocks = [];
163
176
  for (const blockId of section.block_order ?? []) {
164
- const raw = section.blocks?.[blockId];
177
+ const raw = section.blocks && Object.hasOwn(section.blocks, blockId) ? section.blocks[blockId] : void 0;
165
178
  if (!raw || raw.disabled) continue;
166
179
  blocks.push({
167
180
  ...raw,
@@ -197,7 +210,7 @@ function selectRenderableChildren(block) {
197
210
  }
198
211
  for (const id of block.block_order ?? []) {
199
212
  if (seen.has(id)) continue;
200
- const child = block.blocks?.[id];
213
+ const child = block.blocks && Object.hasOwn(block.blocks, id) ? block.blocks[id] : void 0;
201
214
  if (!child || child.disabled) continue;
202
215
  out.push({
203
216
  ...child,
@@ -212,6 +225,7 @@ function collectTypes(template) {
212
225
  const layouts = /* @__PURE__ */ new Set();
213
226
  layouts.add(template.layout ?? "main");
214
227
  const walkBlock = (block) => {
228
+ if (block.source === "app") return;
215
229
  blocks.add(block.type);
216
230
  if (block.blocks) for (const child of Object.values(block.blocks)) walkBlock(child);
217
231
  };
@@ -294,4 +308,4 @@ function deepClone(value) {
294
308
  return JSON.parse(JSON.stringify(value));
295
309
  }
296
310
  //#endregion
297
- export { buildBlockInstance, buildSectionInstance, buildStaticChildren, collectTypes, defaultSettings, seedMissingStatics, selectRenderableChildren, selectRenderableSections, staticBlockId, staticChildId, stripStaticFromBlockOrder };
311
+ export { APP_BLOCK_WILDCARD, MAX_APP_BLOCK_CONTAINER_DEPTH, acceptsAppBlocks, buildBlockInstance, buildSectionInstance, buildStaticChildren, collectTypes, defaultSettings, seedMissingStatics, selectRenderableChildren, selectRenderableSections, staticBlockId, staticChildId, stripStaticFromBlockOrder };
@@ -0,0 +1,85 @@
1
+ import { BlockDefinition, SchemaSetting, TemplateSection } from "../schemas.js";
2
+ import { Op, ThemeDocument } from "../ops.js";
3
+ import { IdPolicy } from "./coerce.js";
4
+ import { EditorScope } from "./digest.js";
5
+ import { LoadedRecipe, Recipe } from "./recipes.js";
6
+ //#region src/editor-tools/apply.d.ts
7
+ type Placement = {
8
+ enabled_on?: {
9
+ groups?: string[];
10
+ templates?: string[];
11
+ };
12
+ disabled_on?: {
13
+ groups?: string[];
14
+ templates?: string[];
15
+ };
16
+ };
17
+ type AppFields = {
18
+ source?: "app";
19
+ appId?: string;
20
+ tag?: string;
21
+ scriptUrl?: string;
22
+ };
23
+ type Preset = {
24
+ settings?: Record<string, unknown>;
25
+ blocks?: BlockDefinition[];
26
+ };
27
+ export type EditorBlockSchema = AppFields & {
28
+ type: string;
29
+ name?: string;
30
+ settings?: SchemaSetting[];
31
+ blocks?: BlockDefinition[];
32
+ max_blocks?: number;
33
+ presets?: Preset[];
34
+ };
35
+ export type EditorSectionSchema = EditorBlockSchema & Placement;
36
+ export interface EditorSchemas {
37
+ /** `config/settings-schema.json`, t: resolved. */
38
+ settingsSchema: readonly {
39
+ settings?: SchemaSetting[];
40
+ }[];
41
+ /** Theme and app sections, as the editor-loader lists them. */
42
+ sections: EditorSectionSchema[];
43
+ blocks: EditorBlockSchema[];
44
+ }
45
+ export interface EngineContext {
46
+ doc: ThemeDocument;
47
+ schemas: EditorSchemas;
48
+ recipes: LoadedRecipe[];
49
+ /** `config/ai/guide.md`; empty when the theme ships none. */
50
+ guide?: string;
51
+ sanitizeRichText: (html: string) => string;
52
+ images: IdPolicy;
53
+ fonts?: IdPolicy;
54
+ /** Mints a new instance id; `${type}_${8 hex}` when absent, as the editor does. */
55
+ idFor?: (type: string) => string;
56
+ }
57
+ export type EditorToolResult = {
58
+ ok: true;
59
+ ops: Op[];
60
+ result: Record<string, unknown>;
61
+ } | {
62
+ ok: false;
63
+ reason: string;
64
+ };
65
+ /**
66
+ * A recipe's sections, built and coerced as `add_section` and
67
+ * `update_*_setting` would build and coerce them, keyed by their new ids in
68
+ * recipe order. Refuses the whole recipe at the first bad section, block,
69
+ * setting or param.
70
+ */
71
+ export declare function expandRecipe(recipe: Recipe, params: Record<string, string | number>, ctx: EngineContext, scope?: EditorScope): {
72
+ ok: true;
73
+ sections: [string, TemplateSection][];
74
+ } | {
75
+ ok: false;
76
+ reason: string;
77
+ };
78
+ /**
79
+ * Runs one editor tool against a document: the ops that make the edit (what
80
+ * the admin would have sent for it, via `diffDocument`) and the result the
81
+ * model sees, or a refusal. Pure: no I/O, and `ctx.doc` is never mutated.
82
+ */
83
+ export declare function applyEditorTool(name: string, args: unknown, ctx: EngineContext): EditorToolResult;
84
+ //#endregion
85
+ export {};