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.
- package/build-astro.ts +914 -0
- package/dist/build-static.js +2 -2
- package/dist/chunks/{chunk-UR7L5UZ3.js → chunk-HNAS6BSS.js} +2 -2
- package/dist/chunks/{chunk-EUCAKI5U.js → chunk-W6HDII4T.js} +8 -1
- package/dist/chunks/{chunk-EUCAKI5U.js.map → chunk-W6HDII4T.js.map} +2 -2
- package/dist/chunks/{chunk-JACS3C25.js → chunk-WK5XLASY.js} +2 -2
- package/dist/entries/server-router.js +2 -2
- package/dist/lib/client/index.js +5 -3
- package/dist/lib/client/index.js.map +2 -2
- package/dist/lib/server/index.js +1840 -5
- package/dist/lib/server/index.js.map +4 -4
- package/dist/lib/shared/index.js +5 -3
- package/dist/lib/shared/index.js.map +2 -2
- package/lib/client/theme.ts +4 -1
- package/lib/server/astro/componentEmitter.ts +208 -0
- package/lib/server/astro/cssCollector.ts +147 -0
- package/lib/server/astro/index.ts +5 -0
- package/lib/server/astro/nodeToAstro.ts +771 -0
- package/lib/server/astro/pageEmitter.ts +190 -0
- package/lib/server/astro/tailwindMapper.ts +547 -0
- package/lib/server/index.ts +3 -0
- package/lib/server/ssr/htmlGenerator.ts +3 -0
- package/lib/server/ssr/ssrRenderer.test.ts +8 -4
- package/lib/server/ssr/ssrRenderer.ts +1 -3
- package/lib/shared/themeDefaults.test.ts +1 -1
- package/lib/shared/themeDefaults.ts +4 -1
- package/package.json +1 -1
- /package/dist/chunks/{chunk-UR7L5UZ3.js.map → chunk-HNAS6BSS.js.map} +0 -0
- /package/dist/chunks/{chunk-JACS3C25.js.map → chunk-WK5XLASY.js.map} +0 -0
|
@@ -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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
+
}
|