@shop-on/theme-server 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 (95) hide show
  1. package/dist/LICENSE.md +39 -0
  2. package/dist/account.d.ts +12 -0
  3. package/dist/account.js +72 -0
  4. package/dist/activity.d.ts +25 -0
  5. package/dist/activity.js +35 -0
  6. package/dist/analytics-proxy.d.ts +22 -0
  7. package/dist/analytics-proxy.js +39 -0
  8. package/dist/analytics-script-proxy.d.ts +18 -0
  9. package/dist/analytics-script-proxy.js +46 -0
  10. package/dist/blog.d.ts +109 -0
  11. package/dist/blog.js +481 -0
  12. package/dist/cache.d.ts +23 -0
  13. package/dist/cache.js +60 -0
  14. package/dist/canonical-host.d.ts +26 -0
  15. package/dist/canonical-host.js +39 -0
  16. package/dist/cart-customer.d.ts +26 -0
  17. package/dist/cart-customer.js +126 -0
  18. package/dist/cart-mutations.d.ts +115 -0
  19. package/dist/cart-mutations.js +273 -0
  20. package/dist/cart-recommendations.d.ts +10 -0
  21. package/dist/cart-recommendations.js +31 -0
  22. package/dist/cart-session.d.ts +8 -0
  23. package/dist/cart-session.js +29 -0
  24. package/dist/cart.d.ts +253 -0
  25. package/dist/cart.js +209 -0
  26. package/dist/checkout.d.ts +24 -0
  27. package/dist/checkout.js +66 -0
  28. package/dist/collection.d.ts +368 -0
  29. package/dist/collection.js +658 -0
  30. package/dist/contact.d.ts +25 -0
  31. package/dist/contact.js +33 -0
  32. package/dist/content-version.d.ts +29 -0
  33. package/dist/content-version.js +63 -0
  34. package/dist/csrf.js +21 -0
  35. package/dist/customer-handoff.d.ts +13 -0
  36. package/dist/customer-handoff.js +57 -0
  37. package/dist/customer-session.d.ts +12 -0
  38. package/dist/customer-session.js +51 -0
  39. package/dist/customer.d.ts +11 -0
  40. package/dist/customer.js +61 -0
  41. package/dist/files.d.ts +68 -0
  42. package/dist/files.js +137 -0
  43. package/dist/fonts.d.ts +9 -0
  44. package/dist/fonts.js +61 -0
  45. package/dist/graphql-env.d.ts +21128 -0
  46. package/dist/graphql.d.ts +64 -0
  47. package/dist/graphql.js +331 -0
  48. package/dist/html.d.ts +3 -0
  49. package/dist/html.js +13 -0
  50. package/dist/lease.d.ts +2 -0
  51. package/dist/lease.js +1 -0
  52. package/dist/liquid.d.ts +85 -0
  53. package/dist/liquid.js +104 -0
  54. package/dist/marketing-cookies.d.ts +13 -0
  55. package/dist/marketing-cookies.js +27 -0
  56. package/dist/menu.d.ts +156 -0
  57. package/dist/menu.js +71 -0
  58. package/dist/metaobject.d.ts +18 -0
  59. package/dist/metaobject.js +92 -0
  60. package/dist/newsletter.d.ts +17 -0
  61. package/dist/newsletter.js +28 -0
  62. package/dist/page.d.ts +28 -0
  63. package/dist/page.js +94 -0
  64. package/dist/password-session.d.ts +10 -0
  65. package/dist/password-session.js +45 -0
  66. package/dist/password.d.ts +38 -0
  67. package/dist/password.js +91 -0
  68. package/dist/policy.d.ts +20 -0
  69. package/dist/policy.js +75 -0
  70. package/dist/product.d.ts +100 -0
  71. package/dist/product.js +660 -0
  72. package/dist/recommendations.d.ts +26 -0
  73. package/dist/recommendations.js +67 -0
  74. package/dist/request-scope.d.ts +2 -0
  75. package/dist/request-scope.js +1 -0
  76. package/dist/review.d.ts +162 -0
  77. package/dist/review.js +531 -0
  78. package/dist/schema.storefront.graphql +9429 -0
  79. package/dist/search.d.ts +152 -0
  80. package/dist/search.js +518 -0
  81. package/dist/seo-context.d.ts +14 -0
  82. package/dist/seo-context.js +47 -0
  83. package/dist/seo-proxy.d.ts +11 -0
  84. package/dist/seo-proxy.js +26 -0
  85. package/dist/shop-seo.d.ts +12 -0
  86. package/dist/shop-seo.js +37 -0
  87. package/dist/shop.d.ts +44 -0
  88. package/dist/shop.js +63 -0
  89. package/dist/store-availability.d.ts +24 -0
  90. package/dist/store-availability.js +59 -0
  91. package/dist/trace.js +5 -0
  92. package/dist/translations.d.ts +57 -0
  93. package/dist/translations.js +105 -0
  94. package/package.json +74 -3
  95. 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,12 @@
1
+ //#region src/account.d.ts
2
+ export declare function normalizeReturnToPath(raw: unknown): string;
3
+ export type AccountRedirectSubpath = "" | `/${string}`;
4
+ export declare function getAccountRedirectUrl(data: {
5
+ subpath?: AccountRedirectSubpath;
6
+ returnToPath?: string;
7
+ }): Promise<{
8
+ url: string;
9
+ }>;
10
+ export declare function handleCustomerLogoutPost(request: Request): Promise<Response>;
11
+ export declare function renderLogoutForm(accountLogoutUrl: string, returnTo: string): string;
12
+ //#endregion
@@ -0,0 +1,72 @@
1
+ import { fetchShopInfo } from "./shop.js";
2
+ import { checkOrigin } from "./csrf.js";
3
+ import { escapeHtmlAttr } from "./html.js";
4
+ //#region src/account.ts
5
+ const FORBIDDEN_RETURN_TO = /^(?:\/[^/]+)?\/(?:account|customer_logout)(?:\/|$)/;
6
+ function normalizeReturnToPath(raw) {
7
+ if (typeof raw !== "string") return "/";
8
+ if (!raw.startsWith("/") || raw.startsWith("//")) return "/";
9
+ const pathOnly = raw.split(/[?#]/, 1)[0];
10
+ if (FORBIDDEN_RETURN_TO.test(pathOnly)) return "/";
11
+ return raw;
12
+ }
13
+ async function getAccountRedirectUrl(data) {
14
+ const shop = await fetchShopInfo();
15
+ if (!shop) throw new Error("[theme/account] shop info unavailable from Rails");
16
+ const subpath = data.subpath ?? "";
17
+ const target = new URL(`${shop.accountUrl}${subpath}`);
18
+ if (data.returnToPath !== void 0) {
19
+ const safePath = normalizeReturnToPath(data.returnToPath);
20
+ const { getRequestUrl } = await import("@tanstack/react-start/server");
21
+ const origin = getRequestUrl({
22
+ xForwardedHost: true,
23
+ xForwardedProto: true
24
+ }).origin;
25
+ target.searchParams.set("return_to", `${origin}${safePath}`);
26
+ }
27
+ return { url: target.toString() };
28
+ }
29
+ async function handleCustomerLogoutPost(request) {
30
+ const { getRequestUrl } = await import("@tanstack/react-start/server");
31
+ const expectedOrigin = getRequestUrl({
32
+ xForwardedHost: true,
33
+ xForwardedProto: true
34
+ }).origin;
35
+ const originHeader = request.headers.get("origin");
36
+ const originResult = checkOrigin(originHeader, expectedOrigin);
37
+ if (!originResult.ok) {
38
+ console.warn(JSON.stringify({
39
+ event: "theme_logout_csrf_blocked",
40
+ reason: originResult.reason,
41
+ originHeader,
42
+ expectedOrigin,
43
+ ts: Date.now()
44
+ }));
45
+ return new Response("Forbidden", { status: 403 });
46
+ }
47
+ const shopPromise = fetchShopInfo().catch(() => null);
48
+ const { revokeCustomerSession } = await import("./customer.js");
49
+ const { clearCustomerSession } = await import("./customer-session.js");
50
+ const { useCartSession } = await import("./cart-session.js");
51
+ await revokeCustomerSession();
52
+ await clearCustomerSession();
53
+ await (await useCartSession()).clear();
54
+ const shop = await shopPromise;
55
+ if (!shop) return new Response("shop unavailable", { status: 500 });
56
+ return new Response(renderLogoutForm(`${shop.accountUrl}/logout`, `${expectedOrigin}/`), {
57
+ status: 200,
58
+ headers: { "Content-Type": "text/html; charset=utf-8" }
59
+ });
60
+ }
61
+ function renderLogoutForm(accountLogoutUrl, returnTo) {
62
+ return `<!DOCTYPE html>
63
+ <html><body>
64
+ <form action="${escapeHtmlAttr(accountLogoutUrl)}" method="POST">
65
+ <input type="hidden" name="return_to" value="${escapeHtmlAttr(returnTo)}">
66
+ <noscript><button type="submit">Continue</button></noscript>
67
+ </form>
68
+ <script>document.forms[0].submit()<\/script>
69
+ </body></html>`;
70
+ }
71
+ //#endregion
72
+ export { getAccountRedirectUrl, handleCustomerLogoutPost, normalizeReturnToPath, renderLogoutForm };
@@ -0,0 +1,25 @@
1
+ //#region src/activity.d.ts
2
+ /**
3
+ * In-memory last-request tracker for a running storefront isolate. The
4
+ * orchestrator probes `/health` to prove an install renders before it publishes
5
+ * the store's route — the theme holds no connection to any platform
6
+ * infrastructure for this. `lastRequestAt` initializes to isolate start.
7
+ */
8
+ export declare function createActivityTracker({ now }?: {
9
+ now?: () => number;
10
+ }): {
11
+ record: () => void;
12
+ snapshot: () => {
13
+ status: "ok";
14
+ lastRequestAt: number;
15
+ };
16
+ };
17
+ /**
18
+ * Request middleware: every theme registers this in `src/start.ts`. Serves
19
+ * `/health` (a 200 JSON body with `lastRequestAt`) for the orchestrator's
20
+ * warm-up probe, and stamps the tracker for everything else. `/health` itself
21
+ * must not count as activity, which the short-circuit guarantees by returning
22
+ * before the record.
23
+ */
24
+ export declare const storefrontActivityMiddleware: import("@tanstack/react-start").RequestMiddlewareAfterServer<{}, undefined, undefined>;
25
+ //#endregion
@@ -0,0 +1,35 @@
1
+ import { createMiddleware } from "@tanstack/react-start";
2
+ //#region src/activity.ts
3
+ /**
4
+ * In-memory last-request tracker for a running storefront isolate. The
5
+ * orchestrator probes `/health` to prove an install renders before it publishes
6
+ * the store's route — the theme holds no connection to any platform
7
+ * infrastructure for this. `lastRequestAt` initializes to isolate start.
8
+ */
9
+ function createActivityTracker({ now = Date.now } = {}) {
10
+ let lastRequestAt = now();
11
+ return {
12
+ record: () => {
13
+ lastRequestAt = now();
14
+ },
15
+ snapshot: () => ({
16
+ status: "ok",
17
+ lastRequestAt
18
+ })
19
+ };
20
+ }
21
+ const tracker = createActivityTracker();
22
+ /**
23
+ * Request middleware: every theme registers this in `src/start.ts`. Serves
24
+ * `/health` (a 200 JSON body with `lastRequestAt`) for the orchestrator's
25
+ * warm-up probe, and stamps the tracker for everything else. `/health` itself
26
+ * must not count as activity, which the short-circuit guarantees by returning
27
+ * before the record.
28
+ */
29
+ const storefrontActivityMiddleware = createMiddleware().server(async ({ next, request }) => {
30
+ if (new URL(request.url).pathname === "/health") return Response.json(tracker.snapshot());
31
+ tracker.record();
32
+ return next();
33
+ });
34
+ //#endregion
35
+ export { createActivityTracker, storefrontActivityMiddleware };
@@ -0,0 +1,22 @@
1
+ //#region src/analytics-proxy.d.ts
2
+ declare const PATHS: {
3
+ readonly visits: "/storefront/analytics/visits";
4
+ readonly events: "/storefront/analytics/events";
5
+ };
6
+ export type AnalyticsEndpoint = keyof typeof PATHS;
7
+ export declare function proxyAnalytics({ endpoint, body, visitToken, visitorToken, userAgent, clientIp, fbp, fbc, eventSourceUrl }: {
8
+ endpoint: AnalyticsEndpoint;
9
+ body: unknown;
10
+ visitToken?: string | null;
11
+ visitorToken?: string | null;
12
+ userAgent?: string | null;
13
+ clientIp?: string | null;
14
+ fbp?: string | null;
15
+ fbc?: string | null;
16
+ eventSourceUrl?: string | null;
17
+ }): Promise<{
18
+ status: number;
19
+ }>;
20
+ export declare function extractClientIp(headers: Headers): string | null;
21
+ //#endregion
22
+ export {};
@@ -0,0 +1,39 @@
1
+ import { requestTraceHeaders } from "./trace.js";
2
+ import { getStorefrontToken, storefrontApiUrl } from "./graphql.js";
3
+ import { isIP } from "node:net";
4
+ //#region src/analytics-proxy.ts
5
+ const PATHS = {
6
+ visits: "/storefront/analytics/visits",
7
+ events: "/storefront/analytics/events"
8
+ };
9
+ async function proxyAnalytics({ endpoint, body, visitToken, visitorToken, userAgent, clientIp, fbp, fbc, eventSourceUrl }) {
10
+ const headers = {
11
+ Authorization: `Bearer ${await getStorefrontToken()}`,
12
+ "Content-Type": "application/json",
13
+ ...requestTraceHeaders()
14
+ };
15
+ if (visitToken) headers["Ahoy-Visit"] = visitToken;
16
+ if (visitorToken) headers["Ahoy-Visitor"] = visitorToken;
17
+ if (userAgent) headers["User-Agent"] = userAgent;
18
+ if (clientIp) headers["X-Buyer-IP"] = clientIp;
19
+ if (fbp) headers["X-Marketing-Fbp"] = fbp;
20
+ if (fbc) headers["X-Marketing-Fbc"] = fbc;
21
+ if (eventSourceUrl) headers["X-Event-Source-Url"] = eventSourceUrl;
22
+ return { status: (await fetch(`${storefrontApiUrl()}${PATHS[endpoint]}`, {
23
+ method: "POST",
24
+ headers,
25
+ body: JSON.stringify(body ?? {})
26
+ })).status };
27
+ }
28
+ function extractClientIp(headers) {
29
+ const realIp = headers.get("x-real-ip");
30
+ if (realIp && isIP(realIp.trim())) return realIp.trim();
31
+ const xff = headers.get("x-forwarded-for");
32
+ if (xff) {
33
+ const first = xff.split(",")[0]?.trim();
34
+ if (first && isIP(first)) return first;
35
+ }
36
+ return null;
37
+ }
38
+ //#endregion
39
+ export { extractClientIp, proxyAnalytics };
@@ -0,0 +1,18 @@
1
+ //#region src/analytics-script-proxy.d.ts
2
+ export interface AnalyticsScriptDocument {
3
+ body: string;
4
+ /** Rails' own `Cache-Control`, so the browser TTL stays a server decision. */
5
+ cacheControl: string | null;
6
+ }
7
+ /**
8
+ * The script body, memoized, under the hard `DEADLINE_MS` ceiling. Rejects when
9
+ * the deadline passes; the route turns any rejection into an empty 200.
10
+ *
11
+ * A rejection here abandons the read without keeping it alive for anyone else:
12
+ * on workerd its fetch dies with this request, and concurrent requests wait on
13
+ * the memo's lease rather than on this call, taking over once it lapses.
14
+ * `Promise.race` has already attached a handler to the memoized call, so a
15
+ * later failure can't surface as an unhandled rejection.
16
+ */
17
+ export declare function getAnalyticsScript(): Promise<AnalyticsScriptDocument>;
18
+ //#endregion
@@ -0,0 +1,46 @@
1
+ import { memoTTL } from "./cache.js";
2
+ import { getStorefrontTokenWithin, storefrontApiUrl } from "./graphql.js";
3
+ import { getContentVersion } from "./content-version.js";
4
+ //#region src/analytics-script-proxy.ts
5
+ const TIMEOUT_MS = 2e3;
6
+ const DEADLINE_MS = 2500;
7
+ async function fetchAnalyticsScript() {
8
+ const signal = AbortSignal.timeout(TIMEOUT_MS);
9
+ const token = await getStorefrontTokenWithin(signal);
10
+ const res = await fetch(`${storefrontApiUrl()}/storefront/analytics/script`, {
11
+ headers: { Authorization: `Bearer ${token}` },
12
+ signal
13
+ });
14
+ if (!res.ok) throw new Error(`[analytics-script-proxy] upstream ${res.status}`);
15
+ return {
16
+ body: await res.text(),
17
+ cacheControl: res.headers.get("cache-control")
18
+ };
19
+ }
20
+ const memoizedPixelScript = memoTTL(fetchAnalyticsScript, {
21
+ key: () => "analytics-script",
22
+ version: getContentVersion,
23
+ leaseMs: 2250
24
+ });
25
+ /**
26
+ * The script body, memoized, under the hard `DEADLINE_MS` ceiling. Rejects when
27
+ * the deadline passes; the route turns any rejection into an empty 200.
28
+ *
29
+ * A rejection here abandons the read without keeping it alive for anyone else:
30
+ * on workerd its fetch dies with this request, and concurrent requests wait on
31
+ * the memo's lease rather than on this call, taking over once it lapses.
32
+ * `Promise.race` has already attached a handler to the memoized call, so a
33
+ * later failure can't surface as an unhandled rejection.
34
+ */
35
+ async function getAnalyticsScript() {
36
+ let timer;
37
+ try {
38
+ return await Promise.race([memoizedPixelScript(), new Promise((_resolve, reject) => {
39
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`[analytics-script-proxy] read exceeded ${DEADLINE_MS}ms`)), DEADLINE_MS);
40
+ })]);
41
+ } finally {
42
+ clearTimeout(timer);
43
+ }
44
+ }
45
+ //#endregion
46
+ export { getAnalyticsScript };
package/dist/blog.d.ts ADDED
@@ -0,0 +1,109 @@
1
+ import { ImageFileInfo } from "./files.js";
2
+ //#region src/blog.d.ts
3
+ export type ArticleAuthor = {
4
+ name: string;
5
+ };
6
+ export type ArticleCardData = {
7
+ handle: string;
8
+ blogHandle: string;
9
+ title: string;
10
+ summary: string | null;
11
+ image: ImageFileInfo | null;
12
+ tags: string[];
13
+ author: ArticleAuthor | null;
14
+ publishedAt: string | null;
15
+ bodyExcerpt?: string;
16
+ };
17
+ export type ArticleData = ArticleCardData & {
18
+ bodyHtml: string;
19
+ summaryHtml: string | null;
20
+ updatedAt: string;
21
+ bodyPlain: string;
22
+ seo: {
23
+ title: string;
24
+ description: string;
25
+ };
26
+ };
27
+ export type BlogMeta = {
28
+ id: string;
29
+ handle: string;
30
+ title: string;
31
+ };
32
+ export type BlogData = {
33
+ handle: string;
34
+ title: string;
35
+ articles_count: number;
36
+ articles: ArticleCardData[];
37
+ };
38
+ export type BlogPageData = BlogData & {
39
+ tags: string[];
40
+ total_count: number;
41
+ page_info: {
42
+ end_cursor: string | null;
43
+ has_next_page: boolean;
44
+ };
45
+ };
46
+ /** Featured-blog / blog-card resolver fetcher. Returns `null` on missing
47
+ * blog or GraphQL error; pickers degrade silently. */
48
+ export declare const fetchBlog: (data: {
49
+ handle: string;
50
+ first: number;
51
+ locale?: string;
52
+ }) => Promise<BlogData | null>;
53
+ type FetchBlogPageInput = {
54
+ handle: string;
55
+ first?: number;
56
+ after?: string;
57
+ tag?: string;
58
+ locale?: string;
59
+ imageWidth?: number;
60
+ excerptLength?: number;
61
+ withBodyExcerpt?: boolean;
62
+ };
63
+ /** Server-driven blog listing. Uncached because cursor + tag permutations would
64
+ * blow the TTL cache; the underlying API is fast. Returns `null` on missing
65
+ * blog so the route can map to `notFound()`. */
66
+ export declare const fetchBlogPage: (data: FetchBlogPageInput) => Promise<BlogPageData | null>;
67
+ /** Single-article fetch. Used by the `article` and `article_list` resolver
68
+ * specs; returns `null` on missing article so the resolver drops the entry. */
69
+ export declare const fetchArticleByRef: (ref: string, locale?: string) => Promise<ArticleData | null>;
70
+ /** Detail-page fetch by `(blogHandle, handle)`. Used by the article route
71
+ * loader. */
72
+ export declare const fetchArticle: (params: {
73
+ blogHandle: string;
74
+ handle: string;
75
+ locale?: string;
76
+ }) => Promise<ArticleData | null>;
77
+ /** One picked article as a card, sized as `fetchBlogPage` sizes a listing's
78
+ * cards — for a picker whose article renders as a card, not a page. `ref` is
79
+ * what the `article` setting stores (`parseArticleRef`). `null` on a missing
80
+ * article or a failed read, so the card falls back rather than the page. */
81
+ export declare const fetchArticleCard: (params: {
82
+ ref: string;
83
+ imageWidth?: number;
84
+ excerptLength?: number;
85
+ withBodyExcerpt?: boolean;
86
+ locale?: string;
87
+ }) => Promise<ArticleCardData | null>;
88
+ /** Lean blog lookup for breadcrumbs / contextual labels. Skips articles. */
89
+ export declare const fetchBlogMeta: (h: string, locale?: string | undefined) => Promise<BlogMeta | null>;
90
+ /** The posts either side of one in its blog, by publish date. Named by age
91
+ * rather than previous / next because the two conventions disagree: Liquid's
92
+ * `blog.previous_article` is the NEWER post and `next_article` the older.
93
+ * Null at either end, for a post with no publish date, and on a failed read.
94
+ * `imageWidth`, `excerptLength` and `withBodyExcerpt` size the cards as
95
+ * `fetchBlogPage`'s do. */
96
+ export declare const fetchAdjacentArticles: (params: {
97
+ blogId: string;
98
+ handle: string;
99
+ publishedAt: string | null;
100
+ locale?: string;
101
+ imageWidth?: number;
102
+ excerptLength?: number;
103
+ withBodyExcerpt?: boolean;
104
+ }) => Promise<{
105
+ newer: ArticleCardData | null;
106
+ older: ArticleCardData | null;
107
+ }>;
108
+ //#endregion
109
+ export {};