@shop-on/theme-contract 0.0.0-stage → 0.2.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,32 @@
1
+ //#region src/block-path.d.ts
2
+ export declare const BLOCK_PATH_SEPARATOR = ".";
3
+ export declare function joinBlockPath(path: readonly string[]): string;
4
+ export declare function splitBlockPath(value: string | null | undefined): string[];
5
+ export declare function blockPathsEqual(a: readonly string[] | undefined, b: readonly string[] | undefined): boolean;
6
+ /** True when `prefix` names `path` itself or one of its ancestors. */
7
+ export declare function isBlockPathPrefix(prefix: readonly string[], path: readonly string[]): boolean;
8
+ /**
9
+ * Coerce an untrusted wire value into a block path. Accepts the modern
10
+ * `string[]` and dotted-`string` forms, plus the legacy single
11
+ * `parentBlockId` (normalized to a one-segment path) that older bundled
12
+ * theme-react bridges still emit. Anything else yields `[]`.
13
+ */
14
+ export declare function normalizeBlockPath(value: unknown, legacyParentBlockId?: unknown): string[];
15
+ /** The block type a container's `blocks` map holds — recovered from the
16
+ * section/block type the caller passed, so the resolved container keeps its
17
+ * real type (`type`, `settings`, `custom_css`, …) instead of a structural
18
+ * stand-in. */
19
+ type BlocksOf<S> = S extends {
20
+ blocks?: Record<string, infer B> | undefined;
21
+ } ? B : never;
22
+ /**
23
+ * Walk `path` hop by hop from the section and return the container it names —
24
+ * the section itself for an empty path, otherwise the block at the end of the
25
+ * chain. Returns `undefined` the instant a hop is missing, so callers can fail
26
+ * loudly (`container_not_found`) instead of silently writing nowhere.
27
+ */
28
+ export declare function resolveContainerAtPath<S extends {
29
+ blocks?: Record<string, unknown>;
30
+ }>(section: S | undefined, path: readonly string[]): S | BlocksOf<S> | undefined;
31
+ //#endregion
32
+ export {};
@@ -0,0 +1,52 @@
1
+ //#region src/block-path.ts
2
+ const BLOCK_PATH_SEPARATOR = ".";
3
+ function joinBlockPath(path) {
4
+ return path.join(".");
5
+ }
6
+ function splitBlockPath(value) {
7
+ if (!value) return [];
8
+ return value.split(".").filter((seg) => seg !== "");
9
+ }
10
+ function blockPathsEqual(a, b) {
11
+ const left = a ?? [];
12
+ const right = b ?? [];
13
+ if (left.length !== right.length) return false;
14
+ for (let i = 0; i < left.length; i++) if (left[i] !== right[i]) return false;
15
+ return true;
16
+ }
17
+ /** True when `prefix` names `path` itself or one of its ancestors. */
18
+ function isBlockPathPrefix(prefix, path) {
19
+ if (prefix.length > path.length) return false;
20
+ for (let i = 0; i < prefix.length; i++) if (prefix[i] !== path[i]) return false;
21
+ return true;
22
+ }
23
+ /**
24
+ * Coerce an untrusted wire value into a block path. Accepts the modern
25
+ * `string[]` and dotted-`string` forms, plus the legacy single
26
+ * `parentBlockId` (normalized to a one-segment path) that older bundled
27
+ * theme-react bridges still emit. Anything else yields `[]`.
28
+ */
29
+ function normalizeBlockPath(value, legacyParentBlockId) {
30
+ if (Array.isArray(value)) return value.filter((seg) => typeof seg === "string" && seg !== "");
31
+ if (typeof value === "string") return splitBlockPath(value);
32
+ if (typeof legacyParentBlockId === "string" && legacyParentBlockId !== "") return [legacyParentBlockId];
33
+ return [];
34
+ }
35
+ /**
36
+ * Walk `path` hop by hop from the section and return the container it names —
37
+ * the section itself for an empty path, otherwise the block at the end of the
38
+ * chain. Returns `undefined` the instant a hop is missing, so callers can fail
39
+ * loudly (`container_not_found`) instead of silently writing nowhere.
40
+ */
41
+ function resolveContainerAtPath(section, path) {
42
+ if (!section) return void 0;
43
+ if (path.length === 0) return section;
44
+ let node = section.blocks?.[path[0]];
45
+ for (let i = 1; i < path.length; i++) {
46
+ if (!node) return void 0;
47
+ node = node.blocks?.[path[i]];
48
+ }
49
+ return node;
50
+ }
51
+ //#endregion
52
+ export { BLOCK_PATH_SEPARATOR, blockPathsEqual, isBlockPathPrefix, joinBlockPath, normalizeBlockPath, resolveContainerAtPath, splitBlockPath };
@@ -0,0 +1,48 @@
1
+ //#region src/color-palette.d.ts
2
+ export declare const PALETTE_GROUP_TYPE = "color_palette_group";
3
+ export declare const PALETTE_PREFIX = "color_palette.";
4
+ export declare const PALETTE_REF_PREFIX = "palette:";
5
+ /** Shopify's bounds: a palette holds at least 2 and at most 20 colors. */
6
+ export declare const MIN_PALETTE_COLORS = 2;
7
+ export declare const MAX_PALETTE_COLORS = 20;
8
+ /** Substituted when a `palette:` ref points at a color that no longer exists
9
+ * (hand-edited JSON, a half-applied migration). Mid-gray keeps the surface
10
+ * legible; leaving the raw ref in place would emit invalid CSS and blank the
11
+ * declaration unpredictably. */
12
+ export declare const PALETTE_FALLBACK_COLOR = "#808080";
13
+ export interface PaletteColor {
14
+ id: string;
15
+ label: string;
16
+ value: string;
17
+ }
18
+ export declare function paletteKey(id: string, field: "label" | "value"): string;
19
+ export declare function paletteRef(id: string): string;
20
+ /** `"palette:brand"` → `"brand"`. Anything else (including a hex literal, a
21
+ * non-string, or a ref with an unsafe id) → `null`. */
22
+ export declare function parsePaletteRef(value: unknown): string | null;
23
+ export declare function comparePaletteIds(a: string, b: string): number;
24
+ /** Every palette color defined in a flat theme-settings map, ordered
25
+ * `palette_1`, `palette_2`, … then any non-numeric ids alphabetically. */
26
+ export declare function parsePalette(settings: Record<string, unknown>): PaletteColor[];
27
+ /** Palette id → literal color, the lookup table every resolver takes. */
28
+ export declare function paletteValues(settings: Record<string, unknown>): Record<string, string>;
29
+ /** A single value: `palette:<id>` → its literal (or the neutral fallback);
30
+ * everything else passes through untouched. */
31
+ export declare function resolvePaletteValue(value: unknown, palette: Record<string, string>): unknown;
32
+ /**
33
+ * Replace every `palette:<id>` ref inside a template / section-group document
34
+ * (section settings, block settings, nested block settings) with its literal
35
+ * color. Structurally shared: untouched branches keep their identity, and a
36
+ * document with no refs is returned as-is.
37
+ */
38
+ export declare function resolvePaletteRefs<T>(document: T, palette: Record<string, string>): T;
39
+ /** Cheap pre-check so callers can skip loading the palette (and, in the loader,
40
+ * an extra settings fetch) for the overwhelmingly common ref-free document. */
41
+ export declare function hasPaletteRefs(node: unknown): boolean;
42
+ /**
43
+ * The flat theme-settings map resolves against ITSELF — palette definitions and
44
+ * the settings that reference them live in the same document. `color_palette.*`
45
+ * keys are skipped so a definition can never chain into another definition.
46
+ */
47
+ export declare function resolveThemeSettingsPalette(settings: Record<string, unknown>): Record<string, unknown>;
48
+ //#endregion
@@ -0,0 +1,143 @@
1
+ //#region src/color-palette.ts
2
+ const PALETTE_GROUP_TYPE = "color_palette_group";
3
+ const PALETTE_PREFIX = "color_palette.";
4
+ const PALETTE_REF_PREFIX = "palette:";
5
+ /** Shopify's bounds: a palette holds at least 2 and at most 20 colors. */
6
+ const MIN_PALETTE_COLORS = 2;
7
+ const MAX_PALETTE_COLORS = 20;
8
+ /** Substituted when a `palette:` ref points at a color that no longer exists
9
+ * (hand-edited JSON, a half-applied migration). Mid-gray keeps the surface
10
+ * legible; leaving the raw ref in place would emit invalid CSS and blank the
11
+ * declaration unpredictably. */
12
+ const PALETTE_FALLBACK_COLOR = "#808080";
13
+ const PALETTE_ID_RE = /^[a-zA-Z0-9_-]+$/;
14
+ const NUMERIC_SUFFIX_RE = /^palette_(\d+)$/;
15
+ function paletteKey(id, field) {
16
+ return `${PALETTE_PREFIX}${id}.${field}`;
17
+ }
18
+ function paletteRef(id) {
19
+ return `${PALETTE_REF_PREFIX}${id}`;
20
+ }
21
+ /** `"palette:brand"` → `"brand"`. Anything else (including a hex literal, a
22
+ * non-string, or a ref with an unsafe id) → `null`. */
23
+ function parsePaletteRef(value) {
24
+ if (typeof value !== "string" || !value.startsWith("palette:")) return null;
25
+ const id = value.slice(8);
26
+ return PALETTE_ID_RE.test(id) ? id : null;
27
+ }
28
+ function comparePaletteIds(a, b) {
29
+ const ma = a.match(NUMERIC_SUFFIX_RE);
30
+ const mb = b.match(NUMERIC_SUFFIX_RE);
31
+ if (ma && mb) return Number.parseInt(ma[1], 10) - Number.parseInt(mb[1], 10);
32
+ if (ma) return -1;
33
+ if (mb) return 1;
34
+ return a < b ? -1 : a > b ? 1 : 0;
35
+ }
36
+ /** Every palette color defined in a flat theme-settings map, ordered
37
+ * `palette_1`, `palette_2`, … then any non-numeric ids alphabetically. */
38
+ function parsePalette(settings) {
39
+ const byId = {};
40
+ for (const [key, raw] of Object.entries(settings)) {
41
+ if (!key.startsWith("color_palette.") || typeof raw !== "string") continue;
42
+ const rest = key.slice(14);
43
+ const dot = rest.indexOf(".");
44
+ if (dot === -1) continue;
45
+ const id = rest.slice(0, dot);
46
+ const field = rest.slice(dot + 1);
47
+ if (!PALETTE_ID_RE.test(id)) continue;
48
+ if (field !== "label" && field !== "value") continue;
49
+ (byId[id] ??= {})[field] = raw;
50
+ }
51
+ return Object.entries(byId).filter(([, parts]) => typeof parts.value === "string").map(([id, parts]) => ({
52
+ id,
53
+ label: parts.label || id,
54
+ value: parts.value
55
+ })).sort((a, b) => comparePaletteIds(a.id, b.id));
56
+ }
57
+ /** Palette id → literal color, the lookup table every resolver takes. */
58
+ function paletteValues(settings) {
59
+ const out = {};
60
+ for (const color of parsePalette(settings)) out[color.id] = color.value;
61
+ return out;
62
+ }
63
+ /** A single value: `palette:<id>` → its literal (or the neutral fallback);
64
+ * everything else passes through untouched. */
65
+ function resolvePaletteValue(value, palette) {
66
+ const id = parsePaletteRef(value);
67
+ if (id === null) return value;
68
+ return palette[id] ?? "#808080";
69
+ }
70
+ function isPlainObject(v) {
71
+ return typeof v === "object" && v !== null && !Array.isArray(v);
72
+ }
73
+ function resolveSettingsMap(settings, palette) {
74
+ let changed = false;
75
+ const out = {};
76
+ for (const [key, value] of Object.entries(settings)) {
77
+ const next = resolvePaletteValue(value, palette);
78
+ if (next !== value) changed = true;
79
+ out[key] = next;
80
+ }
81
+ return changed ? out : settings;
82
+ }
83
+ function resolveNode(node, palette) {
84
+ if (Array.isArray(node)) {
85
+ let changed = false;
86
+ const out = node.map((item) => {
87
+ const next = resolveNode(item, palette);
88
+ if (next !== item) changed = true;
89
+ return next;
90
+ });
91
+ return changed ? out : node;
92
+ }
93
+ if (!isPlainObject(node)) return node;
94
+ let changed = false;
95
+ const out = {};
96
+ for (const [key, value] of Object.entries(node)) {
97
+ const next = key === "settings" && isPlainObject(value) ? resolveSettingsMap(value, palette) : resolveNode(value, palette);
98
+ if (next !== value) changed = true;
99
+ out[key] = next;
100
+ }
101
+ return changed ? out : node;
102
+ }
103
+ /**
104
+ * Replace every `palette:<id>` ref inside a template / section-group document
105
+ * (section settings, block settings, nested block settings) with its literal
106
+ * color. Structurally shared: untouched branches keep their identity, and a
107
+ * document with no refs is returned as-is.
108
+ */
109
+ function resolvePaletteRefs(document, palette) {
110
+ return resolveNode(document, palette);
111
+ }
112
+ /** Cheap pre-check so callers can skip loading the palette (and, in the loader,
113
+ * an extra settings fetch) for the overwhelmingly common ref-free document. */
114
+ function hasPaletteRefs(node) {
115
+ if (Array.isArray(node)) return node.some((item) => hasPaletteRefs(item));
116
+ if (!isPlainObject(node)) return false;
117
+ for (const [key, value] of Object.entries(node)) {
118
+ if (key === "settings" && isPlainObject(value)) {
119
+ for (const inner of Object.values(value)) if (parsePaletteRef(inner) !== null) return true;
120
+ continue;
121
+ }
122
+ if (hasPaletteRefs(value)) return true;
123
+ }
124
+ return false;
125
+ }
126
+ /**
127
+ * The flat theme-settings map resolves against ITSELF — palette definitions and
128
+ * the settings that reference them live in the same document. `color_palette.*`
129
+ * keys are skipped so a definition can never chain into another definition.
130
+ */
131
+ function resolveThemeSettingsPalette(settings) {
132
+ const palette = paletteValues(settings);
133
+ let changed = false;
134
+ const out = {};
135
+ for (const [key, value] of Object.entries(settings)) {
136
+ const next = key.startsWith("color_palette.") ? value : resolvePaletteValue(value, palette);
137
+ if (next !== value) changed = true;
138
+ out[key] = next;
139
+ }
140
+ return changed ? out : settings;
141
+ }
142
+ //#endregion
143
+ export { MAX_PALETTE_COLORS, MIN_PALETTE_COLORS, PALETTE_FALLBACK_COLOR, PALETTE_GROUP_TYPE, PALETTE_PREFIX, PALETTE_REF_PREFIX, comparePaletteIds, hasPaletteRefs, paletteKey, paletteRef, paletteValues, parsePalette, parsePaletteRef, resolvePaletteRefs, resolvePaletteValue, resolveThemeSettingsPalette };
@@ -0,0 +1,115 @@
1
+ import { BlockDefinition, SchemaSetting, TemplateBlock, TemplateSection } from "./schemas.js";
2
+ //#region src/document.d.ts
3
+ export interface SelectedSection {
4
+ instanceId: string;
5
+ section: TemplateSection;
6
+ blocks: TemplateBlock[];
7
+ staticBlocks: Record<string, TemplateBlock>;
8
+ }
9
+ export interface TemplateShape {
10
+ layout?: string;
11
+ sections: Record<string, TemplateSection>;
12
+ order: string[];
13
+ section_groups?: Record<string, {
14
+ sections: Record<string, TemplateSection>;
15
+ order: string[];
16
+ }>;
17
+ }
18
+ export interface CollectedTypes {
19
+ sections: Set<string>;
20
+ blocks: Set<string>;
21
+ layouts: Set<string>;
22
+ }
23
+ export declare function defaultSettings(schemaDefs?: SchemaSetting[]): Record<string, unknown>;
24
+ export declare function staticChildId(childType: string, parentType: string): string;
25
+ export declare function staticBlockId(type: string): string;
26
+ export declare function buildStaticChildren(args: {
27
+ parentType: string;
28
+ childDefs: BlockDefinition[];
29
+ getBlockSchema: (type: string) => {
30
+ settings?: SchemaSetting[];
31
+ blocks?: BlockDefinition[];
32
+ } | undefined;
33
+ }): {
34
+ blocks: Record<string, TemplateBlock>;
35
+ block_order: string[];
36
+ };
37
+ type BlockSchemaLike = {
38
+ settings?: SchemaSetting[];
39
+ blocks?: BlockDefinition[];
40
+ } | undefined;
41
+ /**
42
+ * Build a fresh block instance the way a merchant adding it should get it:
43
+ * schema defaults → its schema's static children → its first preset overlaid.
44
+ * The preset is Horizon's block `presets[0]` — the one Shopify applies when a
45
+ * block is added — so a container arrives with the content it ships with (an
46
+ * accordion with its rows, a recommendations block with its heading and card
47
+ * fields) rather than as an empty frame. Same inputs contract as
48
+ * `buildSectionInstance`: schemas already t:-resolved, `idFor` injected.
49
+ */
50
+ export declare function buildBlockInstance(args: {
51
+ type: string;
52
+ getBlockSchema: (type: string) => BlockSchemaLike;
53
+ preset?: {
54
+ settings?: Record<string, unknown>;
55
+ blocks?: BlockDefinition[];
56
+ };
57
+ idFor: (type: string) => string;
58
+ }): TemplateBlock;
59
+ /**
60
+ * Build a fresh section instance from its schema: schema/preset defaults →
61
+ * seeded static blocks → preset overlay. Shared by the admin editor's
62
+ * `addSection` and the storefront's standalone section-preview route so both
63
+ * produce identical instances.
64
+ *
65
+ * Inputs must be **already t:-resolved** — the admin gets resolved schemas from
66
+ * the orchestrator's editor-loader; the storefront resolves on-disk before
67
+ * calling. `idFor` is injected (admin mints a random suffix; the preview route
68
+ * uses a deterministic counter) so callers control id stability.
69
+ */
70
+ export declare function buildSectionInstance(args: {
71
+ type: string;
72
+ sectionSchema: {
73
+ settings?: SchemaSetting[];
74
+ blocks?: BlockDefinition[];
75
+ } | undefined;
76
+ getBlockSchema: (type: string) => BlockSchemaLike;
77
+ preset?: {
78
+ settings?: Record<string, unknown>;
79
+ blocks?: BlockDefinition[];
80
+ };
81
+ idFor: (type: string) => string;
82
+ }): TemplateSection & {
83
+ blocks: Record<string, TemplateBlock>;
84
+ block_order: string[];
85
+ };
86
+ export declare function selectRenderableSections(sections: Record<string, TemplateSection>, order: string[]): SelectedSection[];
87
+ export declare function selectRenderableChildren(block: TemplateBlock): TemplateBlock[];
88
+ export declare function collectTypes(template: TemplateShape): CollectedTypes;
89
+ type BlockNode = {
90
+ static?: boolean;
91
+ blocks?: Record<string, BlockNode>;
92
+ block_order?: string[];
93
+ } & Record<string, unknown>;
94
+ type BlockContainer = {
95
+ blocks?: Record<string, BlockNode>;
96
+ block_order?: string[];
97
+ } & Record<string, unknown>;
98
+ export declare function stripStaticFromBlockOrder(container: BlockContainer): number;
99
+ export type SeedMissingStaticsReport = {
100
+ added: Array<{
101
+ sectionId: string;
102
+ blockPath: string;
103
+ }>;
104
+ reordered: Array<{
105
+ sectionId: string;
106
+ containerPath: string;
107
+ }>;
108
+ };
109
+ export declare function seedMissingStatics(oldTemplate: {
110
+ sections: Record<string, TemplateSection>;
111
+ }, freshTemplate: {
112
+ sections: Record<string, TemplateSection>;
113
+ }): SeedMissingStaticsReport;
114
+ //#endregion
115
+ export {};