meno-core 1.0.38 → 1.0.39

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,771 @@
1
+ /**
2
+ * Core converter: ComponentNode → Astro template markup
3
+ * Recursively walks JSON node trees and emits Astro template syntax.
4
+ */
5
+
6
+ import type {
7
+ ComponentNode,
8
+ ComponentDefinition,
9
+ StructuredComponentDefinition,
10
+ PropDefinition,
11
+ HtmlNode,
12
+ ComponentInstanceNode,
13
+ SlotMarker,
14
+ EmbedNode,
15
+ LinkNode,
16
+ LocaleListNode,
17
+ } from '../../shared/types';
18
+ import type { ListNode } from '../../shared/registry/nodeTypes/ListNodeType';
19
+ import type {
20
+ StyleObject,
21
+ ResponsiveStyleObject,
22
+ StyleMapping,
23
+ InteractiveStyles,
24
+ LinkMapping,
25
+ HtmlMapping,
26
+ } from '../../shared/types/styles';
27
+ import { responsiveStylesToTailwind, propertyToTailwind } from './tailwindMapper';
28
+ import type { BreakpointConfig } from '../../shared/breakpoints';
29
+ import { generateElementClassName, type ElementClassContext } from '../../shared/elementClassName';
30
+ import { isVoidElement } from '../../shared/nodeUtils';
31
+ import { NODE_TYPE } from '../../shared/constants';
32
+ import { extractInteractiveStyleMappings, hasInteractiveStyleMappings } from '../../shared/interactiveStyleMappings';
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Types
36
+ // ---------------------------------------------------------------------------
37
+
38
+ export interface AstroEmitContext {
39
+ /** Component names collected for import statements */
40
+ imports: Set<string>;
41
+ /** true = inside component definition (use prop refs) */
42
+ isComponentDef: boolean;
43
+ /** Available props when in component */
44
+ componentProps: Record<string, PropDefinition>;
45
+ /** All global components */
46
+ globalComponents: Record<string, ComponentDefinition>;
47
+ /** Current indentation level */
48
+ indent: number;
49
+ /** node path → SSR HTML for complex nodes */
50
+ ssrFallbacks: Map<string, string>;
51
+ /** Current element path for element class generation */
52
+ elementPath: number[];
53
+ /** File type for element class context */
54
+ fileType: 'component' | 'page';
55
+ /** File name for element class context */
56
+ fileName: string;
57
+ /** Breakpoint config for responsive Tailwind classes */
58
+ breakpoints: BreakpointConfig;
59
+ /** Dynamic tag definitions collected during traversal (for frontmatter) */
60
+ dynamicTags?: Map<string, string>;
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Helpers
65
+ // ---------------------------------------------------------------------------
66
+
67
+ function ind(ctx: AstroEmitContext): string {
68
+ return ' '.repeat(ctx.indent);
69
+ }
70
+
71
+ function isStyleMapping(value: unknown): value is StyleMapping {
72
+ return (
73
+ typeof value === 'object' &&
74
+ value !== null &&
75
+ '_mapping' in value &&
76
+ (value as StyleMapping)._mapping === true
77
+ );
78
+ }
79
+
80
+ function isLinkMapping(value: unknown): value is LinkMapping {
81
+ return (
82
+ typeof value === 'object' &&
83
+ value !== null &&
84
+ '_mapping' in value &&
85
+ (value as LinkMapping)._mapping === true
86
+ );
87
+ }
88
+
89
+ function isHtmlMapping(value: unknown): value is HtmlMapping {
90
+ return (
91
+ typeof value === 'object' &&
92
+ value !== null &&
93
+ '_mapping' in value &&
94
+ (value as HtmlMapping)._mapping === true
95
+ );
96
+ }
97
+
98
+ function isResponsiveStyle(
99
+ style: StyleObject | ResponsiveStyleObject
100
+ ): style is ResponsiveStyleObject {
101
+ return 'base' in style || 'tablet' in style || 'mobile' in style;
102
+ }
103
+
104
+ /**
105
+ * Escape a string for use in Astro JSX attribute
106
+ */
107
+ function escapeJSX(s: string): string {
108
+ return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
109
+ }
110
+
111
+ /**
112
+ * Escape a string for use inside a JS template literal
113
+ */
114
+ function escapeTemplateLiteral(s: string): string {
115
+ return s.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
116
+ }
117
+
118
+ /**
119
+ * Get all style mappings from a style value, collecting { prop, property, values } tuples
120
+ */
121
+ function collectStyleMappings(
122
+ style: StyleObject | ResponsiveStyleObject | undefined
123
+ ): Array<{ property: string; mapping: StyleMapping; breakpoint?: string }> {
124
+ if (!style) return [];
125
+ const result: Array<{ property: string; mapping: StyleMapping; breakpoint?: string }> = [];
126
+
127
+ if (isResponsiveStyle(style)) {
128
+ const responsive = style as ResponsiveStyleObject;
129
+ for (const [bp, bpStyle] of Object.entries(responsive)) {
130
+ if (!bpStyle) continue;
131
+ for (const [prop, value] of Object.entries(bpStyle)) {
132
+ if (isStyleMapping(value)) {
133
+ result.push({ property: prop, mapping: value, breakpoint: bp });
134
+ }
135
+ }
136
+ }
137
+ } else {
138
+ for (const [prop, value] of Object.entries(style)) {
139
+ if (isStyleMapping(value)) {
140
+ result.push({ property: prop, mapping: value });
141
+ }
142
+ }
143
+ }
144
+ return result;
145
+ }
146
+
147
+ /**
148
+ * Convert a style mapping to class:list conditional entries.
149
+ * For each value in the mapping, generates the utility class name and a ternary.
150
+ */
151
+ function mappingToClassListEntries(
152
+ mapping: StyleMapping,
153
+ property: string,
154
+ breakpointPrefix: string,
155
+ ctx: AstroEmitContext
156
+ ): string[] {
157
+ const entries: string[] = [];
158
+ const values = Object.entries(mapping.values);
159
+ if (values.length === 0) return entries;
160
+
161
+ // Generate utility class for each possible value
162
+ const propRef = ctx.isComponentDef ? mapping.prop : mapping.prop;
163
+
164
+ if (values.length === 2) {
165
+ const [[val1, css1], [val2, css2]] = values;
166
+ // Generate Tailwind classes for each possible mapping value
167
+ const cls1 = getClassForValue(property, css1, breakpointPrefix);
168
+ const cls2 = getClassForValue(property, css2, breakpointPrefix);
169
+ if (cls1 && cls2) {
170
+ entries.push(`${propRef} === ${JSON.stringify(coerceValue(val1))} ? '${cls1}' : '${cls2}'`);
171
+ }
172
+ } else {
173
+ // Multiple values: use a lookup object or multiple ternaries
174
+ for (const [val, cssValue] of values) {
175
+ const cls = getClassForValue(property, cssValue, breakpointPrefix);
176
+ if (cls) {
177
+ entries.push(`${propRef} === ${JSON.stringify(coerceValue(val))} && '${cls}'`);
178
+ }
179
+ }
180
+ }
181
+
182
+ return entries;
183
+ }
184
+
185
+ /**
186
+ * Convert a mapping key to the right JS type (booleans stay booleans)
187
+ */
188
+ function coerceValue(val: string): string | boolean {
189
+ if (val === 'true') return true as any;
190
+ if (val === 'false') return false as any;
191
+ return val;
192
+ }
193
+
194
+ /**
195
+ * Generate a Tailwind class name for a single property:value pair
196
+ */
197
+ function getClassForValue(
198
+ property: string,
199
+ value: string | number,
200
+ breakpointPrefix: string
201
+ ): string | null {
202
+ const twClass = propertyToTailwind(property, value);
203
+ if (!twClass) return null;
204
+ return breakpointPrefix ? `${breakpointPrefix}${twClass}` : twClass;
205
+ }
206
+
207
+ /**
208
+ * Build the class list for a node, handling static Tailwind classes and mapping-based conditionals.
209
+ * Also returns a style attribute string for dynamic (template-expression) styles.
210
+ */
211
+ function buildClassAndStyleExpression(
212
+ style: StyleObject | ResponsiveStyleObject | undefined,
213
+ interactiveStyles: InteractiveStyles | undefined,
214
+ elementClass: string | null,
215
+ ctx: AstroEmitContext
216
+ ): { classExpr: string; styleAttr: string } {
217
+ // Static Tailwind classes from non-mapping styles
218
+ const result = style
219
+ ? responsiveStylesToTailwind(style, ctx.breakpoints)
220
+ : { classes: [], dynamicStyles: {} };
221
+ const staticClasses = result.classes;
222
+ const dynamicStyles = result.dynamicStyles;
223
+
224
+ // Add element class for interactive styles
225
+ if (elementClass) {
226
+ staticClasses.unshift(elementClass);
227
+ }
228
+
229
+ // Collect mapping-based conditionals
230
+ const conditionals: string[] = [];
231
+ const mappings = collectStyleMappings(style);
232
+
233
+ for (const { property, mapping, breakpoint } of mappings) {
234
+ const bpValue = breakpoint === 'tablet'
235
+ ? ctx.breakpoints.tablet?.breakpoint ?? 1024
236
+ : breakpoint === 'mobile'
237
+ ? ctx.breakpoints.mobile?.breakpoint ?? 540
238
+ : 0;
239
+ const prefix = bpValue ? `max-[${bpValue}px]:` : '';
240
+ const entries = mappingToClassListEntries(mapping, property, prefix, ctx);
241
+ conditionals.push(...entries);
242
+ }
243
+
244
+ // Build style attribute for dynamic (template expression) styles
245
+ let styleAttr = '';
246
+ if (Object.keys(dynamicStyles).length > 0 && ctx.isComponentDef) {
247
+ const styleParts: string[] = [];
248
+ for (const [cssProp, value] of Object.entries(dynamicStyles)) {
249
+ // Convert {{propName}} to Astro expression in style
250
+ const resolved = value.replace(/\{\{(.+?)\}\}/g, (_, expr) => `\${${expr.trim()}}`);
251
+ styleParts.push(`${cssProp}: \${${resolved.includes('${') ? resolved.replace(/\$\{(.+?)\}/g, '$1') : `'${resolved}'`}}`);
252
+ }
253
+ // Build as template literal style attribute
254
+ const entries: string[] = [];
255
+ for (const [cssProp, value] of Object.entries(dynamicStyles)) {
256
+ const resolved = value.replace(/\{\{(.+?)\}\}/g, (_, expr) => `\${${expr.trim()}}`);
257
+ entries.push(`${cssProp}: ${resolved}`);
258
+ }
259
+ styleAttr = ` style={\`${entries.join('; ')}\`}`;
260
+ }
261
+
262
+ // Handle interactive style mappings: emit --is-N CSS variables as inline style
263
+ if (interactiveStyles && interactiveStyles.length > 0 && ctx.isComponentDef && hasInteractiveStyleMappings(interactiveStyles)) {
264
+ const { mappings } = extractInteractiveStyleMappings(interactiveStyles);
265
+ if (mappings.length > 0) {
266
+ // Group mappings by prop for efficient ternary generation
267
+ const varParts: string[] = [];
268
+ for (const extracted of mappings) {
269
+ const { mapping, variableIndex } = extracted;
270
+ const varName = `--is-${variableIndex}`;
271
+ const entries = Object.entries(mapping.values);
272
+
273
+ if (entries.length === 2) {
274
+ const [[val1, css1], [val2, css2]] = entries;
275
+ varParts.push(`'${varName}': ${mapping.prop} === ${JSON.stringify(coerceValue(val1))} ? '${css1}' : '${css2}'`);
276
+ } else {
277
+ // Build a lookup object inline
278
+ const lookupEntries = entries
279
+ .filter(([, v]) => v !== '')
280
+ .map(([k, v]) => `${JSON.stringify(coerceValue(k))}: '${v}'`)
281
+ .join(', ');
282
+ varParts.push(`'${varName}': ({${lookupEntries}})[${mapping.prop}] || ''`);
283
+ }
284
+ }
285
+
286
+ // Merge with existing dynamic styles
287
+ if (varParts.length > 0) {
288
+ const existingStyleParts = styleAttr
289
+ ? styleAttr.replace(/^ style=\{`/, '').replace(/`\}$/, '')
290
+ : '';
291
+ const varStyleExpr = varParts.join(', ');
292
+ if (existingStyleParts) {
293
+ styleAttr = ` style={\`${existingStyleParts}; \${ Object.entries({${varStyleExpr}}).map(([k,v]) => \`\${k}:\${v}\`).join(';') }\`}`;
294
+ } else {
295
+ styleAttr = ` style={Object.entries({${varStyleExpr}}).map(([k,v]) => \`\${k}:\${v}\`).join(';')}`;
296
+ }
297
+ }
298
+ }
299
+ }
300
+
301
+ let classExpr: string;
302
+ if (conditionals.length === 0) {
303
+ // Pure static classes
304
+ if (staticClasses.length === 0) {
305
+ classExpr = '';
306
+ } else {
307
+ classExpr = ` class="${staticClasses.join(' ')}"`;
308
+ }
309
+ } else {
310
+ // Use class:list with both static and dynamic
311
+ const parts: string[] = [];
312
+ if (staticClasses.length > 0) {
313
+ parts.push(`'${staticClasses.join(' ')}'`);
314
+ }
315
+ parts.push(...conditionals);
316
+ classExpr = ` class:list={[${parts.join(', ')}]}`;
317
+ }
318
+
319
+ return { classExpr, styleAttr };
320
+ }
321
+
322
+ /**
323
+ * Convert {{propName}} template to Astro expression
324
+ */
325
+ function resolveTemplate(text: string, ctx: AstroEmitContext): string {
326
+ if (!ctx.isComponentDef) {
327
+ // In page context, templates can't be resolved - return literal
328
+ return text;
329
+ }
330
+ // Check if entire text is a single {{expression}}
331
+ const fullMatch = text.match(/^\{\{(.+)\}\}$/);
332
+ if (fullMatch) {
333
+ return `{${fullMatch[1].trim()}}`;
334
+ }
335
+ // Mixed content: replace each {{expr}} with {expr}
336
+ return text.replace(/\{\{(.+?)\}\}/g, (_, expr) => `{${expr.trim()}}`);
337
+ }
338
+
339
+ /**
340
+ * Check if text contains template expressions
341
+ */
342
+ function hasTemplates(text: string): boolean {
343
+ return /\{\{.+?\}\}/.test(text);
344
+ }
345
+
346
+ /**
347
+ * Build element class name for interactive styles
348
+ */
349
+ function buildElementClass(
350
+ ctx: AstroEmitContext,
351
+ label: string | undefined
352
+ ): string {
353
+ return generateElementClassName({
354
+ fileType: ctx.fileType,
355
+ fileName: ctx.fileName,
356
+ label,
357
+ path: ctx.elementPath,
358
+ });
359
+ }
360
+
361
+ /**
362
+ * Build HTML attributes string from node attributes
363
+ */
364
+ function buildAttributesString(
365
+ attributes: Record<string, string | number | boolean> | undefined,
366
+ ctx: AstroEmitContext
367
+ ): string {
368
+ if (!attributes) return '';
369
+ const parts: string[] = [];
370
+ for (const [key, value] of Object.entries(attributes)) {
371
+ if (typeof value === 'boolean') {
372
+ if (value) parts.push(key);
373
+ } else {
374
+ const strVal = String(value);
375
+ if (hasTemplates(strVal) && ctx.isComponentDef) {
376
+ // Check if entire value is a single {{expression}}
377
+ const fullMatch = strVal.match(/^\{\{(.+)\}\}$/);
378
+ if (fullMatch) {
379
+ parts.push(`${key}={${fullMatch[1].trim()}}`);
380
+ } else {
381
+ // Mixed content: use template literal
382
+ const resolved = strVal.replace(/\{\{(.+?)\}\}/g, (_, expr) => `\${${expr.trim()}}`);
383
+ parts.push(`${key}={\`${resolved}\`}`);
384
+ }
385
+ } else {
386
+ parts.push(`${key}="${escapeJSX(strVal)}"`);
387
+ }
388
+ }
389
+ }
390
+ return parts.length > 0 ? ' ' + parts.join(' ') : '';
391
+ }
392
+
393
+ /**
394
+ * Format a prop value for Astro template usage
395
+ */
396
+ function formatPropValue(value: unknown): string {
397
+ if (typeof value === 'string') return `"${escapeJSX(value)}"`;
398
+ if (typeof value === 'number') return `{${value}}`;
399
+ if (typeof value === 'boolean') return `{${value}}`;
400
+ if (value === null || value === undefined) return `{undefined}`;
401
+ // Objects/arrays
402
+ return `{${JSON.stringify(value)}}`;
403
+ }
404
+
405
+ // ---------------------------------------------------------------------------
406
+ // Main recursive converter
407
+ // ---------------------------------------------------------------------------
408
+
409
+ /**
410
+ * Convert a ComponentNode tree to Astro template markup
411
+ */
412
+ export function nodeToAstro(
413
+ node: ComponentNode | ComponentNode[] | string | number | null | undefined,
414
+ ctx: AstroEmitContext
415
+ ): string {
416
+ if (node === null || node === undefined) return '';
417
+
418
+ // Text/number
419
+ if (typeof node === 'string') {
420
+ if (hasTemplates(node) && ctx.isComponentDef) {
421
+ return `${ind(ctx)}${resolveTemplate(node, ctx)}\n`;
422
+ }
423
+ return `${ind(ctx)}${escapeJSX(node)}\n`;
424
+ }
425
+ if (typeof node === 'number') {
426
+ return `${ind(ctx)}${node}\n`;
427
+ }
428
+
429
+ // Array of nodes
430
+ if (Array.isArray(node)) {
431
+ let result = '';
432
+ for (let i = 0; i < node.length; i++) {
433
+ const child = node[i];
434
+ const savedPath = [...ctx.elementPath];
435
+ ctx.elementPath = [...ctx.elementPath, i];
436
+ result += nodeToAstro(child, ctx);
437
+ ctx.elementPath = savedPath;
438
+ }
439
+ return result;
440
+ }
441
+
442
+ // Dispatch by node type
443
+ switch (node.type) {
444
+ case NODE_TYPE.NODE:
445
+ return emitHtmlNode(node as HtmlNode, ctx);
446
+ case NODE_TYPE.COMPONENT:
447
+ return emitComponentInstance(node as ComponentInstanceNode, ctx);
448
+ case NODE_TYPE.SLOT:
449
+ return emitSlotMarker(node as SlotMarker, ctx);
450
+ case NODE_TYPE.EMBED:
451
+ return emitEmbedNode(node as EmbedNode, ctx);
452
+ case NODE_TYPE.LINK:
453
+ return emitLinkNode(node as LinkNode, ctx);
454
+ case NODE_TYPE.LOCALE_LIST:
455
+ return emitFallback(ctx);
456
+ case NODE_TYPE.LIST:
457
+ case 'cms-list' as any:
458
+ return emitFallback(ctx);
459
+ default:
460
+ return emitFallback(ctx);
461
+ }
462
+ }
463
+
464
+ // ---------------------------------------------------------------------------
465
+ // Node type emitters
466
+ // ---------------------------------------------------------------------------
467
+
468
+ function emitHtmlNode(node: HtmlNode, ctx: AstroEmitContext): string {
469
+ let tag = node.tag;
470
+ const label = node.label;
471
+ const style = node.style as StyleObject | ResponsiveStyleObject | undefined;
472
+
473
+ // Handle dynamic tags: <h{{size}}> → define Tag variable in frontmatter, use <Tag> in template
474
+ let isDynamic = false;
475
+ let dynamicTagVar = '';
476
+ if (hasTemplates(tag) && ctx.isComponentDef) {
477
+ isDynamic = true;
478
+ // Generate a unique variable name based on element path
479
+ dynamicTagVar = `Tag_${ctx.elementPath.join('_')}`;
480
+ const resolved = tag.replace(/\{\{(.+?)\}\}/g, (_, expr) => `\${${expr.trim()}}`);
481
+ // Register dynamic tag for frontmatter emission
482
+ if (!ctx.dynamicTags) ctx.dynamicTags = new Map();
483
+ ctx.dynamicTags.set(dynamicTagVar, resolved);
484
+ tag = dynamicTagVar;
485
+ }
486
+
487
+ // Conditional rendering
488
+ const ifExpr = emitIfOpen(node, ctx);
489
+
490
+ // Element class for interactive styles
491
+ let elementClass: string | null = null;
492
+ if (
493
+ (node.interactiveStyles && node.interactiveStyles.length > 0) ||
494
+ node.generateElementClass
495
+ ) {
496
+ elementClass = buildElementClass(ctx, label);
497
+ }
498
+
499
+ const { classExpr, styleAttr } = buildClassAndStyleExpression(style, node.interactiveStyles as InteractiveStyles | undefined, elementClass, ctx);
500
+ const attrs = buildAttributesString(node.attributes, ctx);
501
+
502
+ // Dynamic tags use the variable name directly (capitalized, defined in frontmatter)
503
+ const openClose = isDynamic ? dynamicTagVar : tag;
504
+
505
+ if (!isDynamic && isVoidElement(tag)) {
506
+ return `${ifExpr}${ind(ctx)}<${tag}${classExpr}${styleAttr}${attrs} />\n${emitIfClose(node, ctx)}`;
507
+ }
508
+
509
+ const children = emitChildren(node.children, ctx);
510
+
511
+ if (!children.trim() && !isDynamic) {
512
+ return `${ifExpr}${ind(ctx)}<${tag}${classExpr}${styleAttr}${attrs} />\n${emitIfClose(node, ctx)}`;
513
+ }
514
+
515
+ return (
516
+ `${ifExpr}${ind(ctx)}<${openClose}${classExpr}${styleAttr}${attrs}>\n` +
517
+ children +
518
+ `${ind(ctx)}</${openClose}>\n${emitIfClose(node, ctx)}`
519
+ );
520
+ }
521
+
522
+ function emitComponentInstance(node: ComponentInstanceNode, ctx: AstroEmitContext): string {
523
+ const name = node.component;
524
+ ctx.imports.add(name);
525
+
526
+ const ifExpr = emitIfOpen(node, ctx);
527
+
528
+ // Build prop expressions
529
+ const propParts: string[] = [];
530
+ if (node.props) {
531
+ for (const [key, value] of Object.entries(node.props)) {
532
+ if (key === 'children') continue;
533
+ // Resolve template expressions in string props when inside component def
534
+ if (typeof value === 'string' && hasTemplates(value) && ctx.isComponentDef) {
535
+ const fullMatch = value.match(/^\{\{(.+)\}\}$/);
536
+ if (fullMatch) {
537
+ propParts.push(`${key}={${fullMatch[1].trim()}}`);
538
+ } else {
539
+ const resolved = value.replace(/\{\{(.+?)\}\}/g, (_, expr) => `\${${expr.trim()}}`);
540
+ propParts.push(`${key}={\`${resolved}\`}`);
541
+ }
542
+ } else {
543
+ propParts.push(`${key}=${formatPropValue(value)}`);
544
+ }
545
+ }
546
+ }
547
+
548
+ // Instance-level style overrides as className (Tailwind)
549
+ if (node.style) {
550
+ const { classes: instanceClasses } = responsiveStylesToTailwind(node.style as StyleObject | ResponsiveStyleObject, ctx.breakpoints);
551
+ if (instanceClasses.length > 0) {
552
+ propParts.push(`class="${instanceClasses.join(' ')}"`);
553
+ }
554
+ }
555
+
556
+ const propsStr = propParts.length > 0 ? ' ' + propParts.join(' ') : '';
557
+
558
+ const children = emitChildren(node.children, ctx);
559
+
560
+ if (!children.trim()) {
561
+ return `${ifExpr}${ind(ctx)}<${name}${propsStr} />\n${emitIfClose(node, ctx)}`;
562
+ }
563
+
564
+ return (
565
+ `${ifExpr}${ind(ctx)}<${name}${propsStr}>\n` +
566
+ children +
567
+ `${ind(ctx)}</${name}>\n${emitIfClose(node, ctx)}`
568
+ );
569
+ }
570
+
571
+ function emitSlotMarker(node: SlotMarker, ctx: AstroEmitContext): string {
572
+ if (node.default) {
573
+ const defaultContent = emitChildren(node.default as any, ctx);
574
+ if (defaultContent.trim()) {
575
+ return (
576
+ `${ind(ctx)}<slot>\n` +
577
+ defaultContent +
578
+ `${ind(ctx)}</slot>\n`
579
+ );
580
+ }
581
+ }
582
+ return `${ind(ctx)}<slot />\n`;
583
+ }
584
+
585
+ function emitEmbedNode(node: EmbedNode, ctx: AstroEmitContext): string {
586
+ const ifExpr = emitIfOpen(node, ctx);
587
+ const style = node.style as StyleObject | ResponsiveStyleObject | undefined;
588
+
589
+ let elementClass: string | null = null;
590
+ if (
591
+ (node.interactiveStyles && node.interactiveStyles.length > 0) ||
592
+ node.generateElementClass
593
+ ) {
594
+ elementClass = buildElementClass(ctx, node.label);
595
+ }
596
+
597
+ const { classExpr, styleAttr } = buildClassAndStyleExpression(style, node.interactiveStyles as InteractiveStyles | undefined, elementClass, ctx);
598
+ const attrs = buildAttributesString(node.attributes, ctx);
599
+
600
+ // Handle HTML mapping (prop-dependent content)
601
+ if (isHtmlMapping(node.html)) {
602
+ if (ctx.isComponentDef) {
603
+ const propRef = node.html.prop;
604
+ return (
605
+ `${ifExpr}${ind(ctx)}<div${classExpr.replace('"', '"oem ') || ' class="oem"'}${attrs}>\n` +
606
+ `${ind(ctx)} <Fragment set:html={${propRef}} />\n` +
607
+ `${ind(ctx)}</div>\n${emitIfClose(node, ctx)}`
608
+ );
609
+ }
610
+ }
611
+
612
+ const htmlStr = typeof node.html === 'string' ? node.html : '';
613
+ const escapedHtml = escapeTemplateLiteral(htmlStr);
614
+
615
+ // Ensure oem class is present
616
+ let finalClassExpr = classExpr;
617
+ if (!classExpr.includes('oem')) {
618
+ if (classExpr) {
619
+ finalClassExpr = classExpr.replace(/class="/, 'class="oem ').replace(/class:list={\['/, "class:list={['oem ");
620
+ } else {
621
+ finalClassExpr = ' class="oem"';
622
+ }
623
+ }
624
+
625
+ return (
626
+ `${ifExpr}${ind(ctx)}<div${finalClassExpr}${attrs}>\n` +
627
+ `${ind(ctx)} <Fragment set:html={\`${escapedHtml}\`} />\n` +
628
+ `${ind(ctx)}</div>\n${emitIfClose(node, ctx)}`
629
+ );
630
+ }
631
+
632
+ function emitLinkNode(node: LinkNode, ctx: AstroEmitContext): string {
633
+ const ifExpr = emitIfOpen(node, ctx);
634
+ const style = node.style as StyleObject | ResponsiveStyleObject | undefined;
635
+
636
+ let elementClass: string | null = null;
637
+ if (
638
+ (node.interactiveStyles && node.interactiveStyles.length > 0) ||
639
+ node.generateElementClass
640
+ ) {
641
+ elementClass = buildElementClass(ctx, node.label);
642
+ }
643
+
644
+ // Build class expression with olink base class
645
+ const { classExpr, styleAttr } = buildClassAndStyleExpression(style, node.interactiveStyles as InteractiveStyles | undefined, elementClass, ctx);
646
+ let finalClassExpr = classExpr;
647
+ if (!classExpr.includes('olink')) {
648
+ if (classExpr) {
649
+ finalClassExpr = classExpr.replace(/class="/, 'class="olink ').replace(/class:list={\['/, "class:list={['olink ");
650
+ } else {
651
+ finalClassExpr = ' class="olink"';
652
+ }
653
+ }
654
+
655
+ // Handle href
656
+ let hrefAttr: string;
657
+ if (isLinkMapping(node.href)) {
658
+ if (ctx.isComponentDef) {
659
+ const propRef = node.href.prop;
660
+ // Link props are objects with {href, target?}
661
+ hrefAttr = ` href={${propRef}.href}`;
662
+ } else {
663
+ hrefAttr = ' href="#"';
664
+ }
665
+ } else {
666
+ const href = typeof node.href === 'string' ? node.href : '#';
667
+ if (hasTemplates(href) && ctx.isComponentDef) {
668
+ const fullMatch = href.match(/^\{\{(.+)\}\}$/);
669
+ if (fullMatch) {
670
+ hrefAttr = ` href={${fullMatch[1].trim()}}`;
671
+ } else {
672
+ const resolved = href.replace(/\{\{(.+?)\}\}/g, (_, expr) => `\${${expr.trim()}}`);
673
+ hrefAttr = ` href={\`${resolved}\`}`;
674
+ }
675
+ } else {
676
+ hrefAttr = ` href="${escapeJSX(href)}"`;
677
+ }
678
+ }
679
+
680
+ const attrs = buildAttributesString(node.attributes, ctx);
681
+ const children = emitChildren(node.children, ctx);
682
+
683
+ if (!children.trim()) {
684
+ return `${ifExpr}${ind(ctx)}<a${hrefAttr}${finalClassExpr}${styleAttr}${attrs} />\n${emitIfClose(node, ctx)}`;
685
+ }
686
+
687
+ return (
688
+ `${ifExpr}${ind(ctx)}<a${hrefAttr}${finalClassExpr}${styleAttr}${attrs}>\n` +
689
+ children +
690
+ `${ind(ctx)}</a>\n${emitIfClose(node, ctx)}`
691
+ );
692
+ }
693
+
694
+ /**
695
+ * Emit SSR fallback for complex nodes (list, locale-list)
696
+ */
697
+ function emitFallback(ctx: AstroEmitContext): string {
698
+ const pathKey = ctx.elementPath.join('.');
699
+ const ssrHtml = ctx.ssrFallbacks.get(pathKey);
700
+ if (ssrHtml) {
701
+ const escaped = escapeTemplateLiteral(ssrHtml);
702
+ return `${ind(ctx)}<Fragment set:html={\`${escaped}\`} />\n`;
703
+ }
704
+ return `${ind(ctx)}{/* Complex node - SSR fallback not available */}\n`;
705
+ }
706
+
707
+ // ---------------------------------------------------------------------------
708
+ // Conditional rendering helpers
709
+ // ---------------------------------------------------------------------------
710
+
711
+ function emitIfOpen(node: ComponentNode, ctx: AstroEmitContext): string {
712
+ const ifValue = (node as any).if;
713
+ if (ifValue === undefined || ifValue === true) return '';
714
+
715
+ if (typeof ifValue === 'boolean') {
716
+ return ifValue ? '' : `${ind(ctx)}{/* hidden */}\n`;
717
+ }
718
+
719
+ if (typeof ifValue === 'object' && ifValue._mapping && ctx.isComponentDef) {
720
+ return `${ind(ctx)}{${ifValue.prop} && (\n`;
721
+ }
722
+
723
+ if (typeof ifValue === 'string' && ctx.isComponentDef) {
724
+ // Check if entire string is a single {{expression}}
725
+ const fullMatch = ifValue.match(/^\{\{(.+)\}\}$/);
726
+ const expr = fullMatch ? fullMatch[1].trim() : ifValue.replace(/\{\{(.+?)\}\}/g, '$1');
727
+ return `${ind(ctx)}{${expr} && (\n`;
728
+ }
729
+
730
+ return '';
731
+ }
732
+
733
+ function emitIfClose(node: ComponentNode, ctx: AstroEmitContext): string {
734
+ const ifValue = (node as any).if;
735
+ if (ifValue === undefined || ifValue === true) return '';
736
+
737
+ if (typeof ifValue === 'boolean') return '';
738
+
739
+ if (
740
+ (typeof ifValue === 'object' && ifValue._mapping && ctx.isComponentDef) ||
741
+ (typeof ifValue === 'string' && ctx.isComponentDef)
742
+ ) {
743
+ return `${ind(ctx)})}\n`;
744
+ }
745
+
746
+ return '';
747
+ }
748
+
749
+ // ---------------------------------------------------------------------------
750
+ // Children helper
751
+ // ---------------------------------------------------------------------------
752
+
753
+ function emitChildren(
754
+ children: (ComponentNode | string)[] | string | ComponentNode | null | undefined,
755
+ ctx: AstroEmitContext
756
+ ): string {
757
+ if (!children) return '';
758
+
759
+ const innerCtx = { ...ctx, indent: ctx.indent + 1, elementPath: [...ctx.elementPath] };
760
+
761
+ if (typeof children === 'string') {
762
+ return nodeToAstro(children, innerCtx);
763
+ }
764
+
765
+ if (Array.isArray(children)) {
766
+ return nodeToAstro(children as ComponentNode[], innerCtx);
767
+ }
768
+
769
+ // Single node
770
+ return nodeToAstro(children, innerCtx);
771
+ }