@shop-on/theme-runtime 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.
Files changed (72) hide show
  1. package/dist/LICENSE.md +39 -0
  2. package/dist/badge-tags.d.ts +8 -0
  3. package/dist/badge-tags.js +13 -0
  4. package/dist/cart-discounts.d.ts +16 -0
  5. package/dist/cart-discounts.js +21 -0
  6. package/dist/cart-note.d.ts +6 -0
  7. package/dist/cart-note.js +7 -0
  8. package/dist/color.d.ts +34 -0
  9. package/dist/color.js +145 -0
  10. package/dist/fonts.d.ts +75 -0
  11. package/dist/fonts.js +137 -0
  12. package/dist/index.d.ts +25 -0
  13. package/dist/index.js +22 -0
  14. package/dist/initials.d.ts +3 -0
  15. package/dist/initials.js +9 -0
  16. package/dist/json-ld-escape.d.ts +3 -0
  17. package/dist/json-ld-escape.js +6 -0
  18. package/dist/line-groups.d.ts +20 -0
  19. package/dist/line-groups.js +35 -0
  20. package/dist/line-item-attributes.d.ts +44 -0
  21. package/dist/line-item-attributes.js +82 -0
  22. package/dist/listing-filters.d.ts +88 -0
  23. package/dist/listing-filters.js +206 -0
  24. package/dist/listing-price.d.ts +3 -0
  25. package/dist/listing-price.js +13 -0
  26. package/dist/money.d.ts +16 -0
  27. package/dist/money.js +51 -0
  28. package/dist/page-cursor.d.ts +20 -0
  29. package/dist/page-cursor.js +24 -0
  30. package/dist/parse-embed-url.d.ts +28 -0
  31. package/dist/parse-embed-url.js +71 -0
  32. package/dist/playable-video.d.ts +6 -0
  33. package/dist/playable-video.js +6 -0
  34. package/dist/product-href.d.ts +3 -0
  35. package/dist/product-href.js +8 -0
  36. package/dist/product-variants/build-availability-index.d.ts +4 -0
  37. package/dist/product-variants/build-availability-index.js +25 -0
  38. package/dist/product-variants/build-variant-state.d.ts +9 -0
  39. package/dist/product-variants/build-variant-state.js +16 -0
  40. package/dist/product-variants/find-closest-variant.d.ts +4 -0
  41. package/dist/product-variants/find-closest-variant.js +26 -0
  42. package/dist/product-variants/index.d.ts +12 -0
  43. package/dist/product-variants/index.js +11 -0
  44. package/dist/product-variants/is-default-single-variant.d.ts +4 -0
  45. package/dist/product-variants/is-default-single-variant.js +8 -0
  46. package/dist/product-variants/is-variant-selectable.d.ts +4 -0
  47. package/dist/product-variants/is-variant-selectable.js +22 -0
  48. package/dist/product-variants/resolve-selected-variant.d.ts +8 -0
  49. package/dist/product-variants/resolve-selected-variant.js +18 -0
  50. package/dist/product-variants/selection-key.d.ts +4 -0
  51. package/dist/product-variants/selection-key.js +7 -0
  52. package/dist/product-variants/stale-variant.d.ts +9 -0
  53. package/dist/product-variants/stale-variant.js +9 -0
  54. package/dist/product-variants/swatch-source.d.ts +12 -0
  55. package/dist/product-variants/swatch-source.js +22 -0
  56. package/dist/product-variants/types.d.ts +59 -0
  57. package/dist/product-variants/variant-price-range.d.ts +27 -0
  58. package/dist/product-variants/variant-price-range.js +38 -0
  59. package/dist/richtext.d.ts +3 -0
  60. package/dist/richtext.js +145 -0
  61. package/dist/sale.d.ts +5 -0
  62. package/dist/sale.js +10 -0
  63. package/dist/seo.d.ts +97 -0
  64. package/dist/seo.js +119 -0
  65. package/dist/strip-handoff-url.d.ts +3 -0
  66. package/dist/strip-handoff-url.js +10 -0
  67. package/dist/template-types.d.ts +142 -0
  68. package/dist/template-types.js +0 -0
  69. package/dist/thumbhash.d.ts +3 -0
  70. package/dist/thumbhash.js +23 -0
  71. package/package.json +46 -3
  72. package/README.md +0 -3
@@ -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,8 @@
1
+ //#region src/badge-tags.d.ts
2
+ export type TagBadgeKind = "hot" | "new" | "sale";
3
+ export type TagBadge = {
4
+ kind: TagBadgeKind;
5
+ label: string;
6
+ };
7
+ export declare function tagBadges(tags: readonly string[] | null | undefined): TagBadge[];
8
+ //#endregion
@@ -0,0 +1,13 @@
1
+ //#region src/badge-tags.ts
2
+ const TAG_BADGE = /^tag__(hot|new|sale)_+(.+)$/i;
3
+ function tagBadges(tags) {
4
+ return (tags ?? []).flatMap((tag) => {
5
+ const match = TAG_BADGE.exec(tag);
6
+ return match ? [{
7
+ kind: match[1].toLowerCase(),
8
+ label: match[2]
9
+ }] : [];
10
+ });
11
+ }
12
+ //#endregion
13
+ export { tagBadges };
@@ -0,0 +1,16 @@
1
+ import { Money } from "./money.js";
2
+ //#region src/cart-discounts.d.ts
3
+ export type CartDiscountAllocationLike = {
4
+ targetLineItemId: string | null;
5
+ amount: Money;
6
+ };
7
+ export type CartDiscountApplicationLike = {
8
+ allocations: ReadonlyArray<CartDiscountAllocationLike>;
9
+ };
10
+ /** Flatten a cart's discount applications into a `lineId → total deduction`
11
+ * map, for strike-through pricing on line rows. Order-level allocations
12
+ * (`targetLineItemId === null`) are skipped — they come off the subtotal, not
13
+ * a line. Several applications on one line are summed, keeping the first
14
+ * allocation's `currencyCode`. */
15
+ export declare function indexLineDiscounts(applications: ReadonlyArray<CartDiscountApplicationLike>): Map<string, Money>;
16
+ //#endregion
@@ -0,0 +1,21 @@
1
+ //#region src/cart-discounts.ts
2
+ /** Flatten a cart's discount applications into a `lineId → total deduction`
3
+ * map, for strike-through pricing on line rows. Order-level allocations
4
+ * (`targetLineItemId === null`) are skipped — they come off the subtotal, not
5
+ * a line. Several applications on one line are summed, keeping the first
6
+ * allocation's `currencyCode`. */
7
+ function indexLineDiscounts(applications) {
8
+ const byLine = /* @__PURE__ */ new Map();
9
+ for (const app of applications) for (const alloc of app.allocations) {
10
+ if (alloc.targetLineItemId === null) continue;
11
+ const prior = byLine.get(alloc.targetLineItemId);
12
+ if (prior) byLine.set(alloc.targetLineItemId, {
13
+ amount: prior.amount + alloc.amount.amount,
14
+ currencyCode: prior.currencyCode
15
+ });
16
+ else byLine.set(alloc.targetLineItemId, alloc.amount);
17
+ }
18
+ return byLine;
19
+ }
20
+ //#endregion
21
+ export { indexLineDiscounts };
@@ -0,0 +1,6 @@
1
+ //#region src/cart-note.d.ts
2
+ /** The API's cap on a cart note, in characters. Past it, `cartNoteUpdate`
3
+ * answers `VALIDATION_ERROR` on `field: "note"`; a note field mirrors it as
4
+ * `maxLength` so the buyer never meets that error. */
5
+ export declare const CART_NOTE_MAX_LENGTH = 1000;
6
+ //#endregion
@@ -0,0 +1,7 @@
1
+ //#region src/cart-note.ts
2
+ /** The API's cap on a cart note, in characters. Past it, `cartNoteUpdate`
3
+ * answers `VALIDATION_ERROR` on `field: "note"`; a note field mirrors it as
4
+ * `maxLength` so the buyer never meets that error. */
5
+ const CART_NOTE_MAX_LENGTH = 1e3;
6
+ //#endregion
7
+ export { CART_NOTE_MAX_LENGTH };
@@ -0,0 +1,34 @@
1
+ //#region src/color.d.ts
2
+ export interface Rgba {
3
+ r: number;
4
+ g: number;
5
+ b: number;
6
+ a: number;
7
+ }
8
+ /** Parse a hex or `rgb()`/`rgba()` color. Anything else (a gradient, a CSS
9
+ * keyword, an unresolved ref, an empty setting) → `null`, so callers can fall
10
+ * back rather than emit a broken declaration. */
11
+ export declare function parseColor(input: unknown): Rgba | null;
12
+ /** Shopify's `color_brightness`: the perceived-luminance average, 0–255.
13
+ * Themes compare it against a threshold (Horizon 128, Impact 150) to pick a
14
+ * contrasting foreground. */
15
+ export declare function brightness({ r, g, b }: Rgba): number;
16
+ /** Liquid's `color_contrast`: the WCAG ratio between two colors, 1–21.
17
+ * Horizon compares against 4.5 (the AA threshold for body text) to decide
18
+ * whether the page's own text color is legible on a custom background. */
19
+ export declare function contrastRatio(a: Rgba, b: Rgba): number;
20
+ /** `color_lighten` / `color_darken`: shift HSL lightness by PERCENTAGE POINTS
21
+ * (not a ratio), the way the Liquid filters do. */
22
+ export declare function shiftLightness(color: Rgba, points: number): Rgba;
23
+ /** `"r g b"` — the space-separated triplet a theme's channel vars hold, so
24
+ * consumers can write `rgb(var(--color-foreground-rgb) / var(--opacity-10))`. */
25
+ export declare function rgbTriplet({ r, g, b }: Rgba): string;
26
+ /** `color_mix`: blend `color` into `other`. `weight` is the percentage of
27
+ * `color` kept — 100 returns `color`, 0 returns `other` — mixed channel by
28
+ * channel in sRGB and rounded, which is what reproduces Shopify's documented
29
+ * `'#E800B0' | color_mix: '#00936F', 50` → `#744a90`. */
30
+ export declare function mix(color: Rgba, other: Rgba, weight: number): Rgba;
31
+ /** A complete CSS color. Alpha is emitted only when it isn't opaque, which
32
+ * keeps the common case readable in devtools. */
33
+ export declare function toCss(color: Rgba): string;
34
+ //#endregion
package/dist/color.js ADDED
@@ -0,0 +1,145 @@
1
+ //#region src/color.ts
2
+ const HEX_RE = /^#([0-9a-f]{3,8})$/i;
3
+ const RGB_RE = /^rgba?\(([^)]+)\)$/i;
4
+ function clamp(value, min, max) {
5
+ return value < min ? min : value > max ? max : value;
6
+ }
7
+ /** Parse a hex or `rgb()`/`rgba()` color. Anything else (a gradient, a CSS
8
+ * keyword, an unresolved ref, an empty setting) → `null`, so callers can fall
9
+ * back rather than emit a broken declaration. */
10
+ function parseColor(input) {
11
+ if (typeof input !== "string") return null;
12
+ const value = input.trim();
13
+ if (!value) return null;
14
+ const hex = value.match(HEX_RE)?.[1];
15
+ if (hex) {
16
+ const wide = hex.length <= 4 ? [...hex].map((c) => c + c).join("") : hex;
17
+ if (wide.length !== 6 && wide.length !== 8) return null;
18
+ const int = Number.parseInt(wide, 16);
19
+ if (Number.isNaN(int)) return null;
20
+ return wide.length === 6 ? {
21
+ r: int >> 16 & 255,
22
+ g: int >> 8 & 255,
23
+ b: int & 255,
24
+ a: 1
25
+ } : {
26
+ r: int >>> 24 & 255,
27
+ g: int >>> 16 & 255,
28
+ b: int >>> 8 & 255,
29
+ a: Math.round((int & 255) / 255 * 1e3) / 1e3
30
+ };
31
+ }
32
+ const parts = value.match(RGB_RE)?.[1];
33
+ if (!parts) return null;
34
+ const nums = parts.split(/[\s,/]+/).filter(Boolean).map(Number);
35
+ if (nums.length < 3 || nums.some(Number.isNaN)) return null;
36
+ return {
37
+ r: clamp(Math.round(nums[0]), 0, 255),
38
+ g: clamp(Math.round(nums[1]), 0, 255),
39
+ b: clamp(Math.round(nums[2]), 0, 255),
40
+ a: nums[3] === void 0 ? 1 : clamp(nums[3], 0, 1)
41
+ };
42
+ }
43
+ /** Shopify's `color_brightness`: the perceived-luminance average, 0–255.
44
+ * Themes compare it against a threshold (Horizon 128, Impact 150) to pick a
45
+ * contrasting foreground. */
46
+ function brightness({ r, g, b }) {
47
+ return (r * 299 + g * 587 + b * 114) / 1e3;
48
+ }
49
+ function channelLuminance(value) {
50
+ const c = value / 255;
51
+ return c <= .03928 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4;
52
+ }
53
+ /** WCAG relative luminance. Distinct from `brightness()`, which is Shopify's
54
+ * cheap perceptual average — this one is what a contrast RATIO needs. */
55
+ function relativeLuminance({ r, g, b }) {
56
+ return .2126 * channelLuminance(r) + .7152 * channelLuminance(g) + .0722 * channelLuminance(b);
57
+ }
58
+ /** Liquid's `color_contrast`: the WCAG ratio between two colors, 1–21.
59
+ * Horizon compares against 4.5 (the AA threshold for body text) to decide
60
+ * whether the page's own text color is legible on a custom background. */
61
+ function contrastRatio(a, b) {
62
+ const la = relativeLuminance(a);
63
+ const lb = relativeLuminance(b);
64
+ const [light, dark] = la > lb ? [la, lb] : [lb, la];
65
+ return (light + .05) / (dark + .05);
66
+ }
67
+ function toHsl({ r, g, b }) {
68
+ const rn = r / 255;
69
+ const gn = g / 255;
70
+ const bn = b / 255;
71
+ const max = Math.max(rn, gn, bn);
72
+ const min = Math.min(rn, gn, bn);
73
+ const l = (max + min) / 2;
74
+ if (max === min) return {
75
+ h: 0,
76
+ s: 0,
77
+ l
78
+ };
79
+ const d = max - min;
80
+ const s = l > .5 ? d / (2 - max - min) : d / (max + min);
81
+ return {
82
+ h: max === rn ? ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6 : max === gn ? ((bn - rn) / d + 2) / 6 : ((rn - gn) / d + 4) / 6,
83
+ s,
84
+ l
85
+ };
86
+ }
87
+ function hueToRgb(p, q, t) {
88
+ const tt = t < 0 ? t + 1 : t > 1 ? t - 1 : t;
89
+ if (tt < 1 / 6) return p + (q - p) * 6 * tt;
90
+ if (tt < 1 / 2) return q;
91
+ if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
92
+ return p;
93
+ }
94
+ function fromHsl(h, s, l, a) {
95
+ if (s === 0) {
96
+ const v = Math.round(l * 255);
97
+ return {
98
+ r: v,
99
+ g: v,
100
+ b: v,
101
+ a
102
+ };
103
+ }
104
+ const q = l < .5 ? l * (1 + s) : l + s - l * s;
105
+ const p = 2 * l - q;
106
+ return {
107
+ r: Math.round(hueToRgb(p, q, h + 1 / 3) * 255),
108
+ g: Math.round(hueToRgb(p, q, h) * 255),
109
+ b: Math.round(hueToRgb(p, q, h - 1 / 3) * 255),
110
+ a
111
+ };
112
+ }
113
+ /** `color_lighten` / `color_darken`: shift HSL lightness by PERCENTAGE POINTS
114
+ * (not a ratio), the way the Liquid filters do. */
115
+ function shiftLightness(color, points) {
116
+ const { h, s, l } = toHsl(color);
117
+ return fromHsl(h, s, clamp(l + points / 100, 0, 1), color.a);
118
+ }
119
+ /** `"r g b"` — the space-separated triplet a theme's channel vars hold, so
120
+ * consumers can write `rgb(var(--color-foreground-rgb) / var(--opacity-10))`. */
121
+ function rgbTriplet({ r, g, b }) {
122
+ return `${r} ${g} ${b}`;
123
+ }
124
+ /** `color_mix`: blend `color` into `other`. `weight` is the percentage of
125
+ * `color` kept — 100 returns `color`, 0 returns `other` — mixed channel by
126
+ * channel in sRGB and rounded, which is what reproduces Shopify's documented
127
+ * `'#E800B0' | color_mix: '#00936F', 50` → `#744a90`. */
128
+ function mix(color, other, weight) {
129
+ const w = clamp(weight, 0, 100) / 100;
130
+ const blend = (a, b) => Math.round(a * w + b * (1 - w));
131
+ return {
132
+ r: blend(color.r, other.r),
133
+ g: blend(color.g, other.g),
134
+ b: blend(color.b, other.b),
135
+ a: Math.round((color.a * w + other.a * (1 - w)) * 1e3) / 1e3
136
+ };
137
+ }
138
+ /** A complete CSS color. Alpha is emitted only when it isn't opaque, which
139
+ * keeps the common case readable in devtools. */
140
+ function toCss(color) {
141
+ const { r, g, b, a } = color;
142
+ return a >= 1 ? `rgb(${r} ${g} ${b})` : `rgb(${r} ${g} ${b} / ${a})`;
143
+ }
144
+ //#endregion
145
+ export { brightness, contrastRatio, mix, parseColor, rgbTriplet, shiftLightness, toCss };
@@ -0,0 +1,75 @@
1
+ //#region src/fonts.d.ts
2
+ export type FontFormat = "woff2" | "woff" | "truetype" | "opentype";
3
+ export type UploadedFace = {
4
+ url: string;
5
+ format: FontFormat;
6
+ byteSize: number;
7
+ weightMin: number;
8
+ weightMax: number;
9
+ style: "normal" | "italic";
10
+ };
11
+ export type UploadedFont = {
12
+ /** The family's node id, exactly as the setting stores it. */
13
+ id: string;
14
+ faces: UploadedFace[];
15
+ };
16
+ /**
17
+ * What `font-family` names an uploaded family by, in the `@font-face` rules
18
+ * and in a theme's font stack alike. Never the merchant's own name for it: a
19
+ * family they called "Inter" must not merge into Google's. Null when the id is
20
+ * not the bare token the API returns, and the theme then uses its fallback.
21
+ */
22
+ export declare function uploadedFontFamily(nodeId: string): string | null;
23
+ /** The `format()` hint for a stored content type, or null for one that is not
24
+ * a font the library accepts. */
25
+ export declare function fontFormat(contentType: string): FontFormat | null;
26
+ /**
27
+ * A face is declared only when every descriptor is one CSS can take as is.
28
+ * Takes anything: a face read off a settings document is not known to be an
29
+ * object, let alone a face.
30
+ */
31
+ export declare function isRenderableFace(value: unknown): value is UploadedFace;
32
+ /** One `@font-face` per face of every family, each family once. */
33
+ export declare function fontFaceCss(fonts: UploadedFont[]): string;
34
+ export type FontPreload = {
35
+ href: string;
36
+ type: string;
37
+ };
38
+ /**
39
+ * One `<link rel="preload">` per target: the family's upright face that draws
40
+ * the target's weight, spelled as its `@font-face` rule spells it. A family
41
+ * with no such face gets none, and the browser picks the nearest one itself,
42
+ * as CSS specifies. Nor does a file over 300 KB: a preload outranks the page's
43
+ * images, and `font-display: swap` keeps the text readable while a large file
44
+ * arrives. Two targets that land on one file preload it once.
45
+ */
46
+ export declare function fontPreloads(targets: ReadonlyArray<{
47
+ font: UploadedFont;
48
+ weight: number;
49
+ }>): FontPreload[];
50
+ export type FontSetting = {
51
+ source: "named";
52
+ family: string;
53
+ } | {
54
+ source: "uploaded";
55
+ family: string;
56
+ font: UploadedFont;
57
+ };
58
+ /**
59
+ * What a theme-wide `font_picker` setting draws in.
60
+ *
61
+ * `named` is the value as stored: a Google family, or a keyword the theme
62
+ * treats as a system font. `uploaded` is a family of the shop's own, under the
63
+ * `font-family` name `fontFaceCss` declares it by, read from the `${id}Data`
64
+ * sibling the loader resolved.
65
+ *
66
+ * Null is "use the theme's fallback": nothing is picked, the value could not
67
+ * be a family name, or the setting holds the id of a family that is deleted,
68
+ * was never resolved, or is not the one beside it. An id is never `named`, in
69
+ * either of its spellings (`nodeGid`), so it cannot reach a Google Fonts URL.
70
+ */
71
+ export declare function fontSetting(settings: Record<string, unknown>, id: string): FontSetting | null;
72
+ /** The uploaded families the named settings draw in, each once: what
73
+ * `fontFaceCss` declares. */
74
+ export declare function uploadedFonts(settings: Record<string, unknown>, ids: readonly string[]): UploadedFont[];
75
+ //#endregion
package/dist/fonts.js ADDED
@@ -0,0 +1,137 @@
1
+ import { isNodeIdToken, nodeGid } from "@shop-on/theme-contract";
2
+ //#region src/fonts.ts
3
+ /**
4
+ * What `font-family` names an uploaded family by, in the `@font-face` rules
5
+ * and in a theme's font stack alike. Never the merchant's own name for it: a
6
+ * family they called "Inter" must not merge into Google's. Null when the id is
7
+ * not the bare token the API returns, and the theme then uses its fallback.
8
+ */
9
+ function uploadedFontFamily(nodeId) {
10
+ return isNodeIdToken(nodeId) ? `uploaded-${nodeId}` : null;
11
+ }
12
+ const FORMATS = /* @__PURE__ */ new Map([
13
+ ["font/woff2", "woff2"],
14
+ ["font/woff", "woff"],
15
+ ["font/ttf", "truetype"],
16
+ ["font/otf", "opentype"]
17
+ ]);
18
+ const FORMAT_NAMES = new Set(FORMATS.values());
19
+ /** The `format()` hint for a stored content type, or null for one that is not
20
+ * a font the library accepts. */
21
+ function fontFormat(contentType) {
22
+ return FORMATS.get(contentType) ?? null;
23
+ }
24
+ const isWeight = (n) => typeof n === "number" && Number.isInteger(n) && n >= 1 && n <= 1e3;
25
+ /**
26
+ * A face is declared only when every descriptor is one CSS can take as is.
27
+ * Takes anything: a face read off a settings document is not known to be an
28
+ * object, let alone a face.
29
+ */
30
+ function isRenderableFace(value) {
31
+ if (typeof value !== "object" || value === null) return false;
32
+ const face = value;
33
+ return isWeight(face.weightMin) && isWeight(face.weightMax) && face.weightMin <= face.weightMax && (face.style === "normal" || face.style === "italic") && typeof face.format === "string" && FORMAT_NAMES.has(face.format) && typeof face.url === "string" && faceHref(face.url) !== null;
34
+ }
35
+ function faceHref(raw) {
36
+ let url;
37
+ try {
38
+ url = new URL(raw);
39
+ } catch {
40
+ return null;
41
+ }
42
+ return url.protocol === "https:" || url.protocol === "http:" ? url.href : null;
43
+ }
44
+ function cssUrl(raw) {
45
+ const href = faceHref(raw);
46
+ return href === null ? null : `url("${href.replace(/["\\]/g, "\\$&")}")`;
47
+ }
48
+ function byStyleThenWeight(a, b) {
49
+ if (a.style !== b.style) return a.style === "normal" ? -1 : 1;
50
+ return a.weightMin - b.weightMin || a.weightMax - b.weightMax;
51
+ }
52
+ /** One `@font-face` per face of every family, each family once. */
53
+ function fontFaceCss(fonts) {
54
+ const seen = /* @__PURE__ */ new Set();
55
+ const rules = [];
56
+ for (const font of fonts) {
57
+ const family = uploadedFontFamily(font.id);
58
+ if (!family || seen.has(family)) continue;
59
+ seen.add(family);
60
+ for (const face of font.faces.filter(isRenderableFace).sort(byStyleThenWeight)) {
61
+ const weight = face.weightMin === face.weightMax ? `${face.weightMin}` : `${face.weightMin} ${face.weightMax}`;
62
+ rules.push(`@font-face{font-family:"${family}";src:${cssUrl(face.url)} format("${face.format}");font-weight:${weight};font-style:${face.style === "italic" ? "italic" : "normal"};font-display:swap}`);
63
+ }
64
+ }
65
+ return rules.join("\n");
66
+ }
67
+ const PRELOAD_MAX_BYTES = 307200;
68
+ const MEDIA_TYPES = new Map([...FORMATS].map(([type, format]) => [format, type]));
69
+ /**
70
+ * One `<link rel="preload">` per target: the family's upright face that draws
71
+ * the target's weight, spelled as its `@font-face` rule spells it. A family
72
+ * with no such face gets none, and the browser picks the nearest one itself,
73
+ * as CSS specifies. Nor does a file over 300 KB: a preload outranks the page's
74
+ * images, and `font-display: swap` keeps the text readable while a large file
75
+ * arrives. Two targets that land on one file preload it once.
76
+ */
77
+ function fontPreloads(targets) {
78
+ const preloads = /* @__PURE__ */ new Map();
79
+ for (const { font, weight } of targets) {
80
+ const face = font.faces.find((f) => isRenderableFace(f) && f.style === "normal" && f.weightMin <= weight && weight <= f.weightMax && f.byteSize <= PRELOAD_MAX_BYTES);
81
+ const href = face ? faceHref(face.url) : null;
82
+ const type = face ? MEDIA_TYPES.get(face.format) : void 0;
83
+ if (href && type) preloads.set(href, {
84
+ href,
85
+ type
86
+ });
87
+ }
88
+ return [...preloads.values()];
89
+ }
90
+ const NOT_A_FAMILY_NAME = /[<>"\\\n\r\f]/;
91
+ function isUploadedFont(value) {
92
+ if (typeof value !== "object" || value === null) return false;
93
+ const { id, faces } = value;
94
+ return typeof id === "string" && Array.isArray(faces);
95
+ }
96
+ /**
97
+ * What a theme-wide `font_picker` setting draws in.
98
+ *
99
+ * `named` is the value as stored: a Google family, or a keyword the theme
100
+ * treats as a system font. `uploaded` is a family of the shop's own, under the
101
+ * `font-family` name `fontFaceCss` declares it by, read from the `${id}Data`
102
+ * sibling the loader resolved.
103
+ *
104
+ * Null is "use the theme's fallback": nothing is picked, the value could not
105
+ * be a family name, or the setting holds the id of a family that is deleted,
106
+ * was never resolved, or is not the one beside it. An id is never `named`, in
107
+ * either of its spellings (`nodeGid`), so it cannot reach a Google Fonts URL.
108
+ */
109
+ function fontSetting(settings, id) {
110
+ const raw = settings[id];
111
+ const value = typeof raw === "string" ? raw.trim() : "";
112
+ if (!value) return null;
113
+ if (nodeGid(value) === null) return NOT_A_FAMILY_NAME.test(value) ? null : {
114
+ source: "named",
115
+ family: value
116
+ };
117
+ const family = uploadedFontFamily(value);
118
+ if (family === null) return null;
119
+ const font = settings[`${id}Data`];
120
+ return isUploadedFont(font) && font.id === value ? {
121
+ source: "uploaded",
122
+ family,
123
+ font
124
+ } : null;
125
+ }
126
+ /** The uploaded families the named settings draw in, each once: what
127
+ * `fontFaceCss` declares. */
128
+ function uploadedFonts(settings, ids) {
129
+ const fonts = /* @__PURE__ */ new Map();
130
+ for (const id of ids) {
131
+ const setting = fontSetting(settings, id);
132
+ if (setting?.source === "uploaded") fonts.set(setting.font.id, setting.font);
133
+ }
134
+ return [...fonts.values()];
135
+ }
136
+ //#endregion
137
+ export { fontFaceCss, fontFormat, fontPreloads, fontSetting, isRenderableFace, uploadedFontFamily, uploadedFonts };
@@ -0,0 +1,25 @@
1
+ import { Money, currencySymbol, formatMoney, formatMoneyWithCurrency } from "./money.js";
2
+ import { initials } from "./initials.js";
3
+ import { escapeForScriptTag } from "./json-ld-escape.js";
4
+ import { defaultPriceMaxBound } from "./listing-price.js";
5
+ import { pageCursor } from "./page-cursor.js";
6
+ import { Embed, EmbedKind, PlayerEmbed, embedBackgroundSrc, parseEmbedUrl } from "./parse-embed-url.js";
7
+ import { LinkedMetafieldRef, LinkedMetaobject, LinkedMetaobjectField, OptionAvailability, ProductOption, ProductOptionValue, ProductVariant, SelectedOption, SwatchVariant, VariantState } from "./product-variants/types.js";
8
+ import { buildAvailabilityIndex } from "./product-variants/build-availability-index.js";
9
+ import { ProductForVariantState, buildVariantState } from "./product-variants/build-variant-state.js";
10
+ import { findClosestVariant } from "./product-variants/find-closest-variant.js";
11
+ import { isDefaultSingleVariant } from "./product-variants/is-default-single-variant.js";
12
+ import { isVariantSelectable } from "./product-variants/is-variant-selectable.js";
13
+ import { ResolvedVariant, resolveSelectedVariant } from "./product-variants/resolve-selected-variant.js";
14
+ import { selectionKey } from "./product-variants/selection-key.js";
15
+ import { ArticleSchemaSource, BlockInstance, BreadcrumbSchemaSource, ProductSchemaSource, SectionGroupConfig, TemplateJSON, TemplateSeoSource, WebsiteSchemaSource } from "./template-types.js";
16
+ import { staleVariantFlag } from "./product-variants/stale-variant.js";
17
+ import { SwatchSource, isSwatchOption, swatchSource } from "./product-variants/swatch-source.js";
18
+ import { VariantPriceRange, variantPriceRange } from "./product-variants/variant-price-range.js";
19
+ import "./product-variants/index.js";
20
+ import { productHref } from "./product-href.js";
21
+ import { isOnSale, savePercent } from "./sale.js";
22
+ import { BuildSeoHeadArgs, SEO_DESCRIPTION_MAX_LENGTH, SeoHead, buildSeoHead, canonicalPath, isNarrowedListing, truncateSeoDescription } from "./seo.js";
23
+ import { stripPostAuthParams } from "./strip-handoff-url.js";
24
+ import { thumbhashDataUrl } from "./thumbhash.js";
25
+ export { ArticleSchemaSource, BlockInstance, BreadcrumbSchemaSource, BuildSeoHeadArgs, Embed, EmbedKind, type LinkedMetafieldRef, type LinkedMetaobject, type LinkedMetaobjectField, Money, type OptionAvailability, PlayerEmbed, type ProductForVariantState, type ProductOption, type ProductOptionValue, ProductSchemaSource, type ProductVariant, type ResolvedVariant, SEO_DESCRIPTION_MAX_LENGTH, SectionGroupConfig, type SelectedOption, SeoHead, type SwatchSource, type SwatchVariant, TemplateJSON, TemplateSeoSource, type VariantPriceRange, type VariantState, WebsiteSchemaSource, buildAvailabilityIndex, buildSeoHead, buildVariantState, canonicalPath, currencySymbol, defaultPriceMaxBound, embedBackgroundSrc, escapeForScriptTag, findClosestVariant, formatMoney, formatMoneyWithCurrency, initials, isDefaultSingleVariant, isNarrowedListing, isOnSale, isSwatchOption, isVariantSelectable, pageCursor, parseEmbedUrl, productHref, resolveSelectedVariant, savePercent, selectionKey, staleVariantFlag, stripPostAuthParams, swatchSource, thumbhashDataUrl, truncateSeoDescription, variantPriceRange };
package/dist/index.js ADDED
@@ -0,0 +1,22 @@
1
+ import { initials } from "./initials.js";
2
+ import { escapeForScriptTag } from "./json-ld-escape.js";
3
+ import { defaultPriceMaxBound } from "./listing-price.js";
4
+ import { currencySymbol, formatMoney, formatMoneyWithCurrency } from "./money.js";
5
+ import { pageCursor } from "./page-cursor.js";
6
+ import { embedBackgroundSrc, parseEmbedUrl } from "./parse-embed-url.js";
7
+ import { buildAvailabilityIndex } from "./product-variants/build-availability-index.js";
8
+ import { resolveSelectedVariant } from "./product-variants/resolve-selected-variant.js";
9
+ import { buildVariantState } from "./product-variants/build-variant-state.js";
10
+ import { findClosestVariant } from "./product-variants/find-closest-variant.js";
11
+ import { isDefaultSingleVariant } from "./product-variants/is-default-single-variant.js";
12
+ import { isVariantSelectable } from "./product-variants/is-variant-selectable.js";
13
+ import { selectionKey } from "./product-variants/selection-key.js";
14
+ import { staleVariantFlag } from "./product-variants/stale-variant.js";
15
+ import { isSwatchOption, swatchSource } from "./product-variants/swatch-source.js";
16
+ import { variantPriceRange } from "./product-variants/variant-price-range.js";
17
+ import { productHref } from "./product-href.js";
18
+ import { isOnSale, savePercent } from "./sale.js";
19
+ import { SEO_DESCRIPTION_MAX_LENGTH, buildSeoHead, canonicalPath, isNarrowedListing, truncateSeoDescription } from "./seo.js";
20
+ import { stripPostAuthParams } from "./strip-handoff-url.js";
21
+ import { thumbhashDataUrl } from "./thumbhash.js";
22
+ export { SEO_DESCRIPTION_MAX_LENGTH, buildAvailabilityIndex, buildSeoHead, buildVariantState, canonicalPath, currencySymbol, defaultPriceMaxBound, embedBackgroundSrc, escapeForScriptTag, findClosestVariant, formatMoney, formatMoneyWithCurrency, initials, isDefaultSingleVariant, isNarrowedListing, isOnSale, isSwatchOption, isVariantSelectable, pageCursor, parseEmbedUrl, productHref, resolveSelectedVariant, savePercent, selectionKey, staleVariantFlag, stripPostAuthParams, swatchSource, thumbhashDataUrl, truncateSeoDescription, variantPriceRange };
@@ -0,0 +1,3 @@
1
+ //#region src/initials.d.ts
2
+ export declare function initials(name: string): string;
3
+ //#endregion
@@ -0,0 +1,9 @@
1
+ //#region src/initials.ts
2
+ function initials(name) {
3
+ const words = name.trim().split(/\s+/).filter(Boolean);
4
+ if (words.length === 0) return "?";
5
+ if (words.length === 1) return words[0].slice(0, 1).toUpperCase();
6
+ return (words[0].slice(0, 1) + words[words.length - 1].slice(0, 1)).toUpperCase();
7
+ }
8
+ //#endregion
9
+ export { initials };
@@ -0,0 +1,3 @@
1
+ //#region src/json-ld-escape.d.ts
2
+ export declare function escapeForScriptTag(json: string): string;
3
+ //#endregion
@@ -0,0 +1,6 @@
1
+ //#region src/json-ld-escape.ts
2
+ function escapeForScriptTag(json) {
3
+ return json.replace(/</g, "\\u003c").replace(/>/g, "\\u003e");
4
+ }
5
+ //#endregion
6
+ export { escapeForScriptTag };
@@ -0,0 +1,20 @@
1
+ //#region src/line-groups.d.ts
2
+ export type GroupedLines<T, G> = Array<{
3
+ kind: "line";
4
+ line: T;
5
+ } | {
6
+ kind: "group";
7
+ group: G;
8
+ lines: T[];
9
+ }>;
10
+ /**
11
+ * Folds lines sharing a `lineGroup` into one entry placed where the group's
12
+ * first line was. Lines of one group need not be adjacent: a page boundary or
13
+ * a mixed ordering still lands every component under its bundle.
14
+ */
15
+ export declare function groupLines<T extends {
16
+ lineGroup: {
17
+ id: string;
18
+ } | null;
19
+ }>(lines: readonly T[]): GroupedLines<T, NonNullable<T["lineGroup"]>>;
20
+ //#endregion
@@ -0,0 +1,35 @@
1
+ //#region src/line-groups.ts
2
+ /**
3
+ * Folds lines sharing a `lineGroup` into one entry placed where the group's
4
+ * first line was. Lines of one group need not be adjacent: a page boundary or
5
+ * a mixed ordering still lands every component under its bundle.
6
+ */
7
+ function groupLines(lines) {
8
+ const out = [];
9
+ const byGroup = /* @__PURE__ */ new Map();
10
+ for (const line of lines) {
11
+ const group = line.lineGroup;
12
+ if (!group) {
13
+ out.push({
14
+ kind: "line",
15
+ line
16
+ });
17
+ continue;
18
+ }
19
+ const existing = byGroup.get(group.id);
20
+ if (existing) {
21
+ existing.push(line);
22
+ continue;
23
+ }
24
+ const members = [line];
25
+ byGroup.set(group.id, members);
26
+ out.push({
27
+ kind: "group",
28
+ group,
29
+ lines: members
30
+ });
31
+ }
32
+ return out;
33
+ }
34
+ //#endregion
35
+ export { groupLines };