@shop-on/theme-contract 0.5.0 → 0.7.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 (43) 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/editor-tools/apply.d.ts +85 -0
  8. package/dist/editor-tools/apply.js +544 -0
  9. package/dist/editor-tools/args.d.ts +153 -0
  10. package/dist/editor-tools/args.js +145 -0
  11. package/dist/editor-tools/coerce.d.ts +43 -0
  12. package/dist/editor-tools/coerce.js +200 -0
  13. package/dist/editor-tools/digest.d.ts +75 -0
  14. package/dist/editor-tools/digest.js +99 -0
  15. package/dist/editor-tools/index.d.ts +21 -0
  16. package/dist/editor-tools/index.js +24 -0
  17. package/dist/editor-tools/recipes.d.ts +122 -0
  18. package/dist/editor-tools/recipes.js +192 -0
  19. package/dist/fonts.d.ts +9 -0
  20. package/dist/fonts.js +11 -0
  21. package/dist/google-fonts.generated.d.ts +7 -0
  22. package/dist/google-fonts.generated.js +753 -0
  23. package/dist/index.d.ts +14 -5
  24. package/dist/index.js +14 -5
  25. package/dist/link-url.d.ts +9 -0
  26. package/dist/link-url.js +33 -0
  27. package/dist/metafield-binding.d.ts +89 -9
  28. package/dist/metafield-binding.js +254 -43
  29. package/dist/ops.d.ts +126 -0
  30. package/dist/ops.js +453 -0
  31. package/dist/richtext.d.ts +23 -0
  32. package/dist/richtext.js +115 -1
  33. package/dist/schemas.d.ts +325 -0
  34. package/dist/schemas.js +57 -7
  35. package/dist/section-scope.d.ts +17 -0
  36. package/dist/section-scope.js +22 -0
  37. package/dist/store-path.d.ts +9 -0
  38. package/dist/store-path.js +13 -1
  39. package/dist/template-limits.d.ts +34 -0
  40. package/dist/template-limits.js +71 -0
  41. package/dist/video-url.d.ts +10 -0
  42. package/dist/video-url.js +36 -0
  43. package/package.json +8 -1
@@ -0,0 +1,122 @@
1
+ import { EditorScope } from "./digest.js";
2
+ import { z } from "zod";
3
+ //#region src/editor-tools/recipes.d.ts
4
+ /** What a theme may ship under `src/ai/` (and Rails accepts at upload): see `aiManifest`. */
5
+ export declare const MAX_RECIPES = 20;
6
+ export declare const MAX_RECIPE_BYTES: number;
7
+ export declare const MAX_GUIDE_BYTES: number;
8
+ export declare const RECIPE_HANDLE_RE: RegExp;
9
+ export interface RecipeBlock {
10
+ type: string;
11
+ settings?: Record<string, string | number | boolean>;
12
+ blocks?: RecipeBlock[];
13
+ }
14
+ export declare const RecipeSchema: z.ZodObject<{
15
+ name: z.ZodString;
16
+ description: z.ZodOptional<z.ZodString>;
17
+ params: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
18
+ type: z.ZodEnum<{
19
+ collection: "collection";
20
+ image: "image";
21
+ number: "number";
22
+ product: "product";
23
+ text: "text";
24
+ url: "url";
25
+ }>;
26
+ label: z.ZodString;
27
+ required: z.ZodOptional<z.ZodBoolean>;
28
+ default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>>;
29
+ }, z.core.$strict>>>;
30
+ position: z.ZodOptional<z.ZodEnum<{
31
+ append: "append";
32
+ prepend: "prepend";
33
+ replace: "replace";
34
+ }>>;
35
+ sections: z.ZodArray<z.ZodObject<{
36
+ type: z.ZodString;
37
+ settings: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>>;
38
+ blocks: z.ZodOptional<z.ZodArray<z.ZodType<RecipeBlock, unknown, z.core.$ZodTypeInternals<RecipeBlock, unknown>>>>;
39
+ }, z.core.$strict>>;
40
+ }, z.core.$strict>;
41
+ export type Recipe = z.infer<typeof RecipeSchema>;
42
+ /** A recipe as a host holds it: its file name is its handle. */
43
+ export type LoadedRecipe = Recipe & {
44
+ handle: string;
45
+ };
46
+ export interface RecipeCheckSchemas {
47
+ sections: readonly {
48
+ type?: string;
49
+ settings?: {
50
+ id?: string;
51
+ }[];
52
+ blocks?: {
53
+ type: string;
54
+ static?: boolean;
55
+ }[];
56
+ enabled_on?: {
57
+ groups?: string[];
58
+ templates?: string[];
59
+ };
60
+ disabled_on?: {
61
+ groups?: string[];
62
+ templates?: string[];
63
+ };
64
+ }[];
65
+ blocks: readonly {
66
+ type?: string;
67
+ source?: "app";
68
+ settings?: {
69
+ id?: string;
70
+ }[];
71
+ blocks?: {
72
+ type: string;
73
+ static?: boolean;
74
+ }[];
75
+ }[];
76
+ }
77
+ /**
78
+ * The first thing wrong with a recipe against a theme's schemas, or null: an
79
+ * unknown section or block type, a setting the type doesn't have, a
80
+ * placeholder no param declares, a child its container doesn't allow, and,
81
+ * given a scope, a section that scope can't hold. The build runs it without a
82
+ * scope; the engine with the call's.
83
+ */
84
+ export declare function checkRecipe(recipe: Recipe, schemas: RecipeCheckSchemas, scope?: EditorScope): string | null;
85
+ /** One entry under a theme's `ai/` dir: its `/`-separated path relative to
86
+ * it, and whether it is a regular file, a directory or anything else (a
87
+ * symlink, a device). */
88
+ export interface AiEntry {
89
+ path: string;
90
+ kind: "file" | "dir" | "other";
91
+ bytes: number;
92
+ }
93
+ export interface AiManifest {
94
+ guide?: AiEntry;
95
+ recipes: AiEntry[];
96
+ refused: {
97
+ path: string;
98
+ reason: string;
99
+ }[];
100
+ }
101
+ /**
102
+ * The one rule for what a theme's `ai/` dir holds, applied by the build, the
103
+ * upload and every host that reads it: `guide.md` (at most MAX_GUIDE_BYTES)
104
+ * and `recipes/<handle>.json` (each at most MAX_RECIPE_BYTES, at most
105
+ * MAX_RECIPES of them, in path order), regular files only. The `recipes`
106
+ * directory itself is allowed; every other entry is refused.
107
+ */
108
+ export declare function aiManifest(entries: readonly AiEntry[]): AiManifest;
109
+ /**
110
+ * Parses recipe files, keyed by file name (`<handle>.json`), each on its own:
111
+ * a file that isn't valid JSON or a valid recipe is skipped with its reason
112
+ * and the others still load. The caps are `aiManifest`'s, checked before a
113
+ * file is read.
114
+ */
115
+ export declare function loadRecipes(files: Record<string, string>): {
116
+ recipes: LoadedRecipe[];
117
+ skipped: {
118
+ name: string;
119
+ reason: string;
120
+ }[];
121
+ };
122
+ //#endregion
@@ -0,0 +1,192 @@
1
+ import "../document.js";
2
+ import "../template-limits.js";
3
+ import { isSectionAddableInGroup, isSectionEnabledOnTemplate } from "../section-scope.js";
4
+ import { allowedChildBlockTypes } from "../block-allowlist.js";
5
+ import { EditorId, MAX_TEXT_PARAM_BYTES } from "./args.js";
6
+ import { z } from "zod";
7
+ //#region src/editor-tools/recipes.ts
8
+ /** What a theme may ship under `src/ai/` (and Rails accepts at upload): see `aiManifest`. */
9
+ const MAX_RECIPES = 20;
10
+ const MAX_RECIPE_BYTES = 32768;
11
+ const MAX_GUIDE_BYTES = 16384;
12
+ const RECIPE_HANDLE_RE = /^[a-z0-9-]{1,64}$/;
13
+ const RecipeValue = z.union([
14
+ z.string().max(MAX_RECIPE_BYTES),
15
+ z.number(),
16
+ z.boolean()
17
+ ]);
18
+ const SettingMap = z.record(EditorId, RecipeValue);
19
+ const RecipeBlockSchema = z.lazy(() => z.object({
20
+ type: EditorId,
21
+ settings: SettingMap.optional(),
22
+ blocks: z.array(RecipeBlockSchema).max(50).optional()
23
+ }).strict());
24
+ const RecipeParam = z.object({
25
+ type: z.enum([
26
+ "text",
27
+ "product",
28
+ "collection",
29
+ "image",
30
+ "url",
31
+ "number"
32
+ ]),
33
+ label: z.string().min(1).max(120),
34
+ required: z.boolean().optional(),
35
+ default: z.union([z.string().max(MAX_TEXT_PARAM_BYTES), z.number()]).optional()
36
+ }).strict();
37
+ const RecipeSchema = z.object({
38
+ name: z.string().min(1).max(120),
39
+ description: z.string().max(1e3).optional(),
40
+ params: z.record(EditorId, RecipeParam).optional(),
41
+ position: z.enum([
42
+ "append",
43
+ "prepend",
44
+ "replace"
45
+ ]).optional(),
46
+ sections: z.array(z.object({
47
+ type: EditorId,
48
+ settings: SettingMap.optional(),
49
+ blocks: z.array(RecipeBlockSchema).max(50).optional()
50
+ }).strict()).min(1).max(25)
51
+ }).strict();
52
+ const PLACEHOLDER_RE = /\{\{\s*([^{}\s]+)\s*\}\}/g;
53
+ function findBlockSchema(schemas, type) {
54
+ return schemas.blocks.find((b) => b.type === type) ?? schemas.blocks.find((b) => b.type === type.replace(/^_/, ""));
55
+ }
56
+ function checkSettings(where, settings, schemaSettings, params) {
57
+ for (const [id, value] of Object.entries(settings ?? {})) {
58
+ if (!schemaSettings?.some((s) => s.id === id)) return `${where}: unknown setting ${id}`;
59
+ if (typeof value !== "string") continue;
60
+ for (const [, name] of value.matchAll(PLACEHOLDER_RE)) if (!Object.hasOwn(params, name)) return `${where}.settings.${id}: unknown param ${name}`;
61
+ }
62
+ return null;
63
+ }
64
+ function checkBlocks(where, blocks, container, depth, schemas, params) {
65
+ if (!blocks?.length) return null;
66
+ if (depth >= 8) return `${where}: blocks nest deeper than 8`;
67
+ const allowed = allowedChildBlockTypes(container, schemas.blocks, { depth });
68
+ for (const [i, block] of blocks.entries()) {
69
+ const at = `${where}.blocks[${i}]`;
70
+ if (block.type === "@app" || !allowed.includes(block.type)) return `${at}: block type ${block.type} is not allowed here`;
71
+ const schema = findBlockSchema(schemas, block.type);
72
+ if (!schema) return `${at}: unknown block type ${block.type}`;
73
+ const bad = checkSettings(at, block.settings, schema.settings, params) ?? checkBlocks(at, block.blocks, schema, depth + 1, schemas, params);
74
+ if (bad) return bad;
75
+ }
76
+ return null;
77
+ }
78
+ /**
79
+ * The first thing wrong with a recipe against a theme's schemas, or null: an
80
+ * unknown section or block type, a setting the type doesn't have, a
81
+ * placeholder no param declares, a child its container doesn't allow, and,
82
+ * given a scope, a section that scope can't hold. The build runs it without a
83
+ * scope; the engine with the call's.
84
+ */
85
+ function checkRecipe(recipe, schemas, scope) {
86
+ const params = recipe.params ?? {};
87
+ for (const [i, section] of recipe.sections.entries()) {
88
+ const at = `sections[${i}]`;
89
+ const schema = schemas.sections.find((s) => s.type === section.type);
90
+ if (!schema) return `${at}: unknown section type ${section.type}`;
91
+ if (scope && "template_id" in scope && !isSectionEnabledOnTemplate(schema, scope.template_id)) return `${at}: section type ${section.type} is not allowed on template ${scope.template_id}`;
92
+ if (scope && "group" in scope && !isSectionAddableInGroup(schema, scope.group)) return `${at}: section type ${section.type} is not allowed in group ${scope.group}`;
93
+ const bad = checkSettings(at, section.settings, schema.settings, params) ?? checkBlocks(at, section.blocks, schema, 0, schemas, params);
94
+ if (bad) return bad;
95
+ }
96
+ return null;
97
+ }
98
+ const RECIPE_PATH_RE = /^recipes\/([^/]+)\.json$/;
99
+ /**
100
+ * The one rule for what a theme's `ai/` dir holds, applied by the build, the
101
+ * upload and every host that reads it: `guide.md` (at most MAX_GUIDE_BYTES)
102
+ * and `recipes/<handle>.json` (each at most MAX_RECIPE_BYTES, at most
103
+ * MAX_RECIPES of them, in path order), regular files only. The `recipes`
104
+ * directory itself is allowed; every other entry is refused.
105
+ */
106
+ function aiManifest(entries) {
107
+ const out = {
108
+ recipes: [],
109
+ refused: []
110
+ };
111
+ const refuse = (path, reason) => out.refused.push({
112
+ path,
113
+ reason
114
+ });
115
+ for (const entry of [...entries].sort((a, b) => a.path < b.path ? -1 : 1)) {
116
+ const { path, kind, bytes } = entry;
117
+ if (path === "recipes" && kind === "dir") continue;
118
+ const handle = RECIPE_PATH_RE.exec(path)?.[1];
119
+ if (!(path === "guide.md" || handle !== void 0 && RECIPE_HANDLE_RE.test(handle))) refuse(path, `${path}: ai/ holds only guide.md and recipes/<handle>.json, handle [a-z0-9-]`);
120
+ else if (kind !== "file") refuse(path, `${path} is not a regular file`);
121
+ else if (path === "guide.md") {
122
+ if (bytes > 16384) refuse(path, `${path} is over ${MAX_GUIDE_BYTES} bytes`);
123
+ else out.guide = entry;
124
+ } else if (bytes > 32768) refuse(path, `${path} is over ${MAX_RECIPE_BYTES} bytes`);
125
+ else if (out.recipes.length >= 20) refuse(path, `${path}: more than 20 recipes`);
126
+ else out.recipes.push(entry);
127
+ }
128
+ return out;
129
+ }
130
+ /**
131
+ * Parses recipe files, keyed by file name (`<handle>.json`), each on its own:
132
+ * a file that isn't valid JSON or a valid recipe is skipped with its reason
133
+ * and the others still load. The caps are `aiManifest`'s, checked before a
134
+ * file is read.
135
+ */
136
+ function loadRecipes(files) {
137
+ const recipes = [];
138
+ const skipped = [];
139
+ for (const name of Object.keys(files).sort()) {
140
+ const handle = name.replace(/\.json$/, "");
141
+ if (!name.endsWith(".json") || !RECIPE_HANDLE_RE.test(handle)) {
142
+ skipped.push({
143
+ name,
144
+ reason: `${name}: a recipe file is <handle>.json, handle [a-z0-9-]`
145
+ });
146
+ continue;
147
+ }
148
+ let raw;
149
+ try {
150
+ raw = JSON.parse(files[name]);
151
+ } catch {
152
+ skipped.push({
153
+ name,
154
+ reason: `${name} is not valid JSON`
155
+ });
156
+ continue;
157
+ }
158
+ const parsed = RecipeSchema.safeParse(raw);
159
+ if (!parsed.success) {
160
+ const issue = parsed.error.issues[0];
161
+ skipped.push({
162
+ name,
163
+ reason: `${name}: ${issue.path.join(".")}: ${issue.message}`
164
+ });
165
+ continue;
166
+ }
167
+ recipes.push({
168
+ ...parsed.data,
169
+ handle
170
+ });
171
+ }
172
+ return {
173
+ recipes,
174
+ skipped
175
+ };
176
+ }
177
+ /** `{{name}}` in a recipe string, replaced by the param's value. */
178
+ function substituteParams(value, params) {
179
+ return value.replace(PLACEHOLDER_RE, (_m, name) => params[name] ?? "");
180
+ }
181
+ function recipeSummary(recipe) {
182
+ return {
183
+ handle: recipe.handle,
184
+ name: recipe.name,
185
+ ...recipe.description ? { description: recipe.description } : {},
186
+ position: recipe.position ?? "append",
187
+ params: recipe.params ?? {},
188
+ sections: recipe.sections.map((s) => s.type)
189
+ };
190
+ }
191
+ //#endregion
192
+ export { MAX_GUIDE_BYTES, MAX_RECIPES, MAX_RECIPE_BYTES, RECIPE_HANDLE_RE, RecipeSchema, aiManifest, checkRecipe, loadRecipes, recipeSummary, substituteParams };
@@ -0,0 +1,9 @@
1
+ import { GOOGLE_FONTS, GoogleFontEntry } from "./google-fonts.generated.js";
2
+ //#region src/fonts.d.ts
3
+ /** The families a `font_picker` offers that load from no file. */
4
+ export declare const SYSTEM_FONT_FAMILIES: readonly ["system-ui"];
5
+ /** Whether a `font_picker` value names a family of the editor's catalog. An
6
+ * uploaded family's node id is not one. */
7
+ export declare function isCatalogFontFamily(family: string): boolean;
8
+ //#endregion
9
+ export { GOOGLE_FONTS, type GoogleFontEntry };
package/dist/fonts.js ADDED
@@ -0,0 +1,11 @@
1
+ import { GOOGLE_FONTS } from "./google-fonts.generated.js";
2
+ //#region src/fonts.ts
3
+ /** The families a `font_picker` offers that load from no file. */
4
+ const SYSTEM_FONT_FAMILIES = ["system-ui"];
5
+ /** Whether a `font_picker` value names a family of the editor's catalog. An
6
+ * uploaded family's node id is not one. */
7
+ function isCatalogFontFamily(family) {
8
+ return SYSTEM_FONT_FAMILIES.includes(family) || GOOGLE_FONTS.some((f) => f.family === family);
9
+ }
10
+ //#endregion
11
+ export { GOOGLE_FONTS, SYSTEM_FONT_FAMILIES, isCatalogFontFamily };
@@ -0,0 +1,7 @@
1
+ //#region src/google-fonts.generated.d.ts
2
+ export interface GoogleFontEntry {
3
+ family: string;
4
+ category: string;
5
+ }
6
+ export declare const GOOGLE_FONTS: GoogleFontEntry[];
7
+ //#endregion