@shop-on/theme-runtime 0.0.0-stage → 0.1.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/LICENSE.md +39 -0
- package/dist/badge-tags.d.ts +8 -0
- package/dist/badge-tags.js +13 -0
- package/dist/cart-discounts.d.ts +16 -0
- package/dist/cart-discounts.js +21 -0
- package/dist/cart-note.d.ts +6 -0
- package/dist/cart-note.js +7 -0
- package/dist/color.d.ts +34 -0
- package/dist/color.js +145 -0
- package/dist/fonts.d.ts +75 -0
- package/dist/fonts.js +137 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +21 -0
- package/dist/initials.d.ts +3 -0
- package/dist/initials.js +9 -0
- package/dist/json-ld-escape.d.ts +3 -0
- package/dist/json-ld-escape.js +6 -0
- package/dist/line-groups.d.ts +20 -0
- package/dist/line-groups.js +35 -0
- package/dist/line-item-attributes.d.ts +44 -0
- package/dist/line-item-attributes.js +82 -0
- package/dist/listing-filters.d.ts +88 -0
- package/dist/listing-filters.js +206 -0
- package/dist/listing-price.d.ts +3 -0
- package/dist/listing-price.js +13 -0
- package/dist/money.d.ts +16 -0
- package/dist/money.js +51 -0
- package/dist/page-cursor.d.ts +20 -0
- package/dist/page-cursor.js +24 -0
- package/dist/parse-embed-url.d.ts +28 -0
- package/dist/parse-embed-url.js +71 -0
- package/dist/playable-video.d.ts +6 -0
- package/dist/playable-video.js +6 -0
- package/dist/product-href.d.ts +3 -0
- package/dist/product-href.js +8 -0
- package/dist/product-variants/build-availability-index.d.ts +4 -0
- package/dist/product-variants/build-availability-index.js +25 -0
- package/dist/product-variants/build-variant-state.d.ts +9 -0
- package/dist/product-variants/build-variant-state.js +16 -0
- package/dist/product-variants/find-closest-variant.d.ts +4 -0
- package/dist/product-variants/find-closest-variant.js +26 -0
- package/dist/product-variants/index.d.ts +11 -0
- package/dist/product-variants/index.js +10 -0
- package/dist/product-variants/is-default-single-variant.d.ts +4 -0
- package/dist/product-variants/is-default-single-variant.js +8 -0
- package/dist/product-variants/is-variant-selectable.d.ts +4 -0
- package/dist/product-variants/is-variant-selectable.js +22 -0
- package/dist/product-variants/resolve-selected-variant.d.ts +8 -0
- package/dist/product-variants/resolve-selected-variant.js +18 -0
- package/dist/product-variants/selection-key.d.ts +4 -0
- package/dist/product-variants/selection-key.js +7 -0
- package/dist/product-variants/swatch-source.d.ts +12 -0
- package/dist/product-variants/swatch-source.js +22 -0
- package/dist/product-variants/types.d.ts +59 -0
- package/dist/product-variants/variant-price-range.d.ts +27 -0
- package/dist/product-variants/variant-price-range.js +38 -0
- package/dist/richtext.d.ts +3 -0
- package/dist/richtext.js +145 -0
- package/dist/sale.d.ts +5 -0
- package/dist/sale.js +10 -0
- package/dist/seo.d.ts +97 -0
- package/dist/seo.js +119 -0
- package/dist/strip-handoff-url.d.ts +3 -0
- package/dist/strip-handoff-url.js +10 -0
- package/dist/template-types.d.ts +138 -0
- package/dist/template-types.js +0 -0
- package/dist/thumbhash.d.ts +3 -0
- package/dist/thumbhash.js +23 -0
- package/package.json +46 -3
- package/README.md +0 -3
package/dist/LICENSE.md
ADDED
|
@@ -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,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 };
|
package/dist/color.d.ts
ADDED
|
@@ -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 };
|
package/dist/fonts.d.ts
ADDED
|
@@ -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 };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
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 { SwatchSource, isSwatchOption, swatchSource } from "./product-variants/swatch-source.js";
|
|
16
|
+
import { VariantPriceRange, variantPriceRange } from "./product-variants/variant-price-range.js";
|
|
17
|
+
import { productHref } from "./product-href.js";
|
|
18
|
+
import { isOnSale, savePercent } from "./sale.js";
|
|
19
|
+
import { BuildSeoHeadArgs, SEO_DESCRIPTION_MAX_LENGTH, SeoHead, buildSeoHead, canonicalPath, isNarrowedListing, truncateSeoDescription } from "./seo.js";
|
|
20
|
+
import { stripPostAuthParams } from "./strip-handoff-url.js";
|
|
21
|
+
import { ArticleSchemaSource, BlockInstance, BreadcrumbSchemaSource, ProductSchemaSource, SectionGroupConfig, TemplateJSON, TemplateSeoSource, WebsiteSchemaSource } from "./template-types.js";
|
|
22
|
+
import { thumbhashDataUrl } from "./thumbhash.js";
|
|
23
|
+
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, stripPostAuthParams, swatchSource, thumbhashDataUrl, truncateSeoDescription, variantPriceRange };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
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 { isSwatchOption, swatchSource } from "./product-variants/swatch-source.js";
|
|
15
|
+
import { variantPriceRange } from "./product-variants/variant-price-range.js";
|
|
16
|
+
import { productHref } from "./product-href.js";
|
|
17
|
+
import { isOnSale, savePercent } from "./sale.js";
|
|
18
|
+
import { SEO_DESCRIPTION_MAX_LENGTH, buildSeoHead, canonicalPath, isNarrowedListing, truncateSeoDescription } from "./seo.js";
|
|
19
|
+
import { stripPostAuthParams } from "./strip-handoff-url.js";
|
|
20
|
+
import { thumbhashDataUrl } from "./thumbhash.js";
|
|
21
|
+
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, stripPostAuthParams, swatchSource, thumbhashDataUrl, truncateSeoDescription, variantPriceRange };
|
package/dist/initials.js
ADDED
|
@@ -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,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 };
|