astro-tractstack 2.0.14 → 2.0.15

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.
@@ -0,0 +1,395 @@
1
+ import { useState, useMemo } from 'react';
2
+ import { useStore } from '@nanostores/react';
3
+ import {
4
+ Select,
5
+ Combobox,
6
+ Pagination,
7
+ Portal,
8
+ type SelectValueChangeDetails,
9
+ type ComboboxInputValueChangeDetails,
10
+ type PaginationPageChangeDetails,
11
+ } from '@ark-ui/react';
12
+ import { createListCollection } from '@ark-ui/react/collection';
13
+ import { NodesContext } from '@/stores/nodes';
14
+ import { viewportKeyStore } from '@/stores/storykeep';
15
+ import { createEmptyStorykeep } from '@/utils/compositor/nodesHelper';
16
+ import { convertStorageToLiveTemplate } from '@/utils/compositor/designLibraryHelper';
17
+ import type { StoragePane } from '@/types/compositorTypes';
18
+ import type { BrandConfig, DesignLibraryEntry } from '@/types/tractstack';
19
+ import {
20
+ PaneSnapshotGenerator,
21
+ type SnapshotData,
22
+ } from '@/components/compositor/preview/PaneSnapshotGenerator';
23
+ import {
24
+ PanesPreviewGenerator,
25
+ type PanePreviewRequest,
26
+ type PaneFragmentResult,
27
+ } from '@/components/compositor/preview/PanesPreviewGenerator';
28
+ import { classNames } from '@/utils/helpers';
29
+
30
+ const PAGE_SIZE = 12;
31
+
32
+ // --- Sub-component for rendering a single preview item ---
33
+ interface TemplatePreviewItemProps {
34
+ storageTemplate: StoragePane;
35
+ config: BrandConfig;
36
+ onClick: () => void;
37
+ title: string;
38
+ category: string;
39
+ }
40
+
41
+ const TemplatePreviewItem = ({
42
+ storageTemplate,
43
+ config,
44
+ onClick,
45
+ title,
46
+ category,
47
+ }: TemplatePreviewItemProps) => {
48
+ const [previewState, setPreviewState] = useState<{
49
+ htmlFragment?: string;
50
+ snapshot?: SnapshotData;
51
+ error?: string;
52
+ } | null>(null);
53
+
54
+ // Convert storage template to live template for previewing
55
+ const liveTemplate = useMemo(
56
+ () => convertStorageToLiveTemplate(storageTemplate),
57
+ [storageTemplate]
58
+ );
59
+
60
+ const fragmentRequest = useMemo((): PanePreviewRequest[] => {
61
+ // This preview logic is correct: it creates a *temporary* context.
62
+ const ctx = new NodesContext();
63
+ ctx.addNode(createEmptyStorykeep('tmp'));
64
+ ctx.addTemplatePane('tmp', liveTemplate);
65
+ return [{ id: liveTemplate.id, ctx }];
66
+ }, [liveTemplate]);
67
+
68
+ const handleFragmentComplete = (results: PaneFragmentResult[]) => {
69
+ const result = results[0];
70
+ if (result?.htmlString) {
71
+ setPreviewState({ htmlFragment: result.htmlString });
72
+ } else {
73
+ setPreviewState({
74
+ error: result?.error || 'Failed to generate HTML fragment.',
75
+ });
76
+ }
77
+ };
78
+
79
+ const handleSnapshotComplete = (data: SnapshotData) => {
80
+ setPreviewState((prev) => (prev ? { ...prev, snapshot: data } : null));
81
+ };
82
+
83
+ return (
84
+ <div
85
+ className="group flex cursor-pointer flex-col rounded-lg border bg-white shadow-sm transition-all hover:border-cyan-600 hover:shadow-lg"
86
+ onClick={onClick}
87
+ role="button"
88
+ tabIndex={0}
89
+ >
90
+ <div className="relative overflow-hidden rounded-t-lg border-b bg-gray-50">
91
+ {!previewState?.snapshot && (
92
+ <div className="flex h-48 w-full animate-pulse items-center justify-center bg-gray-200 text-sm text-gray-500">
93
+ Generating preview...
94
+ </div>
95
+ )}
96
+
97
+ {previewState?.error && (
98
+ <div className="flex h-full items-center justify-center p-4">
99
+ <p className="text-xs text-red-500">{previewState.error}</p>
100
+ </div>
101
+ )}
102
+
103
+ {fragmentRequest.length > 0 && !previewState?.htmlFragment && (
104
+ <PanesPreviewGenerator
105
+ requests={fragmentRequest}
106
+ onComplete={handleFragmentComplete}
107
+ onError={(err) => setPreviewState({ error: err })}
108
+ />
109
+ )}
110
+
111
+ {previewState?.htmlFragment && !previewState.snapshot && (
112
+ <PaneSnapshotGenerator
113
+ id={liveTemplate.id}
114
+ htmlString={previewState.htmlFragment}
115
+ outputWidth={800}
116
+ config={config}
117
+ onComplete={(_id, data) => handleSnapshotComplete(data)}
118
+ onError={(_id, err) =>
119
+ setPreviewState((prev) =>
120
+ prev ? { ...prev, error: err } : { error: err }
121
+ )
122
+ }
123
+ />
124
+ )}
125
+
126
+ {previewState?.snapshot && (
127
+ <img
128
+ src={previewState.snapshot.imageData}
129
+ alt={`Preview for ${title}`}
130
+ className="block h-auto w-full object-contain"
131
+ />
132
+ )}
133
+ <div className="absolute inset-0 bg-cyan-600/80 opacity-0 transition-opacity group-hover:opacity-100">
134
+ <div className="flex h-full items-center justify-center">
135
+ <span className="font-action text-xl font-bold text-white">
136
+ Select Design
137
+ </span>
138
+ </div>
139
+ </div>
140
+ </div>
141
+ <div className="flex-grow p-3">
142
+ <h3 className="truncate font-semibold" title={title}>
143
+ {title}
144
+ </h3>
145
+ <p className="text-sm capitalize text-gray-600">{category}</p>
146
+ </div>
147
+ </div>
148
+ );
149
+ };
150
+
151
+ // --- Main component ---
152
+ interface DesignLibraryStepProps {
153
+ config: BrandConfig;
154
+ onSelect: (entry: DesignLibraryEntry) => void;
155
+ }
156
+
157
+ export const DesignLibraryStep = ({
158
+ config,
159
+ onSelect,
160
+ }: DesignLibraryStepProps) => {
161
+ const designLibrary = config?.DESIGN_LIBRARY || [];
162
+ const viewport = useStore(viewportKeyStore).value;
163
+
164
+ const [selectedCategory, setSelectedCategory] = useState<string>('all');
165
+ const [searchTerm, setSearchTerm] = useState('');
166
+ const [currentPage, setCurrentPage] = useState(1);
167
+
168
+ const gridClass = useMemo(() => {
169
+ switch (viewport) {
170
+ case 'mobile':
171
+ return 'grid-cols-1';
172
+ case 'tablet':
173
+ return 'grid-cols-2';
174
+ case 'desktop':
175
+ return 'grid-cols-3';
176
+ }
177
+ }, [viewport]);
178
+
179
+ const categories = useMemo(() => {
180
+ const allCategories = new Set(
181
+ designLibrary.map((entry: DesignLibraryEntry) => entry.category)
182
+ );
183
+ return ['all', ...Array.from(allCategories)];
184
+ }, [designLibrary]);
185
+
186
+ const filteredEntries = useMemo(() => {
187
+ return designLibrary
188
+ .filter(
189
+ (entry: DesignLibraryEntry) =>
190
+ (selectedCategory === 'all' || entry.category === selectedCategory) &&
191
+ entry.title.toLowerCase().includes(searchTerm.toLowerCase())
192
+ )
193
+ .sort((a, b) => a.title.localeCompare(b.title));
194
+ }, [designLibrary, selectedCategory, searchTerm]);
195
+
196
+ const paginatedEntries = useMemo(() => {
197
+ const start = (currentPage - 1) * PAGE_SIZE;
198
+ const end = start + PAGE_SIZE;
199
+ return filteredEntries.slice(start, end);
200
+ }, [filteredEntries, currentPage]);
201
+
202
+ const totalPages = Math.ceil(filteredEntries.length / PAGE_SIZE);
203
+
204
+ const comboboxCollection = useMemo(
205
+ () =>
206
+ createListCollection({
207
+ items: filteredEntries,
208
+ itemToValue: (item) => item.title,
209
+ itemToString: (item) => item.title,
210
+ }),
211
+ [filteredEntries]
212
+ );
213
+
214
+ const selectCollection = useMemo(
215
+ () =>
216
+ createListCollection({
217
+ items: categories.map((c) => ({
218
+ label: c.charAt(0).toUpperCase() + c.slice(1),
219
+ value: c,
220
+ })),
221
+ itemToValue: (item) => item.value,
222
+ itemToString: (item) => item.label,
223
+ }),
224
+ [categories]
225
+ );
226
+
227
+ return (
228
+ <div className="flex h-full flex-col space-y-4 rounded-lg bg-gray-50 p-4 shadow-inner">
229
+ <label className="block text-lg font-semibold text-gray-800">
230
+ 2. Choose a Design
231
+ </label>
232
+
233
+ {/* --- Filters --- */}
234
+ <nav className="flex items-center gap-x-4 rounded-md border bg-white p-3">
235
+ <Select.Root
236
+ collection={selectCollection}
237
+ value={[selectedCategory]}
238
+ onValueChange={(details: SelectValueChangeDetails) =>
239
+ setSelectedCategory(details.value[0])
240
+ }
241
+ className="w-48"
242
+ positioning={{ gutter: 4 }}
243
+ >
244
+ <Select.Label className="mb-1 text-sm font-medium text-gray-700">
245
+ Category
246
+ </Select.Label>
247
+ <Select.Control>
248
+ <Select.Trigger className="flex w-full items-center justify-between rounded-md border bg-white p-2 text-left shadow-sm">
249
+ <Select.ValueText />
250
+ <Select.Indicator>▼</Select.Indicator>
251
+ </Select.Trigger>
252
+ </Select.Control>
253
+ <Portal>
254
+ <Select.Positioner>
255
+ <Select.Content className="z-50 rounded-md border bg-white shadow-lg">
256
+ {categories.map((c) => (
257
+ <Select.Item
258
+ key={c}
259
+ item={{
260
+ label: c.charAt(0).toUpperCase() + c.slice(1),
261
+ value: c,
262
+ }}
263
+ className="cursor-pointer p-2 hover:bg-gray-100"
264
+ >
265
+ <Select.ItemText>
266
+ {c.charAt(0).toUpperCase() + c.slice(1)}
267
+ </Select.ItemText>
268
+ </Select.Item>
269
+ ))}
270
+ </Select.Content>
271
+ </Select.Positioner>
272
+ </Portal>
273
+ </Select.Root>
274
+
275
+ <Combobox.Root
276
+ collection={comboboxCollection}
277
+ onInputValueChange={(e: ComboboxInputValueChangeDetails) =>
278
+ setSearchTerm(e.inputValue)
279
+ }
280
+ className="flex-1"
281
+ positioning={{ gutter: 4 }}
282
+ >
283
+ <Combobox.Label className="mb-1 text-sm font-medium text-gray-700">
284
+ Filter by Title
285
+ </Combobox.Label>
286
+ <Combobox.Control>
287
+ <Combobox.Input
288
+ placeholder="Search by title..."
289
+ className="w-full rounded-md border p-2 shadow-sm"
290
+ />
291
+ </Combobox.Control>
292
+ <Portal>
293
+ <Combobox.Positioner>
294
+ <Combobox.Content className="z-50 rounded-md border bg-white shadow-lg">
295
+ {filteredEntries.map((entry: DesignLibraryEntry) => (
296
+ <Combobox.Item
297
+ key={entry.title}
298
+ item={entry}
299
+ className="cursor-pointer p-2 hover:bg-gray-100"
300
+ >
301
+ <Combobox.ItemText>{entry.title}</Combobox.ItemText>
302
+ </Combobox.Item>
303
+ ))}
304
+ </Combobox.Content>
305
+ </Combobox.Positioner>
306
+ </Portal>
307
+ </Combobox.Root>
308
+ </nav>
309
+
310
+ {/* --- Previews Grid --- */}
311
+ <main className="flex-1 overflow-y-auto">
312
+ {paginatedEntries.length === 0 ? (
313
+ <div className="flex h-full items-center justify-center rounded-md bg-white p-6">
314
+ <p className="text-gray-500">
315
+ No designs found matching your criteria.
316
+ </p>
317
+ </div>
318
+ ) : (
319
+ <div className={classNames('grid gap-6', gridClass)}>
320
+ {paginatedEntries.map((entry) => (
321
+ <TemplatePreviewItem
322
+ key={entry.title}
323
+ storageTemplate={entry.template}
324
+ config={config}
325
+ onClick={() => onSelect(entry)}
326
+ title={entry.title}
327
+ category={entry.category}
328
+ />
329
+ ))}
330
+ </div>
331
+ )}
332
+ </main>
333
+
334
+ {/* --- Pagination --- */}
335
+ {totalPages > 1 && (
336
+ <footer className="flex items-center justify-center border-t pt-4">
337
+ <Pagination.Root
338
+ count={totalPages * PAGE_SIZE}
339
+ pageSize={PAGE_SIZE}
340
+ siblingCount={1}
341
+ page={currentPage}
342
+ onPageChange={(details: PaginationPageChangeDetails) =>
343
+ setCurrentPage(details.page)
344
+ }
345
+ className="flex items-center gap-x-2"
346
+ >
347
+ <Pagination.PrevTrigger
348
+ type="button"
349
+ className="rounded p-2 text-sm hover:bg-gray-100 disabled:text-gray-400"
350
+ disabled={currentPage === 1}
351
+ >
352
+ Previous
353
+ </Pagination.PrevTrigger>
354
+ <Pagination.Context>
355
+ {(pagination) =>
356
+ pagination.pages.map((page, index: number) =>
357
+ page.type === 'page' ? (
358
+ <Pagination.Item
359
+ key={index}
360
+ {...page}
361
+ type="page"
362
+ className={classNames(
363
+ 'flex h-9 w-9 items-center justify-center rounded-md text-sm',
364
+ page.value === currentPage
365
+ ? 'bg-cyan-600 font-bold text-white'
366
+ : 'hover:bg-gray-100'
367
+ )}
368
+ >
369
+ {page.value}
370
+ </Pagination.Item>
371
+ ) : (
372
+ <Pagination.Ellipsis
373
+ key={index}
374
+ index={index}
375
+ className="px-2 text-sm"
376
+ >
377
+ ...
378
+ </Pagination.Ellipsis>
379
+ )
380
+ )
381
+ }
382
+ </Pagination.Context>
383
+ <Pagination.NextTrigger
384
+ type="button"
385
+ className="rounded p-2 text-sm hover:bg-gray-100 disabled:text-gray-400"
386
+ disabled={currentPage === totalPages}
387
+ >
388
+ Next
389
+ </Pagination.NextTrigger>
390
+ </Pagination.Root>
391
+ </footer>
392
+ )}
393
+ </div>
394
+ );
395
+ };
@@ -45,6 +45,8 @@
45
45
  },
46
46
  "aiPaneCopyPrompt": {
47
47
  "system": "You are an expert **web designer and copywriter**. Your task is to generate a single, visually compelling block of HTML content. You must ensure the content is well-written, engaging, **beautifully spaced**, and **highly readable**.",
48
- "user_template": "Here is the design 'shell' and 'theme' (bgColour, parentClasses, and defaultClasses) you must write your HTML for. Your HTML will be placed *inside* this shell. Use the `defaultClasses` as your base theme for styling:\n{{SHELL_JSON}}\n\nNow, generate the HTML content based on these inputs:\n\nContent Prompt: \"{{COPY_INPUT}}\"\nDesign Style: **strictly for visual reference** when choosing element styles **DO NOT** include any words or concepts from the `Design Style` input in the written copy text itself.\"{{DESIGN_INPUT}}\"\nLayout Type: \"{{LAYOUT_TYPE}}\"\n\nCRITICAL RULES:\n1. You are responsible for the **inner layout and visual rhythm**. You MUST add appropriate vertical margins (e.g., `mt-4`, `mt-6`, `mt-8`) directly to any HTML block elements that *deviate* from the default spacing. **Elements must not touch.**\n2. You **MUST NOT** use `<h1>` tags. You must use `<h2>`, `<h3>`, and `<p>` tags for all text content.\n3. For responsive styles, you *must* only use `md:` and `xl:` prefixes.\n4. To make headlines pop, you MUST wrap key words in `<span>` tags with creative classes (e.g., gradient text, different colors).\n5. **All text**, even short links or phrases (like 'Learn more →'), **must** be wrapped in a block element like `<p>` or `<button>`.\n6. You MUST include at least one `<button>` tag for the primary call-to-action.\n7. Verify that **all text elements**, including text within `<span>` tags, `<button>` elements, and any elements using override classes, maintain **high contrast** (meeting at least WCAG AA standards - 4.5:1 for normal text, 3:1 for large text) against the `bgColour` provided in the `SHELL_JSON`. **Prioritize readability above all else**.\n8. Respond *only* with the raw HTML.\n\nEXAMPLE of a good, well-spaced response:\n<h2 class=\"text-4xl font-bold tracking-tight text-white md:text-6xl\"><span class=\"bg-gradient-to-r from-purple-500 to-indigo-400 bg-clip-text text-transparent\">Own the Art.</span> Possess the Reality.</h2>\n<p class=\"mt-6 text-lg leading-8 text-gray-300 md:text-xl\">Every Sneaky Productions NFT is your key. This is where digital rarity meets tangible legacy.</p>\n<button class=\"mt-8 rounded-md bg-indigo-600 px-5 py-3 text-base font-semibold text-white shadow-sm hover:bg-indigo-500\">Secure Your Drop</button>\n<p class=\"mt-4 text-sm text-gray-400\">Learn more <span>→</span></p>"
48
+ "user_template": "Here is the design 'shell' and 'theme' (bgColour, parentClasses, and defaultClasses) you must write your HTML for. Your HTML will be placed *inside* this shell. Use the `defaultClasses` as your base theme for styling:\n{{SHELL_JSON}}\n\nNow, generate the HTML content based on these inputs:\n\nContent Prompt: \"{{COPY_INPUT}}\"\nDesign Style: **strictly for visual reference** when choosing element styles **DO NOT** include any words or concepts from the `Design Style` input in the written copy text itself.\"{{DESIGN_INPUT}}\"\nLayout Type: \"{{LAYOUT_TYPE}}\"\n\nCRITICAL RULES:\n1. You are responsible for the **inner layout and visual rhythm**. You MUST add appropriate vertical margins (e.g., `mt-4`, `mt-6`, `mt-8`) directly to any HTML block elements that *deviate* from the default spacing. **Elements must not touch.**\n2. You **MUST NOT** use `<h1>` tags. You must use `<h2>`, `<h3>`, and `<p>` tags for all text content.\n3. For responsive styles, you *must* only use `md:` and `xl:` prefixes.\n4. To make headlines pop, you MUST wrap key words in `<span>` tags with creative classes (e.g., gradient text, different colors).\n5. **All text**, even short links or phrases (like 'Learn more →'), **must** be wrapped in a block element like `<p>` or `<button>`.\n6. You MUST include at least one `<button>` tag for the primary call-to-action.\n7. Verify that **all text elements**, including text within `<span>` tags, `<button>` elements, and any elements using override classes, maintain **high contrast** (meeting at least WCAG AA standards - 4.5:1 for normal text, 3:1 for large text) against the `bgColour` provided in the `SHELL_JSON`. **Prioritize readability above all else**.\n8. Respond *only* with the raw HTML.\n\nEXAMPLE of a good, well-spaced response:\n<h2 class=\"text-4xl font-bold tracking-tight text-white md:text-6xl\"><span class=\"bg-gradient-to-r from-purple-500 to-indigo-400 bg-clip-text text-transparent\">Own the Art.</span> Possess the Reality.</h2>\n<p class=\"mt-6 text-lg leading-8 text-gray-300 md:text-xl\">Every Sneaky Productions NFT is your key. This is where digital rarity meets tangible legacy.</p>\n<button class=\"mt-8 rounded-md bg-indigo-600 px-5 py-3 text-base font-semibold text-white shadow-sm hover:bg-indigo-500\">Secure Your Drop</button>\n<p class=\"mt-4 text-sm text-gray-400\">Learn more <span>→</span></p>",
49
+ "heroDefault": "A compelling hero section for a website about [topic]. It should have a strong, attention-grabbing headline, a brief paragraph explaining the core value proposition, and a clear call-to-action.",
50
+ "contentDefault": "A content section that follows a hero. It should elaborate on a key feature or benefit related to [topic]. Include a sub-headline and a descriptive paragraph."
49
51
  }
50
52
  }
@@ -78,7 +78,6 @@ function buildKeyNormalizationLookup(): Map<string, string> {
78
78
 
79
79
  const keyMap = new Map<string, string>();
80
80
  for (const key in tailwindClasses) {
81
- // Store lowercase key -> correctly cased key
82
81
  keyMap.set(key.toLowerCase(), key);
83
82
  }
84
83
  KEY_NORMALIZATION_LOOKUP = keyMap;
@@ -97,7 +96,6 @@ function normalizeKeys(
97
96
  if (Object.prototype.hasOwnProperty.call(styleObj, key)) {
98
97
  const lowerKey = key.toLowerCase();
99
98
  const correctKey = keyMap.get(lowerKey);
100
- // Use the correctly cased key if found, otherwise keep original (handles potential non-Tailwind keys)
101
99
  normalized[correctKey || key] = styleObj[key];
102
100
  }
103
101
  }
@@ -258,22 +256,16 @@ function walkDom(
258
256
  ) {
259
257
  if (domNode.nodeType === Node.TEXT_NODE) {
260
258
  const copy = domNode.textContent || '';
261
- // Preserve leading/trailing spaces unless the *entire* content is just whitespace.
262
- // Trim internal excessive whitespace as a basic sanitation step.
263
259
  const trimmedCopy = copy.replace(/\s+/g, ' ').trim();
264
260
 
265
261
  if (trimmedCopy.length > 0) {
266
- // Use the original copy to preserve meaningful spaces, but cleaned up.
267
262
  let finalCopy = copy.replace(/\s+/g, ' ');
268
- // Preserve single leading space if original had one AND previous sibling exists
269
263
  if (copy.startsWith(' ') && domNode.previousSibling) {
270
264
  finalCopy = ' ' + finalCopy.trimStart();
271
265
  }
272
- // Preserve single trailing space if original had one AND next sibling exists
273
266
  if (copy.endsWith(' ') && domNode.nextSibling) {
274
267
  finalCopy = finalCopy.trimEnd() + ' ';
275
268
  }
276
- // Special case: if it was ONLY space, respect if it was intended between elements
277
269
  if (
278
270
  trimmedCopy.length === 0 &&
279
271
  copy.length > 0 &&
@@ -283,15 +275,13 @@ function walkDom(
283
275
  finalCopy = ' ';
284
276
  }
285
277
 
286
- // Only create node if there's actual content or a meaningful space
287
278
  if (finalCopy.trim().length > 0 || finalCopy === ' ') {
288
279
  const textNode: TemplateNode = {
289
280
  id: ulid(),
290
281
  nodeType: 'TagElement',
291
282
  parentId: parentId,
292
283
  tagName: 'text',
293
- copy: finalCopy, // Use the carefully preserved copy
294
- overrideClasses: {},
284
+ copy: finalCopy,
295
285
  };
296
286
  parsedNodes.push({
297
287
  flatNode: textNode,
@@ -326,7 +316,6 @@ function walkDom(
326
316
  nodeType: 'TagElement',
327
317
  parentId: parentId,
328
318
  tagName: 'p',
329
- overrideClasses: {},
330
319
  };
331
320
  parsedNodes.push({
332
321
  flatNode: pNode,
@@ -341,8 +330,7 @@ function walkDom(
341
330
  id: ulid(),
342
331
  nodeType: 'TagElement',
343
332
  parentId: finalParentId,
344
- tagName: 'a',
345
- overrideClasses: {},
333
+ tagName: 'a', // Buttons are converted to anchor tags for our system
346
334
  href: '#',
347
335
  buttonPayload: {
348
336
  ...buttonPayload,
@@ -368,7 +356,6 @@ function walkDom(
368
356
  nodeType: 'TagElement',
369
357
  parentId: parentId,
370
358
  tagName: tagName,
371
- overrideClasses: {},
372
359
  };
373
360
 
374
361
  if (tagName === 'span') {
@@ -493,14 +480,41 @@ function parseDefaultClassesFromShell(
493
480
  return sanitizedDefaults;
494
481
  }
495
482
 
483
+ /**
484
+ * Parses a raw HTML string from the AI into a structured array of TemplateNodes.
485
+ * @param copyHtml The raw HTML string.
486
+ * @param markdownId The parent ID for the top-level nodes.
487
+ * @returns An array of TemplateNodes representing the structured content.
488
+ */
489
+ export function parseAiCopyHtml(
490
+ copyHtml: string,
491
+ markdownId: string
492
+ ): TemplateNode[] {
493
+ const parser = new DOMParser();
494
+ const doc = parser.parseFromString(copyHtml, 'text/html');
495
+
496
+ const allParsedNodes: ParsedNode[] = [];
497
+ walkDom(doc.body, markdownId, allParsedNodes, markdownId);
498
+
499
+ // When parsing copy in isolation, all classes are treated as potential overrides.
500
+ // The consumer is responsible for merging these with a set of defaults if needed.
501
+ return allParsedNodes.map((pNode) => {
502
+ if (
503
+ Object.keys(pNode.responsiveClasses).length > 0 &&
504
+ pNode.flatNode.tagName !== 'span'
505
+ ) {
506
+ pNode.flatNode.overrideClasses = pNode.responsiveClasses;
507
+ }
508
+ return pNode.flatNode;
509
+ });
510
+ }
511
+
496
512
  export const parseAiPane = (
497
513
  shellJson: string,
498
514
  copyHtml: string,
499
515
  layout: string
500
516
  ): TemplatePane => {
501
517
  const shell: ShellJson = JSON.parse(shellJson);
502
- const parser = new DOMParser();
503
- const doc = parser.parseFromString(copyHtml, 'text/html');
504
518
 
505
519
  const paneId = ulid();
506
520
  const markdownId = ulid();
@@ -527,73 +541,7 @@ export const parseAiPane = (
527
541
  defaultClasses: shellDefaults,
528
542
  };
529
543
 
530
- const allParsedNodes: ParsedNode[] = [];
531
- walkDom(doc.body, markdownId, allParsedNodes, markdownId);
532
-
533
- const templateNodes: TemplateNode[] = [];
534
- const nodesByTag = new Map<string, ParsedNode[]>();
535
-
536
- allParsedNodes.forEach((parsedNode) => {
537
- templateNodes.push(parsedNode.flatNode);
538
- const tagName = parsedNode.flatNode.tagName;
539
-
540
- if (
541
- tagName &&
542
- tagName !== 'span' &&
543
- tagName !== 'text' &&
544
- tagName !== 'em' &&
545
- tagName !== 'strong' &&
546
- tagName !== 'a'
547
- ) {
548
- if (!nodesByTag.has(tagName)) {
549
- nodesByTag.set(tagName, []);
550
- }
551
- nodesByTag.get(tagName)!.push(parsedNode);
552
- }
553
- });
554
-
555
- nodesByTag.forEach((nodes, tagName) => {
556
- const commonResponsiveFromCopy = findMostCommonClasses(nodes);
557
- const requiredCommonFromCopy = ensureRequiredViewports(
558
- commonResponsiveFromCopy
559
- );
560
-
561
- const existingShellDefault = markdownNode.defaultClasses![tagName];
562
- const mergedDefault = ensureRequiredViewports(
563
- mergeResponsive(existingShellDefault, commonResponsiveFromCopy)
564
- );
565
-
566
- markdownNode.defaultClasses![tagName] = mergedDefault;
567
-
568
- nodes.forEach((parsedNode) => {
569
- const requiredNodeResponsive = ensureRequiredViewports(
570
- parsedNode.responsiveClasses
571
- );
572
-
573
- if (!isDeepEqual(requiredNodeResponsive, requiredCommonFromCopy)) {
574
- if (!parsedNode.flatNode.overrideClasses) {
575
- parsedNode.flatNode.overrideClasses = {};
576
- }
577
- parsedNode.flatNode.overrideClasses = parsedNode.responsiveClasses;
578
- }
579
- });
580
- });
581
-
582
- if (layout.includes('Image')) {
583
- const imgNode: TemplateNode = {
584
- id: ulid(),
585
- nodeType: 'TagElement',
586
- parentId: markdownId,
587
- tagName: 'img',
588
- src: '/static.jpg',
589
- overrideClasses: {},
590
- };
591
- if (layout === 'Text + Image Left') {
592
- templateNodes.unshift(imgNode);
593
- } else {
594
- templateNodes.push(imgNode);
595
- }
596
- }
544
+ const templateNodes = parseAiCopyHtml(copyHtml, markdownId);
597
545
 
598
546
  const templatePane: TemplatePane = {
599
547
  id: paneId,