@shop-on/theme-react 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.
Files changed (41) hide show
  1. package/dist/LICENSE.md +39 -0
  2. package/dist/analytics.d.ts +100 -0
  3. package/dist/analytics.js +250 -0
  4. package/dist/article-json-ld.d.ts +7 -0
  5. package/dist/article-json-ld.js +33 -0
  6. package/dist/breadcrumb-json-ld.d.ts +7 -0
  7. package/dist/breadcrumb-json-ld.js +24 -0
  8. package/dist/custom-css.d.ts +17 -0
  9. package/dist/custom-css.js +29 -0
  10. package/dist/editor-preview-bridge-utils.d.ts +32 -0
  11. package/dist/editor-preview-bridge-utils.js +85 -0
  12. package/dist/editor-preview-bridge.d.ts +8 -0
  13. package/dist/editor-preview-bridge.js +319 -0
  14. package/dist/faq-json-ld.d.ts +11 -0
  15. package/dist/faq-json-ld.js +25 -0
  16. package/dist/hreflang-links.d.ts +12 -0
  17. package/dist/hreflang-links.js +51 -0
  18. package/dist/index.d.ts +12 -0
  19. package/dist/index.js +12 -0
  20. package/dist/link-utils.d.ts +5 -0
  21. package/dist/link-utils.js +8 -0
  22. package/dist/preview-guard.d.ts +6 -0
  23. package/dist/preview-guard.js +15 -0
  24. package/dist/preview-search-middleware.d.ts +3 -0
  25. package/dist/preview-search-middleware.js +11 -0
  26. package/dist/product-json-ld.d.ts +45 -0
  27. package/dist/product-json-ld.js +50 -0
  28. package/dist/template-renderer.d.ts +75 -0
  29. package/dist/template-renderer.js +207 -0
  30. package/dist/turnstile-widget.d.ts +39 -0
  31. package/dist/turnstile-widget.js +84 -0
  32. package/dist/uploaded-font-faces.d.ts +7 -0
  33. package/dist/uploaded-font-faces.js +24 -0
  34. package/dist/use-iso-layout-effect.d.ts +4 -0
  35. package/dist/use-iso-layout-effect.js +5 -0
  36. package/dist/website-json-ld.d.ts +7 -0
  37. package/dist/website-json-ld.js +28 -0
  38. package/dist/when-near-viewport.d.ts +45 -0
  39. package/dist/when-near-viewport.js +59 -0
  40. package/package.json +52 -3
  41. 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,100 @@
1
+ //#region src/analytics.d.ts
2
+ export type EventProperties = Record<string, unknown>;
3
+ export declare function trackVisit(properties?: EventProperties): Promise<void>;
4
+ /** Every event carries a per-event `id`. Rails forwards it as the Meta CAPI
5
+ * `event_id`, which is the dedup key for a job retry (and, later, for a
6
+ * browser-side twin of a server event). */
7
+ export declare function track(name: string, properties?: EventProperties): Promise<void>;
8
+ /** Idempotent per page load — calls `trackVisit` only once even if several
9
+ * components mount concurrently. Every tracking hook calls it first, so the
10
+ * visit exists before any event references it. */
11
+ export declare function ensureVisit(properties?: EventProperties): void;
12
+ /** Storefront product ids arrive as stringified integer primary keys. Every
13
+ * aggregate downstream casts `(properties->>'<key>')::int`, so an unparseable
14
+ * id is dropped at the edge rather than written as a row that poisons a
15
+ * window. */
16
+ export declare function toInt(value: unknown): number | null;
17
+ /** The discovery surfaces that emit `product_list_viewed` / `product_clicked`.
18
+ * The recommendation rail is deliberately NOT one — it keeps its own
19
+ * `recommendation_shown` / `recommendation_clicked` pair, so the two event
20
+ * families stay mutually exclusive by surface and nothing double-counts. */
21
+ export type ProductListType = "collection" | "search";
22
+ /** Fires `product_added_to_cart` after a successful add. Wired once inside
23
+ * `use-cart`, so every add surface (buy buttons, quick add) is covered. */
24
+ export declare function trackProductAddedToCart(input: {
25
+ productId: string | number | null | undefined;
26
+ variantId: string | number | null | undefined;
27
+ quantity: number;
28
+ }): void;
29
+ /** Fires `product_removed_from_cart` on line removal — the funnel-drop signal
30
+ * symmetric to the add event, so it carries the same join keys. */
31
+ export declare function trackProductRemovedFromCart(input: {
32
+ productId: string | number | null | undefined;
33
+ variantId: string | number | null | undefined;
34
+ quantity: number;
35
+ }): void;
36
+ /** Fires `checkout_started` on the cart→checkout handoff. Returns the promise
37
+ * so the caller can await it before the full-document hop: `keepalive` POSTs
38
+ * have historically been dropped by Safari across cross-document navigation,
39
+ * and this is the conversion signal. `beacon` swallows its own errors, so
40
+ * awaiting is safe. */
41
+ export declare function trackCheckoutStarted(input: {
42
+ totalQuantity: number;
43
+ subtotalAmount: number;
44
+ currency: string;
45
+ }): Promise<void>;
46
+ export declare function trackSearchSubmitted(input: {
47
+ query: string;
48
+ totalCount: number;
49
+ }): void;
50
+ export declare function trackCartViewed(input: {
51
+ lineCount: number;
52
+ totalQuantity: number;
53
+ subtotalAmount: number;
54
+ currency: string;
55
+ }): void;
56
+ /** Fires `product_clicked` (GA4 `select_item`) on click-through from a card in
57
+ * a list. `position` is the card's index within the rendered page. */
58
+ export declare function trackProductClicked(input: {
59
+ productId: string | number | null | undefined;
60
+ productHandle?: string;
61
+ listType: ProductListType;
62
+ listId?: string | number | null;
63
+ position?: number | null;
64
+ }): void;
65
+ /** Fires `product_list_viewed` (GA4 `view_item_list`) once a list scrolls into
66
+ * view, carrying the rendered items and their positions so a funnel can
67
+ * compute impression→click→ATC by position. Items with an unparseable id are
68
+ * dropped; the event is skipped entirely when nothing survives. */
69
+ export declare function trackProductListViewed(input: {
70
+ listType: ProductListType;
71
+ listId?: string | number | null;
72
+ listHandle?: string;
73
+ totalCount?: number;
74
+ items: {
75
+ productId: string | number | null | undefined;
76
+ position: number;
77
+ }[];
78
+ }): void;
79
+ /** One card of a recommendation rail: the product the rail recommends for,
80
+ * the one it recommended, why, and where in the rail. */
81
+ export type RecommendationAttribution = {
82
+ referenceProductId: string | number | null | undefined;
83
+ referenceProductHandle: string;
84
+ recommendedProductId: string | number | null | undefined;
85
+ recommendedProductHandle: string;
86
+ intent: "RELATED" | "COMPLEMENTARY";
87
+ position: number;
88
+ };
89
+ /** Fires `recommendation_shown` for a rail card in view. It carries no
90
+ * `product_id` on purpose: "shown" is not a positive engagement signal, so
91
+ * the trainer keeps ignoring it. The pair is separate from the listing's
92
+ * `product_list_viewed` / `product_clicked`, so a rail impression never
93
+ * double-counts as a list impression. */
94
+ export declare function trackRecommendationShown(attribution: RecommendationAttribution): void;
95
+ /** Fires `recommendation_clicked` for a click through to a rail card's product.
96
+ * `product_id` mirrors `recommended_product_id` so the trainer accepts the
97
+ * click as a positive signal for the product the buyer chose; the attribution
98
+ * fields stay distinct for analytics queries. */
99
+ export declare function trackRecommendationClicked(attribution: RecommendationAttribution): void;
100
+ //#endregion
@@ -0,0 +1,250 @@
1
+ //#region src/analytics.ts
2
+ const VISIT_KEY = "ahoy_visit";
3
+ const VISITOR_KEY = "ahoy_visitor";
4
+ const VISIT_TTL_MS = 144e5;
5
+ const VISITOR_TTL_MS = 63072e6;
6
+ function isBrowser() {
7
+ return typeof window !== "undefined" && typeof localStorage !== "undefined";
8
+ }
9
+ function isPreview() {
10
+ try {
11
+ return new URLSearchParams(window.location.search).has("preview");
12
+ } catch {
13
+ return false;
14
+ }
15
+ }
16
+ function readToken(key) {
17
+ if (!isBrowser()) return null;
18
+ try {
19
+ const raw = localStorage.getItem(key);
20
+ if (!raw) return null;
21
+ const parsed = JSON.parse(raw);
22
+ if (!parsed?.token || !parsed.expiresAt || parsed.expiresAt < Date.now()) return null;
23
+ return parsed;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+ function writeToken(key, token, ttlMs) {
29
+ if (!isBrowser()) return;
30
+ try {
31
+ localStorage.setItem(key, JSON.stringify({
32
+ token,
33
+ expiresAt: Date.now() + ttlMs
34
+ }));
35
+ } catch {}
36
+ }
37
+ function newToken() {
38
+ try {
39
+ return crypto.randomUUID();
40
+ } catch {
41
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
42
+ }
43
+ }
44
+ function getVisitToken() {
45
+ const existing = readToken(VISIT_KEY);
46
+ if (existing) {
47
+ writeToken(VISIT_KEY, existing.token, VISIT_TTL_MS);
48
+ return existing.token;
49
+ }
50
+ const token = newToken();
51
+ writeToken(VISIT_KEY, token, VISIT_TTL_MS);
52
+ return token;
53
+ }
54
+ function getVisitorToken() {
55
+ const existing = readToken(VISITOR_KEY);
56
+ if (existing) return existing.token;
57
+ const token = newToken();
58
+ writeToken(VISITOR_KEY, token, VISITOR_TTL_MS);
59
+ return token;
60
+ }
61
+ async function beacon(path, body) {
62
+ if (!isBrowser() || isPreview()) return;
63
+ try {
64
+ await fetch(path, {
65
+ method: "POST",
66
+ headers: {
67
+ "Content-Type": "application/json",
68
+ "Ahoy-Visit": getVisitToken(),
69
+ "Ahoy-Visitor": getVisitorToken()
70
+ },
71
+ body: JSON.stringify(body),
72
+ keepalive: true,
73
+ credentials: "same-origin"
74
+ });
75
+ } catch {}
76
+ }
77
+ async function trackVisit(properties) {
78
+ await beacon("/analytics/visits", {
79
+ started_at: (/* @__PURE__ */ new Date()).toISOString(),
80
+ ...properties
81
+ });
82
+ }
83
+ /** Every event carries a per-event `id`. Rails forwards it as the Meta CAPI
84
+ * `event_id`, which is the dedup key for a job retry (and, later, for a
85
+ * browser-side twin of a server event). */
86
+ async function track(name, properties = {}) {
87
+ await beacon("/analytics/events", { events: [{
88
+ name,
89
+ properties,
90
+ id: newToken(),
91
+ time: (/* @__PURE__ */ new Date()).toISOString()
92
+ }] });
93
+ }
94
+ let visitTrackedThisLoad = false;
95
+ /** Idempotent per page load — calls `trackVisit` only once even if several
96
+ * components mount concurrently. Every tracking hook calls it first, so the
97
+ * visit exists before any event references it. */
98
+ function ensureVisit(properties) {
99
+ if (!isBrowser() || visitTrackedThisLoad) return;
100
+ visitTrackedThisLoad = true;
101
+ trackVisit(properties);
102
+ }
103
+ /** Storefront product ids arrive as stringified integer primary keys. Every
104
+ * aggregate downstream casts `(properties->>'<key>')::int`, so an unparseable
105
+ * id is dropped at the edge rather than written as a row that poisons a
106
+ * window. */
107
+ function toInt(value) {
108
+ if (typeof value === "number" && Number.isInteger(value)) return value;
109
+ const parsed = Number.parseInt(String(value ?? ""), 10);
110
+ return Number.isInteger(parsed) ? parsed : null;
111
+ }
112
+ /** Fires `product_added_to_cart` after a successful add. Wired once inside
113
+ * `use-cart`, so every add surface (buy buttons, quick add) is covered. */
114
+ function trackProductAddedToCart(input) {
115
+ const productId = toInt(input.productId);
116
+ if (productId === null) return;
117
+ const variantId = toInt(input.variantId);
118
+ track("product_added_to_cart", {
119
+ product_id: productId,
120
+ ...variantId !== null ? { variant_id: variantId } : {},
121
+ quantity: input.quantity
122
+ });
123
+ }
124
+ /** Fires `product_removed_from_cart` on line removal — the funnel-drop signal
125
+ * symmetric to the add event, so it carries the same join keys. */
126
+ function trackProductRemovedFromCart(input) {
127
+ const productId = toInt(input.productId);
128
+ if (productId === null) return;
129
+ const variantId = toInt(input.variantId);
130
+ track("product_removed_from_cart", {
131
+ product_id: productId,
132
+ ...variantId !== null ? { variant_id: variantId } : {},
133
+ quantity: input.quantity
134
+ });
135
+ }
136
+ /** Fires `checkout_started` on the cart→checkout handoff. Returns the promise
137
+ * so the caller can await it before the full-document hop: `keepalive` POSTs
138
+ * have historically been dropped by Safari across cross-document navigation,
139
+ * and this is the conversion signal. `beacon` swallows its own errors, so
140
+ * awaiting is safe. */
141
+ function trackCheckoutStarted(input) {
142
+ return track("checkout_started", {
143
+ total_quantity: input.totalQuantity,
144
+ subtotal_amount: input.subtotalAmount,
145
+ currency: input.currency
146
+ });
147
+ }
148
+ /** Fires `search_submitted` once per unique query per page load. Keyed on the
149
+ * query alone, so paginating or refining filters on the same query doesn't
150
+ * refire — only a fresh query does. Blank queries (the empty state) are
151
+ * skipped. */
152
+ const trackedSearches = /* @__PURE__ */ new Set();
153
+ function trackSearchSubmitted(input) {
154
+ const q = input.query.trim();
155
+ if (!q || trackedSearches.has(q)) return;
156
+ trackedSearches.add(q);
157
+ track("search_submitted", {
158
+ query: q,
159
+ total_count: input.totalCount
160
+ });
161
+ }
162
+ /** Fires `cart_viewed` when the drawer opens or the cart page paints.
163
+ * Idempotent across consecutive views of the SAME cart state — reopening the
164
+ * drawer unchanged shouldn't refire. Keyed on `(line_count, total_quantity)`
165
+ * rather than a snapshot of every line, so routine open/close churn doesn't
166
+ * pollute the log. */
167
+ let lastCartViewKey = null;
168
+ function trackCartViewed(input) {
169
+ const key = `${input.lineCount}:${input.totalQuantity}`;
170
+ if (lastCartViewKey === key) return;
171
+ lastCartViewKey = key;
172
+ track("cart_viewed", {
173
+ line_count: input.lineCount,
174
+ total_quantity: input.totalQuantity,
175
+ subtotal_amount: input.subtotalAmount,
176
+ currency: input.currency
177
+ });
178
+ }
179
+ /** Fires `product_clicked` (GA4 `select_item`) on click-through from a card in
180
+ * a list. `position` is the card's index within the rendered page. */
181
+ function trackProductClicked(input) {
182
+ const productId = toInt(input.productId);
183
+ if (productId === null) return;
184
+ const listId = input.listId == null ? null : toInt(input.listId);
185
+ track("product_clicked", {
186
+ product_id: productId,
187
+ ...input.productHandle ? { product_handle: input.productHandle } : {},
188
+ list_type: input.listType,
189
+ ...listId !== null ? { list_id: listId } : {},
190
+ ...input.position != null ? { position: input.position } : {}
191
+ });
192
+ }
193
+ /** Fires `product_list_viewed` (GA4 `view_item_list`) once a list scrolls into
194
+ * view, carrying the rendered items and their positions so a funnel can
195
+ * compute impression→click→ATC by position. Items with an unparseable id are
196
+ * dropped; the event is skipped entirely when nothing survives. */
197
+ function trackProductListViewed(input) {
198
+ const items = input.items.flatMap((it) => {
199
+ const product_id = toInt(it.productId);
200
+ return product_id === null ? [] : [{
201
+ product_id,
202
+ position: it.position
203
+ }];
204
+ });
205
+ if (items.length === 0) return;
206
+ const listId = input.listId == null ? null : toInt(input.listId);
207
+ track("product_list_viewed", {
208
+ list_type: input.listType,
209
+ ...listId !== null ? { list_id: listId } : {},
210
+ ...input.listHandle ? { list_handle: input.listHandle } : {},
211
+ ...input.totalCount != null ? { total_count: input.totalCount } : {},
212
+ items
213
+ });
214
+ }
215
+ function recommendationPayload(a) {
216
+ const reference = toInt(a.referenceProductId);
217
+ const recommended = toInt(a.recommendedProductId);
218
+ if (reference === null || recommended === null) return null;
219
+ return {
220
+ reference_product_id: reference,
221
+ recommended_product_id: recommended,
222
+ reference_product_handle: a.referenceProductHandle,
223
+ recommended_product_handle: a.recommendedProductHandle,
224
+ intent: a.intent,
225
+ position: a.position
226
+ };
227
+ }
228
+ /** Fires `recommendation_shown` for a rail card in view. It carries no
229
+ * `product_id` on purpose: "shown" is not a positive engagement signal, so
230
+ * the trainer keeps ignoring it. The pair is separate from the listing's
231
+ * `product_list_viewed` / `product_clicked`, so a rail impression never
232
+ * double-counts as a list impression. */
233
+ function trackRecommendationShown(attribution) {
234
+ const payload = recommendationPayload(attribution);
235
+ if (payload) track("recommendation_shown", payload);
236
+ }
237
+ /** Fires `recommendation_clicked` for a click through to a rail card's product.
238
+ * `product_id` mirrors `recommended_product_id` so the trainer accepts the
239
+ * click as a positive signal for the product the buyer chose; the attribution
240
+ * fields stay distinct for analytics queries. */
241
+ function trackRecommendationClicked(attribution) {
242
+ const payload = recommendationPayload(attribution);
243
+ if (!payload) return;
244
+ track("recommendation_clicked", {
245
+ ...payload,
246
+ product_id: payload.recommended_product_id
247
+ });
248
+ }
249
+ //#endregion
250
+ export { ensureVisit, toInt, track, trackCartViewed, trackCheckoutStarted, trackProductAddedToCart, trackProductClicked, trackProductListViewed, trackProductRemovedFromCart, trackRecommendationClicked, trackRecommendationShown, trackSearchSubmitted, trackVisit };
@@ -0,0 +1,7 @@
1
+ import { ArticleSchemaSource } from "@shop-on/theme-runtime/template-types";
2
+ //#region src/article-json-ld.d.ts
3
+ export interface ArticleJsonLdProps {
4
+ data: ArticleSchemaSource;
5
+ }
6
+ export declare function ArticleJsonLd({ data }: ArticleJsonLdProps): import("react").JSX.Element;
7
+ //#endregion
@@ -0,0 +1,33 @@
1
+ import { escapeForScriptTag } from "@shop-on/theme-runtime/json-ld-escape";
2
+ import { jsx } from "react/jsx-runtime";
3
+ //#region src/article-json-ld.tsx
4
+ function buildArticleLd(data) {
5
+ const ld = {
6
+ "@context": "https://schema.org",
7
+ "@type": "BlogPosting",
8
+ headline: data.headline,
9
+ description: data.description,
10
+ datePublished: data.datePublished,
11
+ dateModified: data.dateModified
12
+ };
13
+ if (data.imageUrl) ld.image = [data.imageUrl];
14
+ if (data.authorName) ld.author = {
15
+ "@type": "Person",
16
+ name: data.authorName
17
+ };
18
+ if (data.publisherName) ld.publisher = {
19
+ "@type": "Organization",
20
+ name: data.publisherName
21
+ };
22
+ if (data.mainEntityOfPage) ld.mainEntityOfPage = data.mainEntityOfPage;
23
+ return ld;
24
+ }
25
+ function ArticleJsonLd({ data }) {
26
+ const json = escapeForScriptTag(JSON.stringify(buildArticleLd(data)));
27
+ return /* @__PURE__ */ jsx("script", {
28
+ type: "application/ld+json",
29
+ dangerouslySetInnerHTML: { __html: json }
30
+ });
31
+ }
32
+ //#endregion
33
+ export { ArticleJsonLd };
@@ -0,0 +1,7 @@
1
+ import { BreadcrumbSchemaSource } from "@shop-on/theme-runtime/template-types";
2
+ //#region src/breadcrumb-json-ld.d.ts
3
+ export interface BreadcrumbJsonLdProps {
4
+ data: BreadcrumbSchemaSource;
5
+ }
6
+ export declare function BreadcrumbJsonLd({ data }: BreadcrumbJsonLdProps): import("react").JSX.Element;
7
+ //#endregion
@@ -0,0 +1,24 @@
1
+ import { escapeForScriptTag } from "@shop-on/theme-runtime/json-ld-escape";
2
+ import { jsx } from "react/jsx-runtime";
3
+ //#region src/breadcrumb-json-ld.tsx
4
+ function buildBreadcrumbLd(data) {
5
+ return {
6
+ "@context": "https://schema.org",
7
+ "@type": "BreadcrumbList",
8
+ itemListElement: data.items.map((it, i) => ({
9
+ "@type": "ListItem",
10
+ position: i + 1,
11
+ name: it.name,
12
+ item: it.url
13
+ }))
14
+ };
15
+ }
16
+ function BreadcrumbJsonLd({ data }) {
17
+ const json = escapeForScriptTag(JSON.stringify(buildBreadcrumbLd(data)));
18
+ return /* @__PURE__ */ jsx("script", {
19
+ type: "application/ld+json",
20
+ dangerouslySetInnerHTML: { __html: json }
21
+ });
22
+ }
23
+ //#endregion
24
+ export { BreadcrumbJsonLd };
@@ -0,0 +1,17 @@
1
+ //#region src/custom-css.d.ts
2
+ export declare function escapeAttrValue(value: string): string;
3
+ /** Guard + trim a merchant-authored CSS value from settings/instance data.
4
+ * Returns null for anything that isn't a non-empty string once trimmed, so
5
+ * callers can treat null uniformly as "nothing to render". The only
6
+ * transform applied is neutralizing a `</style>` (or `</script>`) breakout —
7
+ * `dangerouslySetInnerHTML` does not escape `</`, so a merchant value
8
+ * containing `</style>` would otherwise close the wrapping tag early. There
9
+ * is no other sanitization: this is merchant-authored CSS rendered on the
10
+ * merchant's own storefront (Shopify parity). */
11
+ export declare function sanitizeCustomCss(css: unknown): string | null;
12
+ /** Wrap sanitized CSS in `selector { … }` via native CSS nesting so a
13
+ * merchant can target the wrapper itself with `&` (the `display: contents`
14
+ * section/block wrapper the renderer emits) or descend into it. Returns null
15
+ * when there's nothing to render. */
16
+ export declare function scopeCustomCss(selector: string, css: unknown): string | null;
17
+ //#endregion
@@ -0,0 +1,29 @@
1
+ //#region src/custom-css.ts
2
+ function escapeAttrValue(value) {
3
+ return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
4
+ }
5
+ /** Guard + trim a merchant-authored CSS value from settings/instance data.
6
+ * Returns null for anything that isn't a non-empty string once trimmed, so
7
+ * callers can treat null uniformly as "nothing to render". The only
8
+ * transform applied is neutralizing a `</style>` (or `<\/script>`) breakout —
9
+ * `dangerouslySetInnerHTML` does not escape `</`, so a merchant value
10
+ * containing `</style>` would otherwise close the wrapping tag early. There
11
+ * is no other sanitization: this is merchant-authored CSS rendered on the
12
+ * merchant's own storefront (Shopify parity). */
13
+ function sanitizeCustomCss(css) {
14
+ if (typeof css !== "string") return null;
15
+ const trimmed = css.trim();
16
+ if (!trimmed) return null;
17
+ return trimmed.replace(/<\//g, "<\\/");
18
+ }
19
+ /** Wrap sanitized CSS in `selector { … }` via native CSS nesting so a
20
+ * merchant can target the wrapper itself with `&` (the `display: contents`
21
+ * section/block wrapper the renderer emits) or descend into it. Returns null
22
+ * when there's nothing to render. */
23
+ function scopeCustomCss(selector, css) {
24
+ const sanitized = sanitizeCustomCss(css);
25
+ if (sanitized === null) return null;
26
+ return `${selector} { ${sanitized} }`;
27
+ }
28
+ //#endregion
29
+ export { escapeAttrValue, sanitizeCustomCss, scopeCustomCss };
@@ -0,0 +1,32 @@
1
+ import { EditorCommand } from "@shop-on/theme-contract";
2
+ //#region src/editor-preview-bridge-utils.d.ts
3
+ export interface EditorSelectedItem {
4
+ type: "section" | "block";
5
+ sectionId: string;
6
+ blockId?: string;
7
+ /** Container path: ids of the ancestor blocks between the section and
8
+ * `blockId`, section excluded, `blockId` excluded. Absent/`[]` = the block
9
+ * sits directly in the section. */
10
+ blockPath?: string[];
11
+ /** Legacy mirror of the last `blockPath` hop. Emitted so a rebuilt theme
12
+ * still nests one level when it talks to an admin build that predates block
13
+ * paths; never read by the new admin (it parses `blockPath`). */
14
+ parentBlockId?: string;
15
+ groupType?: string;
16
+ }
17
+ export declare function sameItem(a: EditorSelectedItem | null, b: EditorSelectedItem | null): boolean;
18
+ export declare function parseSelected(raw: unknown): EditorSelectedItem | null;
19
+ export declare function readClickTarget(target: EventTarget | null): EditorSelectedItem | null;
20
+ /**
21
+ * Whether a key the editor binds should go to the editor from inside the
22
+ * preview, rather than stay with the storefront. The storefront keeps every
23
+ * key typed into its own fields (the search box, a quantity input), a text
24
+ * copy while its text is selected, and Esc inside its own dialogs — a cart
25
+ * drawer closes on Esc, and deselecting the section under it too would be a
26
+ * surprise.
27
+ */
28
+ export declare function forwardsFromPreview(command: EditorCommand, target: EventTarget | null, ctx: {
29
+ textSelected: boolean;
30
+ selectionShown: boolean;
31
+ }): boolean;
32
+ //#endregion
@@ -0,0 +1,85 @@
1
+ import { blockPathsEqual, normalizeBlockPath } from "@shop-on/theme-contract";
2
+ //#region src/editor-preview-bridge-utils.ts
3
+ function sameItem(a, b) {
4
+ if (a === b) return true;
5
+ if (!a || !b) return false;
6
+ return a.type === b.type && a.sectionId === b.sectionId && (a.blockId ?? null) === (b.blockId ?? null) && blockPathsEqual(a.blockPath, b.blockPath) && (a.groupType ?? null) === (b.groupType ?? null);
7
+ }
8
+ function parseSelected(raw) {
9
+ if (!raw || typeof raw !== "object") return null;
10
+ const v = raw;
11
+ if (v.type !== "section" && v.type !== "block") return null;
12
+ if (typeof v.sectionId !== "string" || v.sectionId === "") return null;
13
+ return {
14
+ type: v.type,
15
+ sectionId: v.sectionId,
16
+ blockId: typeof v.blockId === "string" ? v.blockId : void 0,
17
+ blockPath: normalizeBlockPath(v.blockPath, v.parentBlockId),
18
+ groupType: typeof v.groupType === "string" ? v.groupType : void 0
19
+ };
20
+ }
21
+ function readClickTarget(target) {
22
+ if (!(target instanceof Element)) return null;
23
+ const sectionEl = target.closest("[data-editor-section-id]") ?? target.closest("[data-section-id]");
24
+ if (!sectionEl) return null;
25
+ const sectionId = sectionEl.getAttribute("data-editor-section-id") ?? sectionEl.getAttribute("data-section-id") ?? "";
26
+ if (!sectionId) return null;
27
+ const groupType = target.closest("[data-editor-section-id]")?.getAttribute("data-editor-group-type") || void 0;
28
+ const blockEl = target.closest("[data-editor-block-id]") ?? target.closest("[data-block-id]");
29
+ if (blockEl && sectionEl.contains(blockEl)) {
30
+ const blockId = blockEl.getAttribute("data-editor-block-id") ?? blockEl.getAttribute("data-block-id") ?? "";
31
+ if (!blockId) return null;
32
+ const blockPath = readAncestorBlockPath(blockEl, sectionEl);
33
+ return {
34
+ type: "block",
35
+ sectionId,
36
+ blockId,
37
+ blockPath,
38
+ parentBlockId: blockPath[blockPath.length - 1],
39
+ groupType
40
+ };
41
+ }
42
+ return {
43
+ type: "section",
44
+ sectionId,
45
+ groupType
46
+ };
47
+ }
48
+ function readAncestorBlockPath(blockEl, sectionEl) {
49
+ const path = [];
50
+ let node = blockEl.parentElement;
51
+ while (node && node !== sectionEl && sectionEl.contains(node)) {
52
+ const id = node.getAttribute("data-editor-block-id");
53
+ if (id) path.unshift(id);
54
+ node = node.parentElement;
55
+ }
56
+ return path;
57
+ }
58
+ const SELECTION_COMMANDS = /* @__PURE__ */ new Set([
59
+ "deselect",
60
+ "copy",
61
+ "paste",
62
+ "duplicate",
63
+ "remove",
64
+ "moveUp",
65
+ "moveDown"
66
+ ]);
67
+ const STOREFRONT_FIELDS = "input, textarea, select, [role=\"textbox\"], [role=\"combobox\"], [role=\"slider\"], [role=\"spinbutton\"]";
68
+ /**
69
+ * Whether a key the editor binds should go to the editor from inside the
70
+ * preview, rather than stay with the storefront. The storefront keeps every
71
+ * key typed into its own fields (the search box, a quantity input), a text
72
+ * copy while its text is selected, and Esc inside its own dialogs — a cart
73
+ * drawer closes on Esc, and deselecting the section under it too would be a
74
+ * surprise.
75
+ */
76
+ function forwardsFromPreview(command, target, ctx) {
77
+ if (!ctx.selectionShown && SELECTION_COMMANDS.has(command)) return false;
78
+ if (target instanceof HTMLElement) {
79
+ if (target.isContentEditable || target.closest(STOREFRONT_FIELDS)) return false;
80
+ if (command === "deselect" && target.closest("dialog, [role=\"dialog\"]")) return false;
81
+ }
82
+ return !(command === "copy" && ctx.textSelected);
83
+ }
84
+ //#endregion
85
+ export { forwardsFromPreview, parseSelected, readClickTarget, sameItem };
@@ -0,0 +1,8 @@
1
+ import { EditorSelectedItem } from "./editor-preview-bridge-utils.js";
2
+ //#region src/editor-preview-bridge.d.ts
3
+ interface Props {
4
+ preview: string | undefined;
5
+ }
6
+ export declare function EditorPreviewBridge({ preview }: Props): import("react").JSX.Element | null;
7
+ //#endregion
8
+ export type { EditorSelectedItem };