astro-tractstack 2.0.10 → 2.0.12

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,587 @@
1
+ import { ulid } from 'ulid';
2
+ import type {
3
+ TemplatePane,
4
+ TemplateNode,
5
+ TemplateMarkdown,
6
+ ParentClassesPayload,
7
+ DefaultClasses,
8
+ ResponsiveClasses,
9
+ ButtonPayload,
10
+ } from '@/types/compositorTypes';
11
+ import { tailwindClasses } from '@/utils/compositor/tailwindClasses';
12
+ import { isDeepEqual } from '@/utils/helpers';
13
+
14
+ type LLMShellLayer = {
15
+ mobile?: Record<string, string>;
16
+ tablet?: Record<string, string>;
17
+ desktop?: Record<string, string>;
18
+ };
19
+
20
+ type LLMDefaultClasses = {
21
+ [tagName: string]: {
22
+ mobile?: string;
23
+ tablet?: string;
24
+ desktop?: string;
25
+ };
26
+ };
27
+
28
+ type ShellJson = {
29
+ bgColour: string;
30
+ parentClasses: LLMShellLayer[];
31
+ defaultClasses: LLMDefaultClasses;
32
+ };
33
+
34
+ type ParsedNode = {
35
+ flatNode: TemplateNode;
36
+ responsiveClasses: ResponsiveClasses;
37
+ };
38
+
39
+ type ParentClassLayer = {
40
+ mobile: Record<string, string>;
41
+ tablet: Record<string, string>;
42
+ desktop: Record<string, string>;
43
+ };
44
+
45
+ type DefaultClassValue = {
46
+ mobile: Record<string, string>;
47
+ tablet: Record<string, string>;
48
+ desktop: Record<string, string>;
49
+ };
50
+
51
+ type ClassLookupValue = {
52
+ key: string;
53
+ value: string;
54
+ viewport: 'mobile' | 'tablet' | 'desktop';
55
+ };
56
+
57
+ let KEY_NORMALIZATION_LOOKUP: Map<string, string> | null = null;
58
+ let RESPONSIVE_CLASS_LOOKUP: Map<string, ClassLookupValue> | null = null;
59
+ let BUTTON_CLASS_LOOKUP: Map<string, { key: string; value: string }> | null =
60
+ null;
61
+
62
+ const ALLOWED_TAGS = new Set([
63
+ 'h2',
64
+ 'h3',
65
+ 'h4',
66
+ 'h5',
67
+ 'p',
68
+ 'span',
69
+ 'em',
70
+ 'strong',
71
+ 'button',
72
+ ]);
73
+
74
+ function buildKeyNormalizationLookup(): Map<string, string> {
75
+ if (KEY_NORMALIZATION_LOOKUP) {
76
+ return KEY_NORMALIZATION_LOOKUP;
77
+ }
78
+
79
+ const keyMap = new Map<string, string>();
80
+ for (const key in tailwindClasses) {
81
+ // Store lowercase key -> correctly cased key
82
+ keyMap.set(key.toLowerCase(), key);
83
+ }
84
+ KEY_NORMALIZATION_LOOKUP = keyMap;
85
+ return keyMap;
86
+ }
87
+
88
+ function normalizeKeys(
89
+ styleObj: Record<string, string> | undefined
90
+ ): Record<string, string> {
91
+ if (!styleObj) return {};
92
+
93
+ const keyMap = buildKeyNormalizationLookup();
94
+ const normalized: Record<string, string> = {};
95
+
96
+ for (const key in styleObj) {
97
+ if (Object.prototype.hasOwnProperty.call(styleObj, key)) {
98
+ const lowerKey = key.toLowerCase();
99
+ const correctKey = keyMap.get(lowerKey);
100
+ // Use the correctly cased key if found, otherwise keep original (handles potential non-Tailwind keys)
101
+ normalized[correctKey || key] = styleObj[key];
102
+ }
103
+ }
104
+ return normalized;
105
+ }
106
+
107
+ function buildResponsiveClassLookup(): Map<string, ClassLookupValue> {
108
+ if (RESPONSIVE_CLASS_LOOKUP) {
109
+ return RESPONSIVE_CLASS_LOOKUP;
110
+ }
111
+
112
+ const classMap = new Map<string, ClassLookupValue>();
113
+ const viewports: Array<{
114
+ prefix: string;
115
+ key: 'mobile' | 'tablet' | 'desktop';
116
+ }> = [
117
+ { prefix: '', key: 'mobile' },
118
+ { prefix: 'md:', key: 'tablet' },
119
+ { prefix: 'xl:', key: 'desktop' },
120
+ ];
121
+
122
+ for (const tailwindKey in tailwindClasses) {
123
+ const def = tailwindClasses[tailwindKey];
124
+ const classKey = tailwindKey;
125
+
126
+ def.values.forEach((value) => {
127
+ const className = def.useKeyAsClass ? value : `${def.prefix}${value}`;
128
+ viewports.forEach((vp) => {
129
+ const fullClassName = `${vp.prefix}${className}`;
130
+ classMap.set(fullClassName, {
131
+ key: classKey,
132
+ value: value,
133
+ viewport: vp.key,
134
+ });
135
+ });
136
+ });
137
+
138
+ if (def.allowNegative) {
139
+ def.values.forEach((value) => {
140
+ if (value === '0') return;
141
+ const className = def.useKeyAsClass ? value : `${def.prefix}${value}`;
142
+ viewports.forEach((vp) => {
143
+ const fullClassName = `${vp.prefix}-${className}`;
144
+ classMap.set(fullClassName, {
145
+ key: classKey,
146
+ value: `-${value}`,
147
+ viewport: vp.key,
148
+ });
149
+ });
150
+ });
151
+ }
152
+ }
153
+ RESPONSIVE_CLASS_LOOKUP = classMap;
154
+ return classMap;
155
+ }
156
+
157
+ function buildButtonClassLookup(): Map<string, { key: string; value: string }> {
158
+ if (BUTTON_CLASS_LOOKUP) {
159
+ return BUTTON_CLASS_LOOKUP;
160
+ }
161
+
162
+ const classMap = new Map<string, { key: string; value: string }>();
163
+
164
+ for (const tailwindKey in tailwindClasses) {
165
+ const def = tailwindClasses[tailwindKey];
166
+ const classKey = tailwindKey;
167
+
168
+ def.values.forEach((value) => {
169
+ const className = def.useKeyAsClass ? value : `${def.prefix}${value}`;
170
+ classMap.set(className, {
171
+ key: classKey,
172
+ value: value,
173
+ });
174
+ });
175
+
176
+ if (def.allowNegative) {
177
+ def.values.forEach((value) => {
178
+ if (value === '0') return;
179
+ const className = def.useKeyAsClass ? value : `${def.prefix}${value}`;
180
+ classMap.set(`-${className}`, {
181
+ key: classKey,
182
+ value: `-${value}`,
183
+ });
184
+ });
185
+ }
186
+ }
187
+ BUTTON_CLASS_LOOKUP = classMap;
188
+ return classMap;
189
+ }
190
+
191
+ function sanitizeResponsiveClasses(
192
+ classString: string | null | undefined
193
+ ): ResponsiveClasses {
194
+ const responsive: ResponsiveClasses = {};
195
+
196
+ if (!classString) {
197
+ return responsive;
198
+ }
199
+
200
+ const classMap = buildResponsiveClassLookup();
201
+ const classes = classString.split(/\s+/).filter(Boolean);
202
+
203
+ classes.forEach((className) => {
204
+ const lookup = classMap.get(className);
205
+ if (lookup) {
206
+ if (!responsive[lookup.viewport]) {
207
+ responsive[lookup.viewport] = {};
208
+ }
209
+ responsive[lookup.viewport]![lookup.key] = lookup.value;
210
+ }
211
+ });
212
+
213
+ return responsive;
214
+ }
215
+
216
+ function sanitizeButtonClasses(
217
+ classString: string | null | undefined
218
+ ): ButtonPayload {
219
+ const buttonPayload: ButtonPayload = {
220
+ buttonClasses: {},
221
+ buttonHoverClasses: {},
222
+ callbackPayload: '',
223
+ };
224
+
225
+ if (!classString) {
226
+ return buttonPayload;
227
+ }
228
+
229
+ const classMap = buildButtonClassLookup();
230
+ const classes = classString.split(/\s+/).filter(Boolean);
231
+
232
+ classes.forEach((className) => {
233
+ let targetClasses = buttonPayload.buttonClasses;
234
+ let cleanClassName = className;
235
+
236
+ if (className.startsWith('hover:')) {
237
+ targetClasses = buttonPayload.buttonHoverClasses;
238
+ cleanClassName = className.substring(6);
239
+ }
240
+
241
+ const lookup = classMap.get(cleanClassName);
242
+ if (lookup) {
243
+ if (!targetClasses[lookup.key]) {
244
+ targetClasses[lookup.key] = [];
245
+ }
246
+ targetClasses[lookup.key]!.push(lookup.value);
247
+ }
248
+ });
249
+
250
+ return buttonPayload;
251
+ }
252
+
253
+ function walkDom(domNode: Node, parentId: string, parsedNodes: ParsedNode[]) {
254
+ if (domNode.nodeType === Node.TEXT_NODE) {
255
+ const copy = domNode.textContent || '';
256
+ // Preserve leading/trailing spaces unless the *entire* content is just whitespace.
257
+ // Trim internal excessive whitespace as a basic sanitation step.
258
+ const trimmedCopy = copy.replace(/\s+/g, ' ').trim();
259
+
260
+ if (trimmedCopy.length > 0) {
261
+ // Use the original copy to preserve meaningful spaces, but cleaned up.
262
+ let finalCopy = copy.replace(/\s+/g, ' ');
263
+ // Preserve single leading space if original had one AND previous sibling exists
264
+ if (copy.startsWith(' ') && domNode.previousSibling) {
265
+ finalCopy = ' ' + finalCopy.trimStart();
266
+ }
267
+ // Preserve single trailing space if original had one AND next sibling exists
268
+ if (copy.endsWith(' ') && domNode.nextSibling) {
269
+ finalCopy = finalCopy.trimEnd() + ' ';
270
+ }
271
+ // Special case: if it was ONLY space, respect if it was intended between elements
272
+ if (
273
+ trimmedCopy.length === 0 &&
274
+ copy.length > 0 &&
275
+ domNode.previousSibling &&
276
+ domNode.nextSibling
277
+ ) {
278
+ finalCopy = ' ';
279
+ }
280
+
281
+ // Only create node if there's actual content or a meaningful space
282
+ if (finalCopy.trim().length > 0 || finalCopy === ' ') {
283
+ const textNode: TemplateNode = {
284
+ id: ulid(),
285
+ nodeType: 'TagElement',
286
+ parentId: parentId,
287
+ tagName: 'text',
288
+ copy: finalCopy, // Use the carefully preserved copy
289
+ overrideClasses: {},
290
+ };
291
+ parsedNodes.push({
292
+ flatNode: textNode,
293
+ responsiveClasses: {},
294
+ });
295
+ }
296
+ }
297
+ return;
298
+ }
299
+
300
+ if (domNode.nodeType !== Node.ELEMENT_NODE) {
301
+ return;
302
+ }
303
+
304
+ const el = domNode as Element;
305
+ const tagName = el.tagName.toLowerCase();
306
+
307
+ if (!ALLOWED_TAGS.has(tagName)) {
308
+ el.childNodes.forEach((child) => walkDom(child, parentId, parsedNodes));
309
+ return;
310
+ }
311
+
312
+ if (tagName === 'button') {
313
+ const buttonPayload = sanitizeButtonClasses(el.getAttribute('class'));
314
+
315
+ const flatNode: TemplateNode = {
316
+ id: ulid(),
317
+ nodeType: 'TagElement',
318
+ parentId: parentId,
319
+ tagName: 'a',
320
+ overrideClasses: {},
321
+ href: '#',
322
+ buttonPayload: {
323
+ ...buttonPayload,
324
+ callbackPayload: '',
325
+ },
326
+ };
327
+
328
+ parsedNodes.push({
329
+ flatNode,
330
+ responsiveClasses: {},
331
+ });
332
+
333
+ el.childNodes.forEach((child) => walkDom(child, flatNode.id, parsedNodes));
334
+ return;
335
+ }
336
+
337
+ const responsive = sanitizeResponsiveClasses(el.getAttribute('class'));
338
+
339
+ const flatNode: TemplateNode = {
340
+ id: ulid(),
341
+ nodeType: 'TagElement',
342
+ parentId: parentId,
343
+ tagName: tagName,
344
+ overrideClasses: {},
345
+ };
346
+
347
+ if (tagName === 'span') {
348
+ flatNode.overrideClasses = responsive;
349
+ }
350
+
351
+ parsedNodes.push({
352
+ flatNode,
353
+ responsiveClasses: responsive,
354
+ });
355
+
356
+ el.childNodes.forEach((child) => walkDom(child, flatNode.id, parsedNodes));
357
+ }
358
+
359
+ function findMostCommonClasses(nodes: ParsedNode[]): ResponsiveClasses {
360
+ if (nodes.length === 0) return {};
361
+ const classCounts = new Map<string, number>();
362
+ const classMap = new Map<string, ResponsiveClasses>();
363
+
364
+ nodes.forEach((node) => {
365
+ const key = JSON.stringify(
366
+ node.responsiveClasses,
367
+ Object.keys(node.responsiveClasses).sort()
368
+ );
369
+ classCounts.set(key, (classCounts.get(key) || 0) + 1);
370
+ if (!classMap.has(key)) {
371
+ classMap.set(key, node.responsiveClasses);
372
+ }
373
+ });
374
+
375
+ let mostCommonKey = '';
376
+ let maxCount = 0;
377
+ classCounts.forEach((count, key) => {
378
+ if (count > maxCount) {
379
+ maxCount = count;
380
+ mostCommonKey = key;
381
+ }
382
+ });
383
+
384
+ return classMap.get(mostCommonKey) || {};
385
+ }
386
+
387
+ function ensureRequiredViewports(
388
+ responsive: ResponsiveClasses | undefined
389
+ ): DefaultClassValue {
390
+ const base = responsive || {};
391
+ return {
392
+ mobile: base.mobile || {},
393
+ tablet: base.tablet || {},
394
+ desktop: base.desktop || {},
395
+ };
396
+ }
397
+
398
+ function mergeResponsive(
399
+ base: ResponsiveClasses | DefaultClassValue | undefined,
400
+ override: ResponsiveClasses | DefaultClassValue | undefined
401
+ ): ResponsiveClasses {
402
+ const merged = base ? JSON.parse(JSON.stringify(base)) : {};
403
+ if (!override) return merged;
404
+
405
+ (['mobile', 'tablet', 'desktop'] as Array<keyof ResponsiveClasses>).forEach(
406
+ (vp) => {
407
+ if (override[vp]) {
408
+ if (!merged[vp]) {
409
+ merged[vp] = {};
410
+ }
411
+ merged[vp] = { ...merged[vp], ...override[vp] };
412
+ }
413
+ }
414
+ );
415
+
416
+ const result: ResponsiveClasses = {};
417
+ if (merged.mobile && Object.keys(merged.mobile).length > 0)
418
+ result.mobile = merged.mobile;
419
+ if (merged.tablet && Object.keys(merged.tablet).length > 0)
420
+ result.tablet = merged.tablet;
421
+ if (merged.desktop && Object.keys(merged.desktop).length > 0)
422
+ result.desktop = merged.desktop;
423
+ return result;
424
+ }
425
+
426
+ function parseDefaultClassesFromShell(
427
+ llmDefaults: LLMDefaultClasses | undefined
428
+ ): DefaultClasses {
429
+ const sanitizedDefaults: DefaultClasses = {};
430
+ if (!llmDefaults) return sanitizedDefaults;
431
+
432
+ for (const tagName in llmDefaults) {
433
+ if (Object.prototype.hasOwnProperty.call(llmDefaults, tagName)) {
434
+ const tagClasses = llmDefaults[tagName];
435
+ let responsiveForTag: ResponsiveClasses = {};
436
+
437
+ if (tagClasses.mobile) {
438
+ responsiveForTag = mergeResponsive(
439
+ responsiveForTag,
440
+ sanitizeResponsiveClasses(tagClasses.mobile)
441
+ );
442
+ }
443
+ if (tagClasses.tablet) {
444
+ const tabletClasses = sanitizeResponsiveClasses(tagClasses.tablet);
445
+ if (tabletClasses.mobile) {
446
+ responsiveForTag.tablet = {
447
+ ...responsiveForTag.tablet,
448
+ ...tabletClasses.mobile,
449
+ };
450
+ }
451
+ }
452
+ if (tagClasses.desktop) {
453
+ const desktopClasses = sanitizeResponsiveClasses(tagClasses.desktop);
454
+ if (desktopClasses.mobile) {
455
+ responsiveForTag.desktop = {
456
+ ...responsiveForTag.desktop,
457
+ ...desktopClasses.mobile,
458
+ };
459
+ }
460
+ }
461
+ sanitizedDefaults[tagName] = ensureRequiredViewports(responsiveForTag);
462
+ }
463
+ }
464
+ return sanitizedDefaults;
465
+ }
466
+
467
+ export const parseAiPane = (
468
+ shellJson: string,
469
+ copyHtml: string,
470
+ layout: string
471
+ ): TemplatePane => {
472
+ const shell: ShellJson = JSON.parse(shellJson);
473
+ const parser = new DOMParser();
474
+ const doc = parser.parseFromString(copyHtml, 'text/html');
475
+
476
+ const paneId = ulid();
477
+ const markdownId = ulid();
478
+
479
+ // --- MODIFICATION START ---
480
+ // Normalize the keys within parentClasses using the new helper
481
+ const transformedParentClasses: ParentClassesPayload = (
482
+ shell.parentClasses || []
483
+ ).map(
484
+ (layer): ParentClassLayer => ({
485
+ mobile: normalizeKeys(layer.mobile), // Use normalizeKeys helper
486
+ tablet: normalizeKeys(layer.tablet), // Use normalizeKeys helper
487
+ desktop: normalizeKeys(layer.desktop), // Use normalizeKeys helper
488
+ })
489
+ );
490
+ // --- MODIFICATION END ---
491
+
492
+ const shellDefaults = parseDefaultClassesFromShell(shell.defaultClasses);
493
+
494
+ const markdownNode: TemplateMarkdown = {
495
+ id: markdownId,
496
+ nodeType: 'Markdown',
497
+ parentId: paneId,
498
+ type: 'markdown',
499
+ markdownId: ulid(),
500
+ parentClasses: transformedParentClasses, // Use the transformed version
501
+ defaultClasses: shellDefaults,
502
+ };
503
+
504
+ const allParsedNodes: ParsedNode[] = [];
505
+ walkDom(doc.body, markdownId, allParsedNodes);
506
+
507
+ const templateNodes: TemplateNode[] = [];
508
+ const nodesByTag = new Map<string, ParsedNode[]>();
509
+
510
+ allParsedNodes.forEach((parsedNode) => {
511
+ templateNodes.push(parsedNode.flatNode);
512
+ const tagName = parsedNode.flatNode.tagName;
513
+
514
+ if (
515
+ tagName &&
516
+ tagName !== 'span' &&
517
+ tagName !== 'text' &&
518
+ tagName !== 'em' &&
519
+ tagName !== 'strong' &&
520
+ tagName !== 'a'
521
+ ) {
522
+ if (!nodesByTag.has(tagName)) {
523
+ nodesByTag.set(tagName, []);
524
+ }
525
+ nodesByTag.get(tagName)!.push(parsedNode);
526
+ }
527
+ });
528
+
529
+ nodesByTag.forEach((nodes, tagName) => {
530
+ const commonResponsiveFromCopy = findMostCommonClasses(nodes);
531
+ const requiredCommonFromCopy = ensureRequiredViewports(
532
+ commonResponsiveFromCopy
533
+ );
534
+
535
+ const existingShellDefault = markdownNode.defaultClasses![tagName];
536
+ const mergedDefault = ensureRequiredViewports(
537
+ mergeResponsive(existingShellDefault, commonResponsiveFromCopy)
538
+ );
539
+
540
+ markdownNode.defaultClasses![tagName] = mergedDefault;
541
+
542
+ nodes.forEach((parsedNode) => {
543
+ const requiredNodeResponsive = ensureRequiredViewports(
544
+ parsedNode.responsiveClasses
545
+ );
546
+
547
+ if (!isDeepEqual(requiredNodeResponsive, requiredCommonFromCopy)) {
548
+ if (!parsedNode.flatNode.overrideClasses) {
549
+ parsedNode.flatNode.overrideClasses = {};
550
+ }
551
+ parsedNode.flatNode.overrideClasses = parsedNode.responsiveClasses;
552
+ }
553
+ });
554
+ });
555
+
556
+ if (layout.includes('Image')) {
557
+ const imgNode: TemplateNode = {
558
+ id: ulid(),
559
+ nodeType: 'TagElement',
560
+ parentId: markdownId,
561
+ tagName: 'img',
562
+ src: '/static.jpg',
563
+ overrideClasses: {},
564
+ };
565
+ if (layout === 'Text + Image Left') {
566
+ templateNodes.unshift(imgNode);
567
+ } else {
568
+ templateNodes.push(imgNode);
569
+ }
570
+ }
571
+
572
+ const templatePane: TemplatePane = {
573
+ id: paneId,
574
+ nodeType: 'Pane',
575
+ parentId: '',
576
+ title: 'AI Pane',
577
+ slug: `ai-${paneId.slice(-4)}`,
578
+ bgColour: shell.bgColour,
579
+ isDecorative: false,
580
+ markdown: {
581
+ ...markdownNode,
582
+ nodes: templateNodes,
583
+ },
584
+ };
585
+
586
+ return templatePane;
587
+ };
@@ -92,9 +92,7 @@ const allowInsert = (
92
92
  }
93
93
 
94
94
  default:
95
- console.log(
96
- `miss on allowInsert: tagName:${tagName} tagNameNew:${tagNameNew} tagNameAdjacent:${tagNameAdjacent}`
97
- );
95
+ return false;
98
96
  }
99
97
  return false;
100
98
  };