@usequeek/theme-kit 0.1.1 → 0.1.3

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Queek
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/client.ts CHANGED
@@ -50,3 +50,7 @@ export * from './stores/location-store';
50
50
  export * from './stores/address-modal-store';
51
51
  export * from './stores/auth-modal-store';
52
52
  export * from './stores/cart-store';
53
+
54
+ // Mounting a theme — the piece a theme developer cannot build without.
55
+ export * from './theme-mount';
56
+ export * from './theme-context';
package/demo.ts ADDED
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Turns a theme's `demo.json` into the props its components expect.
3
+ *
4
+ * SHIPPED WITH THE KIT because without it a theme developer has demo data and
5
+ * no way to render it. THEME.md already requires every theme to carry a
6
+ * demo.json; these are the functions that resolve its block references into
7
+ * real products, categories, posts and collections. They lived in the
8
+ * storefront's own preview routes, so they existed only here — a theme could be
9
+ * written outside this repo and never shown running against its own data.
10
+ *
11
+ * Pure and type-only by construction: no `fs`, no `next`, no app imports. That
12
+ * is what made moving it a rename rather than a rewrite, and it is why these
13
+ * work identically in a developer's preview and in this repo's preview routes.
14
+ */
15
+ import type { Block, ReviewItem } from './types/block';
16
+ import type { Menu } from './types/menu';
17
+ import type { Collection, Page, Post } from './types/page';
18
+ import type { Product } from './types/product';
19
+ import type { StorefrontConfig, VendorProfile } from './types/vendor';
20
+
21
+ /**
22
+ * Pure demo-data transforms — no filesystem access, so this file is safe to
23
+ * import from a 'use client' component (unlike `demo.ts`, which pulls in
24
+ * Node's `fs`/`path` for `loadDemoData` and will break the browser bundle if
25
+ * a client component imports a value from it). `demo.ts` re-exports
26
+ * everything here for existing server-component call sites; new client-side
27
+ * code should import from here directly.
28
+ */
29
+
30
+ export type DemoCategory = { id: string; slug: string; name: string; image?: string | null };
31
+
32
+ export interface PreviewDemoData {
33
+ profile: VendorProfile;
34
+ config: StorefrontConfig;
35
+ menus: Menu[];
36
+ pages: Record<string, Page>;
37
+ products?: Product[];
38
+ categories?: DemoCategory[];
39
+ // Raw shape varies across themes that predate the `content: Block[]` /
40
+ // `cover_image_url` convention (see `normalizePost`) — kept loose here and
41
+ // normalized on read via `getPosts`/`getPost`.
42
+ posts?: Record<string, unknown>[];
43
+ }
44
+
45
+ export function enrichBlocks(blocks: Block[], demo: PreviewDemoData): Block[] {
46
+ return blocks.map((block) => {
47
+ if (block.type === 'products' && demo.products) {
48
+ const data = (block.data ?? {}) as Record<string, unknown>;
49
+ const title = (data.title as string) ?? '';
50
+ const limit = (data.limit as number) ?? demo.products.length;
51
+ const ids = data.ids as string[] | undefined;
52
+ let items = demo.products;
53
+
54
+ if (Array.isArray(ids) && ids.length > 0) {
55
+ // Explicit ids field — preserve order from ids array for section variety
56
+ const byId = new Map(demo.products.map((p) => [p.id, p]));
57
+ items = ids.map((id) => byId.get(id)).filter((p): p is NonNullable<typeof p> => Boolean(p));
58
+ } else if (/new|arrival/i.test(title)) {
59
+ items = demo.products.filter((product) => product.flags?.is_new);
60
+ } else if (/featured|popular|best|focus|lookbook/i.test(title)) {
61
+ items = demo.products.filter((product) => product.flags?.featured);
62
+ }
63
+
64
+ if (items.length === 0) items = demo.products;
65
+ return { ...block, data: { ...block.data, products: items.slice(0, limit) } };
66
+ }
67
+
68
+ if (block.type === 'categories' && demo.categories) {
69
+ return { ...block, data: { ...block.data, categories: demo.categories } };
70
+ }
71
+
72
+ return block;
73
+ });
74
+ }
75
+
76
+ export function getEnrichedPage<T extends Page>(page: T, demo: PreviewDemoData): T {
77
+ return {
78
+ ...page,
79
+ content: enrichBlocks(page.content ?? [], demo),
80
+ };
81
+ }
82
+
83
+ /**
84
+ * `demo.json`'s `posts` array is the documented, spec-correct source (see
85
+ * `themes/THEME.md` — "must mirror the vendor bootstrap shape") — a few
86
+ * themes authored it before the `content: Block[]` / `cover_image_url`
87
+ * convention settled, so this tolerates the older shapes instead of
88
+ * assuming every theme's data is current.
89
+ */
90
+ function normalizePost(raw: Record<string, unknown>): Post {
91
+ const legacyMedia = raw.media as { image?: string; thumbnail?: string } | undefined;
92
+ const coverImageUrl =
93
+ (raw.cover_image_url as string | undefined) ?? legacyMedia?.image ?? legacyMedia?.thumbnail ?? null;
94
+
95
+ let content = raw.content;
96
+ if (typeof content === 'string') {
97
+ // Legacy raw-HTML content string — convert the handful of tags these
98
+ // themes actually used into one markdown content block rather than
99
+ // rendering literal "<p>"/"<h2>" text.
100
+ const markdown = content
101
+ .replace(/<h2>(.*?)<\/h2>/gi, '\n\n## $1\n\n')
102
+ .replace(/<h3>(.*?)<\/h3>/gi, '\n\n### $1\n\n')
103
+ .replace(/<p>(.*?)<\/p>/gi, '$1\n\n')
104
+ .replace(/\n{3,}/g, '\n\n')
105
+ .trim();
106
+ content = [{ type: 'content', data: { markdown } }];
107
+ }
108
+
109
+ return {
110
+ ...raw,
111
+ type: 'post',
112
+ content: Array.isArray(content) ? (content as Block[]) : [],
113
+ cover_image_url: coverImageUrl,
114
+ } as Post;
115
+ }
116
+
117
+ export function getPosts(demo: PreviewDemoData): Post[] {
118
+ return (demo.posts ?? [])
119
+ .map(normalizePost)
120
+ .sort((left, right) => {
121
+ const leftDate = left.published_at ? new Date(left.published_at).getTime() : 0;
122
+ const rightDate = right.published_at ? new Date(right.published_at).getTime() : 0;
123
+ return rightDate - leftDate;
124
+ });
125
+ }
126
+
127
+ /**
128
+ * Vendor-scope reviews for preview surfaces, flattened from the demo's own
129
+ * product reviews.
130
+ *
131
+ * Review-backed sections (beaurify's `content/testimonials`) render real vendor
132
+ * reviews rather than merchant-authored copy — deliberately, see its manifest.
133
+ * But nothing seeded them in preview, so the section returned null everywhere a
134
+ * vendor evaluates it: theme preview, style guide, and the section-library
135
+ * thumbnails. Themes already ship product reviews in demo.json; this exposes
136
+ * them at vendor scope instead of asking every theme to duplicate the data.
137
+ *
138
+ * Demo review records predate `ReviewItem` (`user` is a bare name, the body is
139
+ * `comment`), so both spellings are accepted.
140
+ */
141
+ export function getVendorReviews(demo: PreviewDemoData): ReviewItem[] {
142
+ return (demo.products ?? []).flatMap((product) => {
143
+ const raw = (product as unknown as { reviews?: Record<string, unknown>[] }).reviews ?? [];
144
+
145
+ return raw.map((review, index): ReviewItem => {
146
+ const author = typeof review.user === 'string'
147
+ ? review.user
148
+ : (review.customer_name as string | null) ?? 'Verified buyer';
149
+
150
+ return {
151
+ id: String(review.id ?? `${product.id}-review-${index}`),
152
+ rating: Number(review.rating ?? 5),
153
+ title: (review.title as string | null) ?? null,
154
+ description: (review.description as string | null) ?? (review.comment as string | null) ?? null,
155
+ media: [],
156
+ is_verified_purchase: review.is_verified_purchase !== false,
157
+ created_at: String(review.created_at ?? ''),
158
+ user: { id: `${product.id}-reviewer-${index}`, name: author, avatar: null },
159
+ product: { id: product.id, slug: product.slug, title: product.title, thumbnail: product.media?.thumbnail ?? null },
160
+ };
161
+ });
162
+ });
163
+ }
164
+
165
+ export function getPost(demo: PreviewDemoData, slug: string): Post | null {
166
+ return getPosts(demo).find((post) => post.slug === slug) ?? null;
167
+ }
168
+
169
+ export function getPolicies(demo: PreviewDemoData): Page[] {
170
+ return Object.values(demo.pages).filter((page) => page.type === 'policy');
171
+ }
172
+
173
+ export function getCollections(demo: PreviewDemoData): Collection[] {
174
+ const categories = demo.categories ?? [];
175
+ const products = demo.products ?? [];
176
+
177
+ return categories.map((category) => ({
178
+ id: category.id,
179
+ slug: category.slug,
180
+ name: category.name,
181
+ description: `${category.name} — curated picks from this collection.`,
182
+ image: category.image ?? null,
183
+ mode: 'manual',
184
+ products_count: products.filter((product) => product.categories.some((item) => item.slug === category.slug)).length,
185
+ }));
186
+ }
187
+
188
+ export function getCollection(demo: PreviewDemoData, slug: string): { collection: Collection; products: Product[] } | null {
189
+ const collection = getCollections(demo).find((item) => item.slug === slug);
190
+ if (!collection) return null;
191
+
192
+ const products = (demo.products ?? []).filter((product) => product.categories.some((category) => category.slug === slug));
193
+
194
+ return {
195
+ collection: {
196
+ ...collection,
197
+ products,
198
+ },
199
+ products,
200
+ };
201
+ }
package/index.ts CHANGED
@@ -47,3 +47,6 @@ export * from './utils/contact-location';
47
47
  export * from './utils/benefit-icon';
48
48
  export * from './utils/video-embed';
49
49
  export * from './utils/match-variant';
50
+
51
+ // Rendering a theme's own demo.json — what makes local preview possible.
52
+ export * from './demo';
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@usequeek/theme-kit",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "private": false,
5
- "license": "UNLICENSED",
5
+ "license": "MIT",
6
6
  "description": "The framework a Queek storefront theme is built against.",
7
7
  "repository": {
8
8
  "type": "git",
@@ -24,7 +24,8 @@
24
24
  "**/*.ts",
25
25
  "**/*.tsx",
26
26
  "**/*.css",
27
- "README.md"
27
+ "README.md",
28
+ "LICENSE"
28
29
  ],
29
30
  "peerDependencies": {
30
31
  "next": ">=15",
@@ -0,0 +1,88 @@
1
+ 'use client';
2
+
3
+ import { Component, createElement, type ErrorInfo, type JSX, type ReactNode } from 'react';
4
+ import type { FooterProps, HeaderProps, ThemeModule } from './types/theme';
5
+ import { ThemeProvider } from './theme-context';
6
+
7
+ /**
8
+ * Renders a theme. THE MINIMUM A THEME NEEDS TO BE SEEN.
9
+ *
10
+ * Without this, installing `@usequeek/theme-kit` gave a developer every hook,
11
+ * store, type and shared block — and no way to actually mount what they built.
12
+ * The component that does it, `VendorShell`, lives in the storefront app, so it
13
+ * was never part of what shipped. A theme could be written and typechecked from
14
+ * outside this repo but never rendered.
15
+ *
16
+ * DELIBERATELY SMALLER THAN VendorShell. That composes the same three pieces and
17
+ * then layers on the Queek storefront's own chrome — checkout header, cart and
18
+ * auth modals, toasts, WhatsApp chat, analytics, the merchant edit-mode bridge.
19
+ * None of that is a theme's concern, and pulling it in would drag the whole app
20
+ * into the package. What is left is the actual contract: a Layout wrapping a
21
+ * header, the page, and a footer.
22
+ *
23
+ * Themes render inside a boundary because a theme's header and footer are
24
+ * SIBLINGS of the page, so a crash in either escapes any boundary the page sets
25
+ * up and blanks the whole screen. A theme developer should see which part broke,
26
+ * not an empty document.
27
+ */
28
+
29
+ class ThemeBoundary extends Component<
30
+ { part: string; children: ReactNode },
31
+ { error: Error | null }
32
+ > {
33
+ state: { error: Error | null } = { error: null };
34
+
35
+ static getDerivedStateFromError(error: Error): { error: Error } {
36
+ return { error };
37
+ }
38
+
39
+ componentDidCatch(error: Error, info: ErrorInfo): void {
40
+ console.error(`[theme-kit] the theme's ${this.props.part} threw`, error, info.componentStack);
41
+ }
42
+
43
+ render(): ReactNode {
44
+ if (!this.state.error) return this.props.children;
45
+
46
+ // Visible on purpose. A silent fallback during theme development means
47
+ // staring at a missing header wondering whether it is CSS.
48
+ return (
49
+ <div role="alert" style={{ padding: '1rem', border: '2px solid #e0492f', background: '#fff5f3', color: '#211711', font: '14px/1.5 ui-monospace, monospace' }}>
50
+ <strong>Theme {this.props.part} crashed.</strong>
51
+ <pre style={{ margin: '0.5rem 0 0', whiteSpace: 'pre-wrap' }}>{this.state.error.message}</pre>
52
+ </div>
53
+ );
54
+ }
55
+ }
56
+
57
+ export interface ThemeMountProps {
58
+ theme: ThemeModule;
59
+ headerProps: HeaderProps;
60
+ footerProps: FooterProps;
61
+ /** Variant ids from the store's config; omit to render the theme's default. */
62
+ headerVariant?: string;
63
+ footerVariant?: string;
64
+ children: ReactNode;
65
+ }
66
+
67
+ export function ThemeMount({
68
+ theme,
69
+ headerProps,
70
+ footerProps,
71
+ headerVariant,
72
+ footerVariant,
73
+ children,
74
+ }: ThemeMountProps): JSX.Element {
75
+ return (
76
+ <ThemeProvider theme={theme}>
77
+ <theme.Layout>
78
+ <ThemeBoundary part="header">
79
+ {createElement(theme.getHeader(headerVariant), headerProps)}
80
+ </ThemeBoundary>
81
+ <main className="storefront-main">{children}</main>
82
+ <ThemeBoundary part="footer">
83
+ {createElement(theme.getFooter(footerVariant), footerProps)}
84
+ </ThemeBoundary>
85
+ </theme.Layout>
86
+ </ThemeProvider>
87
+ );
88
+ }