meno-core 1.0.39 → 1.0.41
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/bin/cli.ts +33 -0
- package/build-astro.ts +172 -69
- package/dist/bin/cli.js +30 -2
- package/dist/bin/cli.js.map +2 -2
- package/dist/build-static.js +7 -7
- package/dist/chunks/{chunk-WK5XLASY.js → chunk-EQOSDQS2.js} +4 -4
- package/dist/chunks/{chunk-AIXKUVNG.js → chunk-IBR2F4IL.js} +4 -5
- package/dist/chunks/{chunk-AIXKUVNG.js.map → chunk-IBR2F4IL.js.map} +2 -2
- package/dist/chunks/{chunk-NV25WXCA.js → chunk-IGVQF5GY.js} +11 -7
- package/dist/chunks/chunk-IGVQF5GY.js.map +7 -0
- package/dist/chunks/{chunk-KULPBDC7.js → chunk-LBWIHPN7.js} +9 -3
- package/dist/chunks/chunk-LBWIHPN7.js.map +7 -0
- package/dist/chunks/{chunk-A6KWUEA6.js → chunk-MKB2J6AD.js} +9 -1
- package/dist/chunks/chunk-MKB2J6AD.js.map +7 -0
- package/dist/chunks/{chunk-P3FX5HJM.js → chunk-S2HXJTAF.js} +1 -1
- package/dist/chunks/chunk-S2HXJTAF.js.map +7 -0
- package/dist/chunks/{chunk-W6HDII4T.js → chunk-SK3TLNUP.js} +140 -114
- package/dist/chunks/chunk-SK3TLNUP.js.map +7 -0
- package/dist/chunks/{chunk-HNAS6BSS.js → chunk-SNUROC7E.js} +56 -6
- package/dist/chunks/{chunk-HNAS6BSS.js.map → chunk-SNUROC7E.js.map} +3 -3
- package/dist/chunks/{configService-TXBNUBBL.js → configService-MICL4S2L.js} +2 -2
- package/dist/chunks/{constants-5CRJRQNR.js → constants-ZEU4TZCA.js} +2 -2
- package/dist/entries/server-router.js +7 -7
- package/dist/lib/client/index.js +11 -6
- package/dist/lib/client/index.js.map +2 -2
- package/dist/lib/server/index.js +507 -1587
- package/dist/lib/server/index.js.map +4 -4
- package/dist/lib/shared/index.js +3 -3
- package/dist/lib/test-utils/index.js +1 -1
- package/lib/client/core/ComponentBuilder.ts +1 -1
- package/lib/client/core/builders/embedBuilder.ts +2 -2
- package/lib/client/routing/Router.tsx +6 -0
- package/lib/client/templateEngine.test.ts +178 -0
- package/lib/client/templateEngine.ts +1 -2
- package/lib/server/astro/cmsPageEmitter.ts +420 -0
- package/lib/server/astro/componentEmitter.ts +150 -17
- package/lib/server/astro/nodeToAstro.test.ts +1101 -0
- package/lib/server/astro/nodeToAstro.ts +869 -37
- package/lib/server/astro/pageEmitter.ts +43 -3
- package/lib/server/astro/tailwindMapper.ts +69 -8
- package/lib/server/astro/templateTransformer.ts +107 -0
- package/lib/server/index.ts +26 -3
- package/lib/server/routes/api/components.ts +62 -0
- package/lib/server/routes/api/core-routes.ts +8 -0
- package/lib/server/services/configService.ts +12 -0
- package/lib/server/ssr/htmlGenerator.ts +0 -5
- package/lib/server/ssr/imageMetadata.ts +3 -3
- package/lib/server/ssr/ssrRenderer.ts +78 -29
- package/lib/server/webflow/buildWebflow.ts +415 -0
- package/lib/server/webflow/index.ts +22 -0
- package/lib/server/webflow/nodeToWebflow.ts +423 -0
- package/lib/server/webflow/styleMapper.ts +241 -0
- package/lib/server/webflow/types.ts +196 -0
- package/lib/shared/constants.ts +4 -0
- package/lib/shared/types/components.ts +9 -4
- package/lib/shared/validation/propValidator.ts +2 -1
- package/lib/shared/validation/schemas.ts +4 -1
- package/package.json +1 -1
- package/templates/index-router.html +0 -5
- package/dist/chunks/chunk-A6KWUEA6.js.map +0 -7
- package/dist/chunks/chunk-KULPBDC7.js.map +0 -7
- package/dist/chunks/chunk-NV25WXCA.js.map +0 -7
- package/dist/chunks/chunk-P3FX5HJM.js.map +0 -7
- package/dist/chunks/chunk-W6HDII4T.js.map +0 -7
- /package/dist/chunks/{chunk-WK5XLASY.js.map → chunk-EQOSDQS2.js.map} +0 -0
- /package/dist/chunks/{configService-TXBNUBBL.js.map → configService-MICL4S2L.js.map} +0 -0
- /package/dist/chunks/{constants-5CRJRQNR.js.map → constants-ZEU4TZCA.js.map} +0 -0
|
@@ -0,0 +1,1101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for nodeToAstro.ts
|
|
3
|
+
* Tests the core ComponentNode -> Astro template markup converter
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, test, expect, beforeEach } from 'bun:test';
|
|
7
|
+
import { nodeToAstro, type AstroEmitContext } from './nodeToAstro';
|
|
8
|
+
import { DEFAULT_BREAKPOINTS } from '../../shared/breakpoints';
|
|
9
|
+
import type { ComponentNode } from '../../shared/types';
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Helpers
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
function createContext(overrides?: Partial<AstroEmitContext>): AstroEmitContext {
|
|
16
|
+
return {
|
|
17
|
+
imports: new Set(),
|
|
18
|
+
isComponentDef: false,
|
|
19
|
+
componentProps: {},
|
|
20
|
+
globalComponents: {},
|
|
21
|
+
indent: 0,
|
|
22
|
+
ssrFallbacks: new Map(),
|
|
23
|
+
elementPath: [0],
|
|
24
|
+
fileType: 'page',
|
|
25
|
+
fileName: 'test-page',
|
|
26
|
+
breakpoints: DEFAULT_BREAKPOINTS,
|
|
27
|
+
...overrides,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Strip leading/trailing whitespace from each line for easier comparison */
|
|
32
|
+
function trimLines(s: string): string {
|
|
33
|
+
return s
|
|
34
|
+
.split('\n')
|
|
35
|
+
.map((l) => l.trimEnd())
|
|
36
|
+
.join('\n')
|
|
37
|
+
.trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Tests
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
describe('nodeToAstro', () => {
|
|
45
|
+
// =========================================================================
|
|
46
|
+
// Null / undefined / primitives
|
|
47
|
+
// =========================================================================
|
|
48
|
+
|
|
49
|
+
describe('primitives and nullish values', () => {
|
|
50
|
+
test('should return empty string for null', () => {
|
|
51
|
+
const ctx = createContext();
|
|
52
|
+
expect(nodeToAstro(null, ctx)).toBe('');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('should return empty string for undefined', () => {
|
|
56
|
+
const ctx = createContext();
|
|
57
|
+
expect(nodeToAstro(undefined, ctx)).toBe('');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('should emit plain text for a string', () => {
|
|
61
|
+
const ctx = createContext();
|
|
62
|
+
const result = nodeToAstro('Hello world', ctx);
|
|
63
|
+
expect(result).toContain('Hello world');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('should escape special characters in strings', () => {
|
|
67
|
+
const ctx = createContext();
|
|
68
|
+
const result = nodeToAstro('<script>alert("xss")</script>', ctx);
|
|
69
|
+
expect(result).toContain('<script>');
|
|
70
|
+
expect(result).toContain('"');
|
|
71
|
+
expect(result).not.toContain('<script>');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('should escape ampersands in strings', () => {
|
|
75
|
+
const ctx = createContext();
|
|
76
|
+
const result = nodeToAstro('Tom & Jerry', ctx);
|
|
77
|
+
expect(result).toContain('&');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('should emit number as-is', () => {
|
|
81
|
+
const ctx = createContext();
|
|
82
|
+
const result = nodeToAstro(42, ctx);
|
|
83
|
+
expect(result).toContain('42');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// =========================================================================
|
|
88
|
+
// HTML nodes (type: 'node')
|
|
89
|
+
// =========================================================================
|
|
90
|
+
|
|
91
|
+
describe('HTML nodes', () => {
|
|
92
|
+
test('should emit a simple div', () => {
|
|
93
|
+
const ctx = createContext();
|
|
94
|
+
const node: ComponentNode = {
|
|
95
|
+
type: 'node',
|
|
96
|
+
tag: 'div',
|
|
97
|
+
children: [],
|
|
98
|
+
} as any;
|
|
99
|
+
const result = nodeToAstro(node, ctx);
|
|
100
|
+
expect(result).toContain('<div');
|
|
101
|
+
// Empty children -> self-closing
|
|
102
|
+
expect(result).toContain('/>');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('should emit div with text child', () => {
|
|
106
|
+
const ctx = createContext();
|
|
107
|
+
const node: ComponentNode = {
|
|
108
|
+
type: 'node',
|
|
109
|
+
tag: 'div',
|
|
110
|
+
children: ['Hello'],
|
|
111
|
+
} as any;
|
|
112
|
+
const result = nodeToAstro(node, ctx);
|
|
113
|
+
expect(result).toContain('<div>');
|
|
114
|
+
expect(result).toContain('Hello');
|
|
115
|
+
expect(result).toContain('</div>');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('should emit self-closing void element (img)', () => {
|
|
119
|
+
const ctx = createContext();
|
|
120
|
+
const node: ComponentNode = {
|
|
121
|
+
type: 'node',
|
|
122
|
+
tag: 'img',
|
|
123
|
+
attributes: { src: 'test.png', alt: 'Test' },
|
|
124
|
+
children: [],
|
|
125
|
+
} as any;
|
|
126
|
+
const result = nodeToAstro(node, ctx);
|
|
127
|
+
expect(result).toContain('<img');
|
|
128
|
+
expect(result).toContain('src="test.png"');
|
|
129
|
+
expect(result).toContain('alt="Test"');
|
|
130
|
+
expect(result).toContain('/>');
|
|
131
|
+
expect(result).not.toContain('</img>');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('should emit self-closing void element (br)', () => {
|
|
135
|
+
const ctx = createContext();
|
|
136
|
+
const node: ComponentNode = {
|
|
137
|
+
type: 'node',
|
|
138
|
+
tag: 'br',
|
|
139
|
+
children: [],
|
|
140
|
+
} as any;
|
|
141
|
+
const result = nodeToAstro(node, ctx);
|
|
142
|
+
expect(result).toContain('<br');
|
|
143
|
+
expect(result).toContain('/>');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('should emit self-closing void element (input)', () => {
|
|
147
|
+
const ctx = createContext();
|
|
148
|
+
const node: ComponentNode = {
|
|
149
|
+
type: 'node',
|
|
150
|
+
tag: 'input',
|
|
151
|
+
attributes: { type: 'text', placeholder: 'Enter...' },
|
|
152
|
+
children: [],
|
|
153
|
+
} as any;
|
|
154
|
+
const result = nodeToAstro(node, ctx);
|
|
155
|
+
expect(result).toContain('<input');
|
|
156
|
+
expect(result).toContain('type="text"');
|
|
157
|
+
expect(result).toContain('/>');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('should emit nested children', () => {
|
|
161
|
+
const ctx = createContext();
|
|
162
|
+
const node: ComponentNode = {
|
|
163
|
+
type: 'node',
|
|
164
|
+
tag: 'div',
|
|
165
|
+
children: [
|
|
166
|
+
{
|
|
167
|
+
type: 'node',
|
|
168
|
+
tag: 'span',
|
|
169
|
+
children: ['inner'],
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
} as any;
|
|
173
|
+
const result = nodeToAstro(node, ctx);
|
|
174
|
+
expect(result).toContain('<div>');
|
|
175
|
+
expect(result).toContain('<span>');
|
|
176
|
+
expect(result).toContain('inner');
|
|
177
|
+
expect(result).toContain('</span>');
|
|
178
|
+
expect(result).toContain('</div>');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('should emit boolean attributes', () => {
|
|
182
|
+
const ctx = createContext();
|
|
183
|
+
const node: ComponentNode = {
|
|
184
|
+
type: 'node',
|
|
185
|
+
tag: 'input',
|
|
186
|
+
attributes: { disabled: true, hidden: false },
|
|
187
|
+
children: [],
|
|
188
|
+
} as any;
|
|
189
|
+
const result = nodeToAstro(node, ctx);
|
|
190
|
+
expect(result).toContain('disabled');
|
|
191
|
+
expect(result).not.toContain('hidden');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('should lowercase capitalized HTML tags', () => {
|
|
195
|
+
const ctx = createContext();
|
|
196
|
+
const node: ComponentNode = {
|
|
197
|
+
type: 'node',
|
|
198
|
+
tag: 'Section',
|
|
199
|
+
children: ['content'],
|
|
200
|
+
} as any;
|
|
201
|
+
const result = nodeToAstro(node, ctx);
|
|
202
|
+
expect(result).toContain('<section>');
|
|
203
|
+
expect(result).toContain('</section>');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('should handle indentation via context', () => {
|
|
207
|
+
const ctx = createContext({ indent: 2 });
|
|
208
|
+
const result = nodeToAstro('text', ctx);
|
|
209
|
+
expect(result).toMatch(/^ text/); // 2 * 2 spaces = 4 spaces
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// =========================================================================
|
|
214
|
+
// Component instances (type: 'component')
|
|
215
|
+
// =========================================================================
|
|
216
|
+
|
|
217
|
+
describe('component instances', () => {
|
|
218
|
+
test('should emit component with PascalCase name', () => {
|
|
219
|
+
const ctx = createContext();
|
|
220
|
+
const node: ComponentNode = {
|
|
221
|
+
type: 'component',
|
|
222
|
+
component: 'Button',
|
|
223
|
+
children: [],
|
|
224
|
+
} as any;
|
|
225
|
+
const result = nodeToAstro(node, ctx);
|
|
226
|
+
expect(result).toContain('<Button');
|
|
227
|
+
expect(result).toContain('/>');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test('should add component name to imports set', () => {
|
|
231
|
+
const ctx = createContext();
|
|
232
|
+
const node: ComponentNode = {
|
|
233
|
+
type: 'component',
|
|
234
|
+
component: 'Card',
|
|
235
|
+
children: [],
|
|
236
|
+
} as any;
|
|
237
|
+
nodeToAstro(node, ctx);
|
|
238
|
+
expect(ctx.imports.has('Card')).toBe(true);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('should emit component with string props', () => {
|
|
242
|
+
const ctx = createContext();
|
|
243
|
+
const node: ComponentNode = {
|
|
244
|
+
type: 'component',
|
|
245
|
+
component: 'Button',
|
|
246
|
+
props: { label: 'Click me', variant: 'primary' },
|
|
247
|
+
children: [],
|
|
248
|
+
} as any;
|
|
249
|
+
const result = nodeToAstro(node, ctx);
|
|
250
|
+
expect(result).toContain('label="Click me"');
|
|
251
|
+
expect(result).toContain('variant="primary"');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('should emit component with number props', () => {
|
|
255
|
+
const ctx = createContext();
|
|
256
|
+
const node: ComponentNode = {
|
|
257
|
+
type: 'component',
|
|
258
|
+
component: 'Grid',
|
|
259
|
+
props: { columns: 3 },
|
|
260
|
+
children: [],
|
|
261
|
+
} as any;
|
|
262
|
+
const result = nodeToAstro(node, ctx);
|
|
263
|
+
expect(result).toContain('columns={3}');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('should emit component with boolean props', () => {
|
|
267
|
+
const ctx = createContext();
|
|
268
|
+
const node: ComponentNode = {
|
|
269
|
+
type: 'component',
|
|
270
|
+
component: 'Modal',
|
|
271
|
+
props: { isOpen: true, hasOverlay: false },
|
|
272
|
+
children: [],
|
|
273
|
+
} as any;
|
|
274
|
+
const result = nodeToAstro(node, ctx);
|
|
275
|
+
expect(result).toContain('isOpen={true}');
|
|
276
|
+
expect(result).toContain('hasOverlay={false}');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('should emit component with children', () => {
|
|
280
|
+
const ctx = createContext();
|
|
281
|
+
const node: ComponentNode = {
|
|
282
|
+
type: 'component',
|
|
283
|
+
component: 'Card',
|
|
284
|
+
children: ['Card content'],
|
|
285
|
+
} as any;
|
|
286
|
+
const result = nodeToAstro(node, ctx);
|
|
287
|
+
expect(result).toContain('<Card>');
|
|
288
|
+
expect(result).toContain('Card content');
|
|
289
|
+
expect(result).toContain('</Card>');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('should skip children key in props', () => {
|
|
293
|
+
const ctx = createContext();
|
|
294
|
+
const node: ComponentNode = {
|
|
295
|
+
type: 'component',
|
|
296
|
+
component: 'Widget',
|
|
297
|
+
props: { children: 'ignored', title: 'hello' },
|
|
298
|
+
children: [],
|
|
299
|
+
} as any;
|
|
300
|
+
const result = nodeToAstro(node, ctx);
|
|
301
|
+
expect(result).not.toContain('children=');
|
|
302
|
+
expect(result).toContain('title="hello"');
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test('should emit template expressions in props when inside component def', () => {
|
|
306
|
+
const ctx = createContext({ isComponentDef: true });
|
|
307
|
+
const node: ComponentNode = {
|
|
308
|
+
type: 'component',
|
|
309
|
+
component: 'Icon',
|
|
310
|
+
props: { name: '{{iconName}}' },
|
|
311
|
+
children: [],
|
|
312
|
+
} as any;
|
|
313
|
+
const result = nodeToAstro(node, ctx);
|
|
314
|
+
expect(result).toContain('name={iconName}');
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// =========================================================================
|
|
319
|
+
// Slot markers (type: 'slot')
|
|
320
|
+
// =========================================================================
|
|
321
|
+
|
|
322
|
+
describe('slot markers', () => {
|
|
323
|
+
test('should emit a self-closing slot', () => {
|
|
324
|
+
const ctx = createContext();
|
|
325
|
+
const node: ComponentNode = {
|
|
326
|
+
type: 'slot',
|
|
327
|
+
} as any;
|
|
328
|
+
const result = nodeToAstro(node, ctx);
|
|
329
|
+
expect(result).toContain('<slot />');
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test('should emit slot with default content', () => {
|
|
333
|
+
const ctx = createContext();
|
|
334
|
+
const node: ComponentNode = {
|
|
335
|
+
type: 'slot',
|
|
336
|
+
default: ['Fallback content'],
|
|
337
|
+
} as any;
|
|
338
|
+
const result = nodeToAstro(node, ctx);
|
|
339
|
+
expect(result).toContain('<slot>');
|
|
340
|
+
expect(result).toContain('Fallback content');
|
|
341
|
+
expect(result).toContain('</slot>');
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test('should emit self-closing slot when default content is empty', () => {
|
|
345
|
+
const ctx = createContext();
|
|
346
|
+
const node: ComponentNode = {
|
|
347
|
+
type: 'slot',
|
|
348
|
+
default: [],
|
|
349
|
+
} as any;
|
|
350
|
+
const result = nodeToAstro(node, ctx);
|
|
351
|
+
expect(result).toContain('<slot />');
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// =========================================================================
|
|
356
|
+
// Embed nodes (type: 'embed')
|
|
357
|
+
// =========================================================================
|
|
358
|
+
|
|
359
|
+
describe('embed nodes', () => {
|
|
360
|
+
test('should emit embed node with html content', () => {
|
|
361
|
+
const ctx = createContext();
|
|
362
|
+
const node: ComponentNode = {
|
|
363
|
+
type: 'embed',
|
|
364
|
+
html: '<iframe src="https://example.com"></iframe>',
|
|
365
|
+
children: [],
|
|
366
|
+
} as any;
|
|
367
|
+
const result = nodeToAstro(node, ctx);
|
|
368
|
+
expect(result).toContain('<div');
|
|
369
|
+
expect(result).toContain('oem');
|
|
370
|
+
expect(result).toContain('Fragment set:html');
|
|
371
|
+
expect(result).toContain('</div>');
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test('should emit embed node with empty html', () => {
|
|
375
|
+
const ctx = createContext();
|
|
376
|
+
const node: ComponentNode = {
|
|
377
|
+
type: 'embed',
|
|
378
|
+
html: '',
|
|
379
|
+
children: [],
|
|
380
|
+
} as any;
|
|
381
|
+
const result = nodeToAstro(node, ctx);
|
|
382
|
+
expect(result).toContain('oem');
|
|
383
|
+
expect(result).toContain('Fragment set:html');
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test('should escape template literals in embed html', () => {
|
|
387
|
+
const ctx = createContext();
|
|
388
|
+
const node: ComponentNode = {
|
|
389
|
+
type: 'embed',
|
|
390
|
+
html: 'has `backtick` and ${expr}',
|
|
391
|
+
children: [],
|
|
392
|
+
} as any;
|
|
393
|
+
const result = nodeToAstro(node, ctx);
|
|
394
|
+
expect(result).toContain('\\`');
|
|
395
|
+
expect(result).toContain('\\${');
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test('should emit template expression embed as Fragment set:html when in component def', () => {
|
|
399
|
+
const ctx = createContext({ isComponentDef: true });
|
|
400
|
+
const node: ComponentNode = {
|
|
401
|
+
type: 'embed',
|
|
402
|
+
html: '{{icon}}',
|
|
403
|
+
children: [],
|
|
404
|
+
} as any;
|
|
405
|
+
const result = nodeToAstro(node, ctx);
|
|
406
|
+
expect(result).toContain('Fragment set:html={icon}');
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// =========================================================================
|
|
411
|
+
// Link nodes (type: 'link')
|
|
412
|
+
// =========================================================================
|
|
413
|
+
|
|
414
|
+
describe('link nodes', () => {
|
|
415
|
+
test('should emit a link with href', () => {
|
|
416
|
+
const ctx = createContext();
|
|
417
|
+
const node: ComponentNode = {
|
|
418
|
+
type: 'link',
|
|
419
|
+
href: '/about',
|
|
420
|
+
children: ['About'],
|
|
421
|
+
} as any;
|
|
422
|
+
const result = nodeToAstro(node, ctx);
|
|
423
|
+
expect(result).toContain('<a');
|
|
424
|
+
expect(result).toContain('href="/about"');
|
|
425
|
+
expect(result).toContain('olink');
|
|
426
|
+
expect(result).toContain('About');
|
|
427
|
+
expect(result).toContain('</a>');
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test('should emit self-closing link when no children', () => {
|
|
431
|
+
const ctx = createContext();
|
|
432
|
+
const node: ComponentNode = {
|
|
433
|
+
type: 'link',
|
|
434
|
+
href: '/home',
|
|
435
|
+
children: [],
|
|
436
|
+
} as any;
|
|
437
|
+
const result = nodeToAstro(node, ctx);
|
|
438
|
+
expect(result).toContain('<a');
|
|
439
|
+
expect(result).toContain('href="/home"');
|
|
440
|
+
expect(result).toContain('/>');
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test('should default to href="#" when href is not a string', () => {
|
|
444
|
+
const ctx = createContext();
|
|
445
|
+
const node: ComponentNode = {
|
|
446
|
+
type: 'link',
|
|
447
|
+
href: undefined,
|
|
448
|
+
children: ['Link'],
|
|
449
|
+
} as any;
|
|
450
|
+
const result = nodeToAstro(node, ctx);
|
|
451
|
+
expect(result).toContain('href="#"');
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test('should localize internal href with locale', () => {
|
|
455
|
+
const ctx = createContext({
|
|
456
|
+
locale: 'fr',
|
|
457
|
+
i18nDefaultLocale: 'en',
|
|
458
|
+
});
|
|
459
|
+
const node: ComponentNode = {
|
|
460
|
+
type: 'link',
|
|
461
|
+
href: '/about',
|
|
462
|
+
children: ['A propos'],
|
|
463
|
+
} as any;
|
|
464
|
+
const result = nodeToAstro(node, ctx);
|
|
465
|
+
expect(result).toContain('/fr/about');
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
test('should not localize href for default locale', () => {
|
|
469
|
+
const ctx = createContext({
|
|
470
|
+
locale: 'en',
|
|
471
|
+
i18nDefaultLocale: 'en',
|
|
472
|
+
});
|
|
473
|
+
const node: ComponentNode = {
|
|
474
|
+
type: 'link',
|
|
475
|
+
href: '/about',
|
|
476
|
+
children: ['About'],
|
|
477
|
+
} as any;
|
|
478
|
+
const result = nodeToAstro(node, ctx);
|
|
479
|
+
expect(result).toContain('href="/about"');
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test('should not localize external URLs', () => {
|
|
483
|
+
const ctx = createContext({
|
|
484
|
+
locale: 'fr',
|
|
485
|
+
i18nDefaultLocale: 'en',
|
|
486
|
+
});
|
|
487
|
+
const node: ComponentNode = {
|
|
488
|
+
type: 'link',
|
|
489
|
+
href: 'https://example.com',
|
|
490
|
+
children: ['External'],
|
|
491
|
+
} as any;
|
|
492
|
+
const result = nodeToAstro(node, ctx);
|
|
493
|
+
expect(result).toContain('href="https://example.com"');
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test('should resolve template expression href in component def', () => {
|
|
497
|
+
const ctx = createContext({ isComponentDef: true });
|
|
498
|
+
const node: ComponentNode = {
|
|
499
|
+
type: 'link',
|
|
500
|
+
href: '{{url}}',
|
|
501
|
+
children: ['Click'],
|
|
502
|
+
} as any;
|
|
503
|
+
const result = nodeToAstro(node, ctx);
|
|
504
|
+
expect(result).toContain('href={url}');
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test('should handle link prop type with ?.href accessor', () => {
|
|
508
|
+
const ctx = createContext({
|
|
509
|
+
isComponentDef: true,
|
|
510
|
+
componentProps: {
|
|
511
|
+
ctaLink: { type: 'link', label: 'CTA Link' } as any,
|
|
512
|
+
},
|
|
513
|
+
});
|
|
514
|
+
const node: ComponentNode = {
|
|
515
|
+
type: 'link',
|
|
516
|
+
href: '{{ctaLink}}',
|
|
517
|
+
children: ['CTA'],
|
|
518
|
+
} as any;
|
|
519
|
+
const result = nodeToAstro(node, ctx);
|
|
520
|
+
expect(result).toContain('ctaLink?.href');
|
|
521
|
+
});
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
// =========================================================================
|
|
525
|
+
// List nodes (type: 'list')
|
|
526
|
+
// =========================================================================
|
|
527
|
+
|
|
528
|
+
describe('list nodes', () => {
|
|
529
|
+
test('should emit prop list with .map() in component def', () => {
|
|
530
|
+
const ctx = createContext({ isComponentDef: true });
|
|
531
|
+
const node: ComponentNode = {
|
|
532
|
+
type: 'list',
|
|
533
|
+
sourceType: 'prop',
|
|
534
|
+
source: '{{items}}',
|
|
535
|
+
children: [
|
|
536
|
+
{
|
|
537
|
+
type: 'node',
|
|
538
|
+
tag: 'div',
|
|
539
|
+
children: ['{{item.name}}'],
|
|
540
|
+
},
|
|
541
|
+
],
|
|
542
|
+
} as any;
|
|
543
|
+
const result = nodeToAstro(node, ctx);
|
|
544
|
+
expect(result).toContain('items.map(');
|
|
545
|
+
expect(result).toContain('(item,');
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
test('should fall back to SSR fallback for prop list on page', () => {
|
|
549
|
+
const ctx = createContext({
|
|
550
|
+
isComponentDef: false,
|
|
551
|
+
ssrFallbacks: new Map([['0', '<div>fallback</div>']]),
|
|
552
|
+
});
|
|
553
|
+
const node: ComponentNode = {
|
|
554
|
+
type: 'list',
|
|
555
|
+
sourceType: 'prop',
|
|
556
|
+
source: 'items',
|
|
557
|
+
children: [],
|
|
558
|
+
} as any;
|
|
559
|
+
const result = nodeToAstro(node, ctx);
|
|
560
|
+
expect(result).toContain('Fragment set:html');
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
test('should emit collection list with getCollection import', () => {
|
|
564
|
+
const ctx = createContext({ isComponentDef: false });
|
|
565
|
+
const node: ComponentNode = {
|
|
566
|
+
type: 'list',
|
|
567
|
+
sourceType: 'collection',
|
|
568
|
+
source: 'posts',
|
|
569
|
+
children: [
|
|
570
|
+
{
|
|
571
|
+
type: 'node',
|
|
572
|
+
tag: 'div',
|
|
573
|
+
children: [],
|
|
574
|
+
},
|
|
575
|
+
],
|
|
576
|
+
} as any;
|
|
577
|
+
const result = nodeToAstro(node, ctx);
|
|
578
|
+
expect(result).toContain('postsList.map(');
|
|
579
|
+
expect(ctx.astroImports?.has('getCollection')).toBe(true);
|
|
580
|
+
expect(ctx.frontmatterLines?.[0]).toContain("getCollection('posts')");
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
test('should handle list with offset and limit', () => {
|
|
584
|
+
const ctx = createContext({ isComponentDef: true });
|
|
585
|
+
const node: ComponentNode = {
|
|
586
|
+
type: 'list',
|
|
587
|
+
sourceType: 'prop',
|
|
588
|
+
source: '{{items}}',
|
|
589
|
+
offset: 2,
|
|
590
|
+
limit: 5,
|
|
591
|
+
children: [],
|
|
592
|
+
} as any;
|
|
593
|
+
const result = nodeToAstro(node, ctx);
|
|
594
|
+
expect(result).toContain('items.slice(2, 7)');
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
test('should handle legacy cms-list type', () => {
|
|
598
|
+
const ctx = createContext({ isComponentDef: false });
|
|
599
|
+
const node = {
|
|
600
|
+
type: 'cms-list',
|
|
601
|
+
sourceType: 'collection',
|
|
602
|
+
source: 'articles',
|
|
603
|
+
children: [],
|
|
604
|
+
} as any;
|
|
605
|
+
const result = nodeToAstro(node, ctx);
|
|
606
|
+
expect(result).toContain('articlesList.map(');
|
|
607
|
+
});
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
// =========================================================================
|
|
611
|
+
// Array of nodes
|
|
612
|
+
// =========================================================================
|
|
613
|
+
|
|
614
|
+
describe('array of nodes', () => {
|
|
615
|
+
test('should emit each node in array', () => {
|
|
616
|
+
const ctx = createContext();
|
|
617
|
+
const nodes: ComponentNode[] = [
|
|
618
|
+
{ type: 'node', tag: 'div', children: ['First'] } as any,
|
|
619
|
+
{ type: 'node', tag: 'span', children: ['Second'] } as any,
|
|
620
|
+
];
|
|
621
|
+
const result = nodeToAstro(nodes, ctx);
|
|
622
|
+
expect(result).toContain('First');
|
|
623
|
+
expect(result).toContain('Second');
|
|
624
|
+
expect(result).toContain('<div>');
|
|
625
|
+
expect(result).toContain('<span>');
|
|
626
|
+
});
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
// =========================================================================
|
|
630
|
+
// Raw HTML prefix
|
|
631
|
+
// =========================================================================
|
|
632
|
+
|
|
633
|
+
describe('raw HTML prefix', () => {
|
|
634
|
+
test('should emit raw HTML via Fragment set:html', () => {
|
|
635
|
+
const ctx = createContext();
|
|
636
|
+
const rawText = '<!--MENO_RAW_HTML--><strong>bold</strong>';
|
|
637
|
+
const result = nodeToAstro(rawText, ctx);
|
|
638
|
+
expect(result).toContain('Fragment set:html');
|
|
639
|
+
expect(result).toContain('<strong>bold</strong>');
|
|
640
|
+
});
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
// =========================================================================
|
|
644
|
+
// Template expressions in text (component def context)
|
|
645
|
+
// =========================================================================
|
|
646
|
+
|
|
647
|
+
describe('template expressions', () => {
|
|
648
|
+
test('should resolve template in text when in component def', () => {
|
|
649
|
+
const ctx = createContext({ isComponentDef: true });
|
|
650
|
+
const result = nodeToAstro('{{title}}', ctx);
|
|
651
|
+
expect(result).toContain('{title}');
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
test('should resolve mixed templates', () => {
|
|
655
|
+
const ctx = createContext({ isComponentDef: true });
|
|
656
|
+
const result = nodeToAstro('Hello {{name}}, welcome!', ctx);
|
|
657
|
+
expect(result).toContain('{name}');
|
|
658
|
+
expect(result).toContain('Hello');
|
|
659
|
+
expect(result).toContain('welcome!');
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
test('should not resolve templates when not in component def', () => {
|
|
663
|
+
const ctx = createContext({ isComponentDef: false });
|
|
664
|
+
const result = nodeToAstro('{{title}}', ctx);
|
|
665
|
+
expect(result).toContain('{{title}}');
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
test('should resolve rich-text prop as Fragment set:html', () => {
|
|
669
|
+
const ctx = createContext({
|
|
670
|
+
isComponentDef: true,
|
|
671
|
+
componentProps: {
|
|
672
|
+
content: { type: 'rich-text', label: 'Content' } as any,
|
|
673
|
+
},
|
|
674
|
+
});
|
|
675
|
+
const result = nodeToAstro('{{content}}', ctx);
|
|
676
|
+
expect(result).toContain('Fragment set:html={content}');
|
|
677
|
+
});
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
// =========================================================================
|
|
681
|
+
// CMS mode
|
|
682
|
+
// =========================================================================
|
|
683
|
+
|
|
684
|
+
describe('CMS mode', () => {
|
|
685
|
+
test('should transform CMS template expressions in text', () => {
|
|
686
|
+
const ctx = createContext({
|
|
687
|
+
cmsMode: true,
|
|
688
|
+
cmsEntryBinding: 'entry',
|
|
689
|
+
});
|
|
690
|
+
const result = nodeToAstro('{{cms.title}}', ctx);
|
|
691
|
+
expect(result).toContain('entry.data.title');
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
test('should transform CMS expressions in attributes', () => {
|
|
695
|
+
const ctx = createContext({
|
|
696
|
+
cmsMode: true,
|
|
697
|
+
cmsEntryBinding: 'entry',
|
|
698
|
+
});
|
|
699
|
+
const node: ComponentNode = {
|
|
700
|
+
type: 'node',
|
|
701
|
+
tag: 'img',
|
|
702
|
+
attributes: { src: '{{cms.image}}', alt: '{{cms.imageAlt}}' },
|
|
703
|
+
children: [],
|
|
704
|
+
} as any;
|
|
705
|
+
const result = nodeToAstro(node, ctx);
|
|
706
|
+
expect(result).toContain('entry.data.image');
|
|
707
|
+
expect(result).toContain('entry.data.imageAlt');
|
|
708
|
+
});
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
// =========================================================================
|
|
712
|
+
// Conditional rendering (if)
|
|
713
|
+
// =========================================================================
|
|
714
|
+
|
|
715
|
+
describe('conditional rendering', () => {
|
|
716
|
+
test('should wrap node in conditional when if is a template string in component def', () => {
|
|
717
|
+
const ctx = createContext({ isComponentDef: true });
|
|
718
|
+
const node: ComponentNode = {
|
|
719
|
+
type: 'node',
|
|
720
|
+
tag: 'div',
|
|
721
|
+
if: '{{showBanner}}',
|
|
722
|
+
children: ['Banner'],
|
|
723
|
+
} as any;
|
|
724
|
+
const result = nodeToAstro(node, ctx);
|
|
725
|
+
expect(result).toContain('{showBanner && (');
|
|
726
|
+
expect(result).toContain('Banner');
|
|
727
|
+
expect(result).toContain(')}');
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
test('should emit hidden comment when if is false boolean', () => {
|
|
731
|
+
const ctx = createContext();
|
|
732
|
+
const node: ComponentNode = {
|
|
733
|
+
type: 'node',
|
|
734
|
+
tag: 'div',
|
|
735
|
+
if: false,
|
|
736
|
+
children: ['Hidden'],
|
|
737
|
+
} as any;
|
|
738
|
+
const result = nodeToAstro(node, ctx);
|
|
739
|
+
// The node still emits but with a hidden comment prepended
|
|
740
|
+
expect(result).toContain('{/* hidden */}');
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
test('should emit nothing extra when if is true boolean', () => {
|
|
744
|
+
const ctx = createContext();
|
|
745
|
+
const node: ComponentNode = {
|
|
746
|
+
type: 'node',
|
|
747
|
+
tag: 'div',
|
|
748
|
+
if: true,
|
|
749
|
+
children: ['Visible'],
|
|
750
|
+
} as any;
|
|
751
|
+
const result = nodeToAstro(node, ctx);
|
|
752
|
+
expect(result).toContain('<div>');
|
|
753
|
+
expect(result).toContain('Visible');
|
|
754
|
+
expect(result).not.toContain('{');
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
test('should handle CMS conditional', () => {
|
|
758
|
+
const ctx = createContext({
|
|
759
|
+
cmsMode: true,
|
|
760
|
+
cmsEntryBinding: 'entry',
|
|
761
|
+
});
|
|
762
|
+
const node: ComponentNode = {
|
|
763
|
+
type: 'node',
|
|
764
|
+
tag: 'div',
|
|
765
|
+
if: '{{cms.showSection}}',
|
|
766
|
+
children: ['Section'],
|
|
767
|
+
} as any;
|
|
768
|
+
const result = nodeToAstro(node, ctx);
|
|
769
|
+
expect(result).toContain('entry.data.showSection');
|
|
770
|
+
});
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
// =========================================================================
|
|
774
|
+
// SSR fallback
|
|
775
|
+
// =========================================================================
|
|
776
|
+
|
|
777
|
+
describe('SSR fallback', () => {
|
|
778
|
+
test('should emit SSR fallback when available', () => {
|
|
779
|
+
const ctx = createContext({
|
|
780
|
+
ssrFallbacks: new Map([['0', '<div>rendered</div>']]),
|
|
781
|
+
});
|
|
782
|
+
const node: ComponentNode = {
|
|
783
|
+
type: 'locale-list',
|
|
784
|
+
children: [],
|
|
785
|
+
} as any;
|
|
786
|
+
const result = nodeToAstro(node, ctx);
|
|
787
|
+
// locale-list without i18nConfig falls back
|
|
788
|
+
expect(result).toContain('Fragment set:html');
|
|
789
|
+
expect(result).toContain('<div>rendered</div>');
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
test('should emit comment when SSR fallback not available', () => {
|
|
793
|
+
const ctx = createContext();
|
|
794
|
+
const node: ComponentNode = {
|
|
795
|
+
type: 'locale-list',
|
|
796
|
+
children: [],
|
|
797
|
+
} as any;
|
|
798
|
+
const result = nodeToAstro(node, ctx);
|
|
799
|
+
expect(result).toContain('Complex node - SSR fallback not available');
|
|
800
|
+
});
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
// =========================================================================
|
|
804
|
+
// Unknown node type
|
|
805
|
+
// =========================================================================
|
|
806
|
+
|
|
807
|
+
describe('unknown node type', () => {
|
|
808
|
+
test('should emit fallback for unknown node type', () => {
|
|
809
|
+
const ctx = createContext();
|
|
810
|
+
const node = { type: 'custom-unknown' } as any;
|
|
811
|
+
const result = nodeToAstro(node, ctx);
|
|
812
|
+
// Falls through default case to emitFallback
|
|
813
|
+
expect(result).toContain('SSR fallback not available');
|
|
814
|
+
});
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
// =========================================================================
|
|
818
|
+
// Styles -> Tailwind classes
|
|
819
|
+
// =========================================================================
|
|
820
|
+
|
|
821
|
+
describe('styles to Tailwind classes', () => {
|
|
822
|
+
test('should emit Tailwind classes from style object', () => {
|
|
823
|
+
const ctx = createContext();
|
|
824
|
+
const node: ComponentNode = {
|
|
825
|
+
type: 'node',
|
|
826
|
+
tag: 'div',
|
|
827
|
+
style: { display: 'flex', justifyContent: 'center' },
|
|
828
|
+
children: ['styled'],
|
|
829
|
+
} as any;
|
|
830
|
+
const result = nodeToAstro(node, ctx);
|
|
831
|
+
expect(result).toContain('class="');
|
|
832
|
+
// The exact Tailwind classes depend on the tailwindMapper, but the class attr should be present
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
test('should not emit class attribute when no styles', () => {
|
|
836
|
+
const ctx = createContext();
|
|
837
|
+
const node: ComponentNode = {
|
|
838
|
+
type: 'node',
|
|
839
|
+
tag: 'div',
|
|
840
|
+
children: ['no style'],
|
|
841
|
+
} as any;
|
|
842
|
+
const result = nodeToAstro(node, ctx);
|
|
843
|
+
expect(result).not.toContain('class=');
|
|
844
|
+
});
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
// =========================================================================
|
|
848
|
+
// Image node (type: 'image')
|
|
849
|
+
// =========================================================================
|
|
850
|
+
|
|
851
|
+
describe('image type node', () => {
|
|
852
|
+
test('should emit img tag for image type node', () => {
|
|
853
|
+
const ctx = createContext();
|
|
854
|
+
const node = {
|
|
855
|
+
type: 'image',
|
|
856
|
+
src: '/images/photo.jpg',
|
|
857
|
+
alt: 'A photo',
|
|
858
|
+
} as any;
|
|
859
|
+
const result = nodeToAstro(node, ctx);
|
|
860
|
+
expect(result).toContain('<img');
|
|
861
|
+
expect(result).toContain('src="/images/photo.jpg"');
|
|
862
|
+
expect(result).toContain('alt="A photo"');
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
test('should emit picture element when avif metadata is available', () => {
|
|
866
|
+
const ctx = createContext({
|
|
867
|
+
imageMetadataMap: new Map([
|
|
868
|
+
['/images/photo.jpg', {
|
|
869
|
+
width: 800,
|
|
870
|
+
height: 600,
|
|
871
|
+
srcset: '/images/photo-400.webp 400w',
|
|
872
|
+
avifSrcset: '/images/photo-400.avif 400w',
|
|
873
|
+
}],
|
|
874
|
+
]) as any,
|
|
875
|
+
});
|
|
876
|
+
const node = {
|
|
877
|
+
type: 'image',
|
|
878
|
+
src: '/images/photo.jpg',
|
|
879
|
+
alt: 'A photo',
|
|
880
|
+
} as any;
|
|
881
|
+
const result = nodeToAstro(node, ctx);
|
|
882
|
+
expect(result).toContain('<picture');
|
|
883
|
+
expect(result).toContain('image/avif');
|
|
884
|
+
expect(result).toContain('image/webp');
|
|
885
|
+
expect(result).toContain('</picture>');
|
|
886
|
+
});
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
// =========================================================================
|
|
890
|
+
// Context: imports tracking
|
|
891
|
+
// =========================================================================
|
|
892
|
+
|
|
893
|
+
describe('imports tracking', () => {
|
|
894
|
+
test('should collect all component imports', () => {
|
|
895
|
+
const ctx = createContext();
|
|
896
|
+
const nodes: ComponentNode[] = [
|
|
897
|
+
{ type: 'component', component: 'Header', children: [] } as any,
|
|
898
|
+
{ type: 'component', component: 'Footer', children: [] } as any,
|
|
899
|
+
{
|
|
900
|
+
type: 'node',
|
|
901
|
+
tag: 'div',
|
|
902
|
+
children: [
|
|
903
|
+
{ type: 'component', component: 'Button', children: [] },
|
|
904
|
+
],
|
|
905
|
+
} as any,
|
|
906
|
+
];
|
|
907
|
+
nodeToAstro(nodes, ctx);
|
|
908
|
+
expect(ctx.imports.has('Header')).toBe(true);
|
|
909
|
+
expect(ctx.imports.has('Footer')).toBe(true);
|
|
910
|
+
expect(ctx.imports.has('Button')).toBe(true);
|
|
911
|
+
});
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
// =========================================================================
|
|
915
|
+
// Dynamic tags
|
|
916
|
+
// =========================================================================
|
|
917
|
+
|
|
918
|
+
describe('dynamic tags', () => {
|
|
919
|
+
test('should register dynamic tag variable when tag has template expression', () => {
|
|
920
|
+
const ctx = createContext({ isComponentDef: true });
|
|
921
|
+
const node: ComponentNode = {
|
|
922
|
+
type: 'node',
|
|
923
|
+
tag: '{{headingTag}}',
|
|
924
|
+
children: ['Title'],
|
|
925
|
+
} as any;
|
|
926
|
+
const result = nodeToAstro(node, ctx);
|
|
927
|
+
expect(ctx.dynamicTags).toBeDefined();
|
|
928
|
+
expect(ctx.dynamicTags!.size).toBe(1);
|
|
929
|
+
// Should use Tag_<path> variable
|
|
930
|
+
const entry = [...ctx.dynamicTags!.entries()][0];
|
|
931
|
+
expect(entry[0]).toMatch(/^Tag_/);
|
|
932
|
+
});
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
// =========================================================================
|
|
936
|
+
// HTML img with imageMetadataMap
|
|
937
|
+
// =========================================================================
|
|
938
|
+
|
|
939
|
+
describe('HTML img with metadata', () => {
|
|
940
|
+
test('should emit picture element for img tag with avif metadata', () => {
|
|
941
|
+
const metadataMap = new Map();
|
|
942
|
+
metadataMap.set('/test.jpg', {
|
|
943
|
+
width: 1920,
|
|
944
|
+
height: 1080,
|
|
945
|
+
srcset: '/test-800.webp 800w, /test-1200.webp 1200w',
|
|
946
|
+
avifSrcset: '/test-800.avif 800w, /test-1200.avif 1200w',
|
|
947
|
+
});
|
|
948
|
+
const ctx = createContext({ imageMetadataMap: metadataMap as any });
|
|
949
|
+
const node: ComponentNode = {
|
|
950
|
+
type: 'node',
|
|
951
|
+
tag: 'img',
|
|
952
|
+
attributes: { src: '/test.jpg', alt: 'Test image' },
|
|
953
|
+
children: [],
|
|
954
|
+
} as any;
|
|
955
|
+
const result = nodeToAstro(node, ctx);
|
|
956
|
+
expect(result).toContain('<picture');
|
|
957
|
+
expect(result).toContain('type="image/avif"');
|
|
958
|
+
expect(result).toContain('type="image/webp"');
|
|
959
|
+
expect(result).toContain('</picture>');
|
|
960
|
+
expect(result).toContain('width="1920"');
|
|
961
|
+
expect(result).toContain('height="1080"');
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
test('should emit img with srcset when only webp metadata available', () => {
|
|
965
|
+
const metadataMap = new Map();
|
|
966
|
+
metadataMap.set('/test.jpg', {
|
|
967
|
+
width: 1920,
|
|
968
|
+
height: 1080,
|
|
969
|
+
srcset: '/test-800.webp 800w',
|
|
970
|
+
});
|
|
971
|
+
const ctx = createContext({ imageMetadataMap: metadataMap as any });
|
|
972
|
+
const node: ComponentNode = {
|
|
973
|
+
type: 'node',
|
|
974
|
+
tag: 'img',
|
|
975
|
+
attributes: { src: '/test.jpg', alt: 'Test' },
|
|
976
|
+
children: [],
|
|
977
|
+
} as any;
|
|
978
|
+
const result = nodeToAstro(node, ctx);
|
|
979
|
+
expect(result).not.toContain('<picture');
|
|
980
|
+
expect(result).toContain('<img');
|
|
981
|
+
expect(result).toContain('srcset=');
|
|
982
|
+
expect(result).toContain('sizes=');
|
|
983
|
+
});
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
// =========================================================================
|
|
987
|
+
// Locale list nodes
|
|
988
|
+
// =========================================================================
|
|
989
|
+
|
|
990
|
+
describe('locale list nodes', () => {
|
|
991
|
+
test('should emit static locale links when i18n config is provided', () => {
|
|
992
|
+
const ctx = createContext({
|
|
993
|
+
locale: 'en',
|
|
994
|
+
i18nConfig: {
|
|
995
|
+
defaultLocale: 'en',
|
|
996
|
+
locales: [
|
|
997
|
+
{ code: 'en', name: 'English', nativeName: 'English', langTag: 'en' },
|
|
998
|
+
{ code: 'fr', name: 'French', nativeName: 'Francais', langTag: 'fr' },
|
|
999
|
+
],
|
|
1000
|
+
},
|
|
1001
|
+
currentPageSlugMap: {
|
|
1002
|
+
en: '/about',
|
|
1003
|
+
fr: '/fr/a-propos',
|
|
1004
|
+
},
|
|
1005
|
+
});
|
|
1006
|
+
const node: ComponentNode = {
|
|
1007
|
+
type: 'locale-list',
|
|
1008
|
+
children: [],
|
|
1009
|
+
} as any;
|
|
1010
|
+
const result = nodeToAstro(node, ctx);
|
|
1011
|
+
expect(result).toContain('data-locale-list="true"');
|
|
1012
|
+
expect(result).toContain('hreflang="en"');
|
|
1013
|
+
expect(result).toContain('hreflang="fr"');
|
|
1014
|
+
expect(result).toContain('/about');
|
|
1015
|
+
expect(result).toContain('/fr/a-propos');
|
|
1016
|
+
expect(result).toContain('English');
|
|
1017
|
+
expect(result).toContain('Francais');
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
test('should mark current locale link with data-current', () => {
|
|
1021
|
+
const ctx = createContext({
|
|
1022
|
+
locale: 'en',
|
|
1023
|
+
i18nConfig: {
|
|
1024
|
+
defaultLocale: 'en',
|
|
1025
|
+
locales: [
|
|
1026
|
+
{ code: 'en', name: 'English', nativeName: 'English', langTag: 'en' },
|
|
1027
|
+
{ code: 'fr', name: 'French', nativeName: 'Francais', langTag: 'fr' },
|
|
1028
|
+
],
|
|
1029
|
+
},
|
|
1030
|
+
currentPageSlugMap: {
|
|
1031
|
+
en: '/',
|
|
1032
|
+
fr: '/fr/',
|
|
1033
|
+
},
|
|
1034
|
+
});
|
|
1035
|
+
const node: ComponentNode = {
|
|
1036
|
+
type: 'locale-list',
|
|
1037
|
+
children: [],
|
|
1038
|
+
} as any;
|
|
1039
|
+
const result = nodeToAstro(node, ctx);
|
|
1040
|
+
expect(result).toContain('data-current="true"');
|
|
1041
|
+
expect(result).toContain('data-current="false"');
|
|
1042
|
+
});
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
// =========================================================================
|
|
1046
|
+
// CMS mode attribute transformation
|
|
1047
|
+
// =========================================================================
|
|
1048
|
+
|
|
1049
|
+
describe('CMS attribute transformation', () => {
|
|
1050
|
+
test('should transform CMS template in link href', () => {
|
|
1051
|
+
const ctx = createContext({
|
|
1052
|
+
cmsMode: true,
|
|
1053
|
+
cmsEntryBinding: 'post',
|
|
1054
|
+
});
|
|
1055
|
+
const node: ComponentNode = {
|
|
1056
|
+
type: 'link',
|
|
1057
|
+
href: '{{cms.url}}',
|
|
1058
|
+
children: ['Read more'],
|
|
1059
|
+
} as any;
|
|
1060
|
+
const result = nodeToAstro(node, ctx);
|
|
1061
|
+
expect(result).toContain('post.data.url');
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
test('should support cmsWrapFn for i18n resolution', () => {
|
|
1065
|
+
const ctx = createContext({
|
|
1066
|
+
cmsMode: true,
|
|
1067
|
+
cmsEntryBinding: 'entry',
|
|
1068
|
+
cmsWrapFn: 'r',
|
|
1069
|
+
});
|
|
1070
|
+
const node: ComponentNode = {
|
|
1071
|
+
type: 'node',
|
|
1072
|
+
tag: 'h1',
|
|
1073
|
+
attributes: { title: '{{cms.title}}' },
|
|
1074
|
+
children: [],
|
|
1075
|
+
} as any;
|
|
1076
|
+
const result = nodeToAstro(node, ctx);
|
|
1077
|
+
expect(result).toContain('r(entry.data.title)');
|
|
1078
|
+
});
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
// =========================================================================
|
|
1082
|
+
// List item binding in attributes
|
|
1083
|
+
// =========================================================================
|
|
1084
|
+
|
|
1085
|
+
describe('list item binding', () => {
|
|
1086
|
+
test('should transform item template expressions in attribute values', () => {
|
|
1087
|
+
const ctx = createContext({
|
|
1088
|
+
isComponentDef: true,
|
|
1089
|
+
listItemBinding: 'item',
|
|
1090
|
+
});
|
|
1091
|
+
const node: ComponentNode = {
|
|
1092
|
+
type: 'node',
|
|
1093
|
+
tag: 'div',
|
|
1094
|
+
attributes: { id: '{{item.id}}' },
|
|
1095
|
+
children: [],
|
|
1096
|
+
} as any;
|
|
1097
|
+
const result = nodeToAstro(node, ctx);
|
|
1098
|
+
expect(result).toContain('id={item.id');
|
|
1099
|
+
});
|
|
1100
|
+
});
|
|
1101
|
+
});
|