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.
Files changed (67) hide show
  1. package/bin/cli.ts +33 -0
  2. package/build-astro.ts +172 -69
  3. package/dist/bin/cli.js +30 -2
  4. package/dist/bin/cli.js.map +2 -2
  5. package/dist/build-static.js +7 -7
  6. package/dist/chunks/{chunk-WK5XLASY.js → chunk-EQOSDQS2.js} +4 -4
  7. package/dist/chunks/{chunk-AIXKUVNG.js → chunk-IBR2F4IL.js} +4 -5
  8. package/dist/chunks/{chunk-AIXKUVNG.js.map → chunk-IBR2F4IL.js.map} +2 -2
  9. package/dist/chunks/{chunk-NV25WXCA.js → chunk-IGVQF5GY.js} +11 -7
  10. package/dist/chunks/chunk-IGVQF5GY.js.map +7 -0
  11. package/dist/chunks/{chunk-KULPBDC7.js → chunk-LBWIHPN7.js} +9 -3
  12. package/dist/chunks/chunk-LBWIHPN7.js.map +7 -0
  13. package/dist/chunks/{chunk-A6KWUEA6.js → chunk-MKB2J6AD.js} +9 -1
  14. package/dist/chunks/chunk-MKB2J6AD.js.map +7 -0
  15. package/dist/chunks/{chunk-P3FX5HJM.js → chunk-S2HXJTAF.js} +1 -1
  16. package/dist/chunks/chunk-S2HXJTAF.js.map +7 -0
  17. package/dist/chunks/{chunk-W6HDII4T.js → chunk-SK3TLNUP.js} +140 -114
  18. package/dist/chunks/chunk-SK3TLNUP.js.map +7 -0
  19. package/dist/chunks/{chunk-HNAS6BSS.js → chunk-SNUROC7E.js} +56 -6
  20. package/dist/chunks/{chunk-HNAS6BSS.js.map → chunk-SNUROC7E.js.map} +3 -3
  21. package/dist/chunks/{configService-TXBNUBBL.js → configService-MICL4S2L.js} +2 -2
  22. package/dist/chunks/{constants-5CRJRQNR.js → constants-ZEU4TZCA.js} +2 -2
  23. package/dist/entries/server-router.js +7 -7
  24. package/dist/lib/client/index.js +11 -6
  25. package/dist/lib/client/index.js.map +2 -2
  26. package/dist/lib/server/index.js +507 -1587
  27. package/dist/lib/server/index.js.map +4 -4
  28. package/dist/lib/shared/index.js +3 -3
  29. package/dist/lib/test-utils/index.js +1 -1
  30. package/lib/client/core/ComponentBuilder.ts +1 -1
  31. package/lib/client/core/builders/embedBuilder.ts +2 -2
  32. package/lib/client/routing/Router.tsx +6 -0
  33. package/lib/client/templateEngine.test.ts +178 -0
  34. package/lib/client/templateEngine.ts +1 -2
  35. package/lib/server/astro/cmsPageEmitter.ts +420 -0
  36. package/lib/server/astro/componentEmitter.ts +150 -17
  37. package/lib/server/astro/nodeToAstro.test.ts +1101 -0
  38. package/lib/server/astro/nodeToAstro.ts +869 -37
  39. package/lib/server/astro/pageEmitter.ts +43 -3
  40. package/lib/server/astro/tailwindMapper.ts +69 -8
  41. package/lib/server/astro/templateTransformer.ts +107 -0
  42. package/lib/server/index.ts +26 -3
  43. package/lib/server/routes/api/components.ts +62 -0
  44. package/lib/server/routes/api/core-routes.ts +8 -0
  45. package/lib/server/services/configService.ts +12 -0
  46. package/lib/server/ssr/htmlGenerator.ts +0 -5
  47. package/lib/server/ssr/imageMetadata.ts +3 -3
  48. package/lib/server/ssr/ssrRenderer.ts +78 -29
  49. package/lib/server/webflow/buildWebflow.ts +415 -0
  50. package/lib/server/webflow/index.ts +22 -0
  51. package/lib/server/webflow/nodeToWebflow.ts +423 -0
  52. package/lib/server/webflow/styleMapper.ts +241 -0
  53. package/lib/server/webflow/types.ts +196 -0
  54. package/lib/shared/constants.ts +4 -0
  55. package/lib/shared/types/components.ts +9 -4
  56. package/lib/shared/validation/propValidator.ts +2 -1
  57. package/lib/shared/validation/schemas.ts +4 -1
  58. package/package.json +1 -1
  59. package/templates/index-router.html +0 -5
  60. package/dist/chunks/chunk-A6KWUEA6.js.map +0 -7
  61. package/dist/chunks/chunk-KULPBDC7.js.map +0 -7
  62. package/dist/chunks/chunk-NV25WXCA.js.map +0 -7
  63. package/dist/chunks/chunk-P3FX5HJM.js.map +0 -7
  64. package/dist/chunks/chunk-W6HDII4T.js.map +0 -7
  65. /package/dist/chunks/{chunk-WK5XLASY.js.map → chunk-EQOSDQS2.js.map} +0 -0
  66. /package/dist/chunks/{configService-TXBNUBBL.js.map → configService-MICL4S2L.js.map} +0 -0
  67. /package/dist/chunks/{constants-5CRJRQNR.js.map → constants-ZEU4TZCA.js.map} +0 -0
@@ -255,6 +255,12 @@ export function Router(props: RouterProps = {}): ReactElement {
255
255
  if (typeof window === 'undefined') return;
256
256
 
257
257
  const handleMessage = (event: MessageEvent) => {
258
+ if (event.data?.type === IFRAME_MESSAGE_TYPES.CSS_VARIABLE_UPDATE) {
259
+ const { name, value } = event.data as { name: string; value: string };
260
+ document.documentElement.style.setProperty(name, value);
261
+ return;
262
+ }
263
+
258
264
  if (event.data?.type === IFRAME_MESSAGE_TYPES.INTERACTIVE_CSS_UPDATE) {
259
265
  const css = event.data.css as string;
260
266
  const styleId = 'interactive-styles';
@@ -807,3 +807,181 @@ describe("Template Engine - normalizeStyle", () => {
807
807
  expect(result).toBeNull();
808
808
  });
809
809
  });
810
+
811
+ // ==========================================================================
812
+ // Targeted tests for `as any` code paths in processStructure
813
+ // These exercise specific type-cast paths to ensure safety before refactoring
814
+ // ==========================================================================
815
+
816
+ describe("Template Engine - processStructure as-any paths", () => {
817
+ const baseContext: TemplateContext = {
818
+ props: { text: "Hello", size: "lg", variant: "primary" },
819
+ componentDef: {} as StructuredComponentDefinition,
820
+ };
821
+
822
+ describe("Boolean preservation (line 429)", () => {
823
+ test("should preserve false boolean values in structure", () => {
824
+ // processStructure should return false as-is, not convert to null
825
+ const result = processStructure(false as any, baseContext);
826
+ expect(result).toBe(false);
827
+ });
828
+
829
+ test("should preserve true boolean values in structure", () => {
830
+ const result = processStructure(true as any, baseContext);
831
+ expect(result).toBe(true);
832
+ });
833
+ });
834
+
835
+ describe("Object template evaluation returning objects (line 454)", () => {
836
+ test("should return object result from full template expression", () => {
837
+ const context: TemplateContext = {
838
+ props: { link: { href: "/about", target: "_blank" } },
839
+ componentDef: {} as StructuredComponentDefinition,
840
+ };
841
+ // Full template {{link}} should return the object as-is
842
+ const result = processStructure("{{link}}", context);
843
+ expect(result).toEqual({ href: "/about", target: "_blank" });
844
+ });
845
+ });
846
+
847
+ describe("Slot default content via 'default' property (lines 483-486, 516-517)", () => {
848
+ test("should render slot default array when no instance children", () => {
849
+ // Slot markers use type: "slot" (NODE_TYPE.SLOT)
850
+ const structure = [
851
+ { type: "node", tag: "div", children: [
852
+ { type: "slot", default: [{ type: "node", tag: "span", children: "fallback" }] }
853
+ ]}
854
+ ] as unknown as ComponentNode[];
855
+ const result = processStructure(structure, baseContext);
856
+ expect(Array.isArray(result)).toBe(true);
857
+ const root = (result as any[])[0];
858
+ expect(root.tag).toBe("div");
859
+ // The slot default should have been rendered
860
+ expect(root.children.length).toBe(1);
861
+ expect(root.children[0].tag).toBe("span");
862
+ });
863
+
864
+ test("should render slot default string when no instance children", () => {
865
+ const structure = [
866
+ { type: "node", tag: "div", children: [
867
+ { type: "slot", default: "Default text" }
868
+ ]}
869
+ ] as unknown as ComponentNode[];
870
+ const result = processStructure(structure, baseContext);
871
+ const root = (result as any[])[0];
872
+ expect(root.children[0]).toBe("Default text");
873
+ });
874
+
875
+ test("should use instance children over slot default", () => {
876
+ const structure = [
877
+ { type: "node", tag: "div", children: [
878
+ { type: "slot", default: [{ type: "node", tag: "span", children: "fallback" }] }
879
+ ]}
880
+ ] as unknown as ComponentNode[];
881
+ const instanceChildren = [
882
+ { type: "node", tag: "b", children: "override" }
883
+ ] as unknown as ComponentNode[];
884
+ const result = processStructure(structure, baseContext, undefined, instanceChildren);
885
+ const root = (result as any[])[0];
886
+ expect(root.children[0].tag).toBe("b");
887
+ });
888
+
889
+ test("should handle slot marker as standalone object with default", () => {
890
+ const slotMarker = {
891
+ type: "slot",
892
+ default: [{ type: "node", tag: "div", children: "content" }],
893
+ };
894
+ const result = processStructure(slotMarker as any, baseContext);
895
+ expect(result).toBeDefined();
896
+ });
897
+ });
898
+
899
+ describe("Plain object processing (lines 538, 543)", () => {
900
+ test("should process plain objects recursively and resolve templates", () => {
901
+ // An object without a valid node type gets treated as a plain props object
902
+ const structure = { label: "{{text}}", visible: true } as any;
903
+ const result = processStructure(structure, baseContext);
904
+ expect(result).toBeDefined();
905
+ expect((result as any).label).toBe("Hello");
906
+ expect((result as any).visible).toBe(true);
907
+ });
908
+
909
+ test("should strip null/undefined values from plain objects", () => {
910
+ const context: TemplateContext = {
911
+ props: { defined: "value" },
912
+ componentDef: {} as StructuredComponentDefinition,
913
+ };
914
+ const structure = { a: "{{defined}}", b: "{{missing}}" } as any;
915
+ const result = processStructure(structure, context);
916
+ expect((result as any).a).toBe("value");
917
+ // Missing props resolve to empty string, not null
918
+ });
919
+ });
920
+
921
+ describe("Embed node creation (line 562)", () => {
922
+ test("should process embed node with html content", () => {
923
+ const node: ComponentNode = {
924
+ type: "embed" as any,
925
+ html: "<script>alert('test')</script>",
926
+ } as any;
927
+ const result = processStructure(node, baseContext);
928
+ expect(result).toBeDefined();
929
+ expect((result as any).type).toBe("embed");
930
+ expect((result as any).html).toBe("<script>alert('test')</script>");
931
+ });
932
+ });
933
+
934
+ describe("Link node creation (line 569)", () => {
935
+ test("should process link node with href", () => {
936
+ const node = {
937
+ type: "link",
938
+ href: "/about",
939
+ children: ["About us"],
940
+ } as any;
941
+ const result = processStructure(node, baseContext);
942
+ expect(result).toBeDefined();
943
+ expect((result as any).type).toBe("link");
944
+ });
945
+
946
+ test("should process link node with template href", () => {
947
+ const context: TemplateContext = {
948
+ props: { url: "/contact" },
949
+ componentDef: {} as StructuredComponentDefinition,
950
+ };
951
+ const node = {
952
+ type: "link",
953
+ href: "{{url}}",
954
+ children: ["Contact"],
955
+ } as any;
956
+ const result = processStructure(node, context);
957
+ expect(result).toBeDefined();
958
+ expect((result as any).type).toBe("link");
959
+ });
960
+ });
961
+
962
+ describe("Locale-list node creation (line 574)", () => {
963
+ test("should process locale-list node", () => {
964
+ const node = {
965
+ type: "locale-list",
966
+ style: { display: "flex" },
967
+ } as any;
968
+ const result = processStructure(node, baseContext);
969
+ expect(result).toBeDefined();
970
+ expect((result as any).type).toBe("locale-list");
971
+ });
972
+ });
973
+
974
+ describe("List node with legacy cms-list type (line 575)", () => {
975
+ test("should process list node", () => {
976
+ const node = {
977
+ type: "list",
978
+ source: "items",
979
+ sourceType: "prop",
980
+ children: [{ type: "html" as const, tag: "div", children: [] }],
981
+ } as any;
982
+ const result = processStructure(node, baseContext);
983
+ expect(result).toBeDefined();
984
+ expect((result as any).type).toBe("list");
985
+ });
986
+ });
987
+ });
@@ -814,8 +814,7 @@ export function processStructure(
814
814
  if (isResponsiveStyle(resolvedStyle)) {
815
815
  // Apply responsive styles directly to node.style or props.style
816
816
  if (isComponentNode(processed)) {
817
- processed.props = processed.props || {};
818
- processed.props.style = resolvedStyle as ResponsiveStyleObject;
817
+ (processed as any).style = resolvedStyle as ResponsiveStyleObject;
819
818
  } else if (isHtmlNode(processed) || isEmbedNode(processed) || isLocaleListNode(processed) || isLinkNode(processed) || isListNode(processed)) {
820
819
  processed.style = resolvedStyle as ResponsiveStyleObject;
821
820
  }
@@ -0,0 +1,420 @@
1
+ /**
2
+ * CMS Page File Generator
3
+ * Generates .astro page files for CMS template pages with getStaticPaths()
4
+ */
5
+
6
+ import type { JSONPage, ComponentDefinition, CMSSchema, I18nConfig } from '../../shared/types';
7
+ import type { BreakpointConfig } from '../../shared/breakpoints';
8
+ import { DEFAULT_BREAKPOINTS } from '../../shared/breakpoints';
9
+ import { nodeToAstro, type AstroEmitContext } from './nodeToAstro';
10
+ import { transformCMSTemplate } from './templateTransformer';
11
+ import type { ImageMetadataMap } from '../ssr/imageMetadata';
12
+ import type { SlugMap } from '../../shared/slugTranslator';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Types
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export interface CMSPageEmitOptions {
19
+ /** Page data */
20
+ pageData: JSONPage;
21
+ /** All global components */
22
+ globalComponents: Record<string, ComponentDefinition>;
23
+ /** CMS collection schema */
24
+ cmsSchema: CMSSchema;
25
+ /** Page title (may contain {{cms.field}}) */
26
+ title: string;
27
+ /** Page meta HTML */
28
+ meta: string;
29
+ /** Locale */
30
+ locale: string;
31
+ /** Default theme */
32
+ theme: string;
33
+ /** Font preloads HTML */
34
+ fontPreloads: string;
35
+ /** Library tags */
36
+ libraryTags: { headCSS?: string; headJS?: string; bodyEndJS?: string };
37
+ /** Script paths */
38
+ scriptPaths: string[];
39
+ /** Import path to BaseLayout */
40
+ layoutImportPath: string;
41
+ /** File depth relative to src/pages */
42
+ fileDepth: number;
43
+ /** SSR HTML fallbacks: node path -> rendered HTML (for ListNode, LocaleListNode) */
44
+ ssrFallbacks: Map<string, string>;
45
+ /** Page name (without extension) */
46
+ pageName: string;
47
+ /** Breakpoint config for responsive Tailwind classes */
48
+ breakpoints?: BreakpointConfig;
49
+ /** Image metadata map for responsive image generation */
50
+ imageMetadataMap?: ImageMetadataMap;
51
+ /** Internationalization config */
52
+ i18nConfig: I18nConfig;
53
+ /** Whether site has multiple locales */
54
+ isMultiLocale: boolean;
55
+ /** Slug mappings for translating internal link hrefs */
56
+ slugMappings?: SlugMap[];
57
+ /** Image format: 'webp' uses plain <img>, 'avif' uses <picture> */
58
+ imageFormat?: 'webp' | 'avif';
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Helpers
63
+ // ---------------------------------------------------------------------------
64
+
65
+ function escapeTemplateLiteral(s: string): string {
66
+ return s.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
67
+ }
68
+
69
+ function escapeJSX(s: string): string {
70
+ return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
71
+ }
72
+
73
+ function componentImportPath(fileDepth: number, componentName: string): string {
74
+ const ups = '../'.repeat(fileDepth + 1);
75
+ return `${ups}components/${componentName}.astro`;
76
+ }
77
+
78
+ /**
79
+ * Collect rich-text field names from CMS schema
80
+ */
81
+ function collectRichTextFields(schema: CMSSchema): Set<string> {
82
+ const richTextFields = new Set<string>();
83
+ for (const [fieldName, fieldDef] of Object.entries(schema.fields)) {
84
+ if (fieldDef.type === 'rich-text') {
85
+ richTextFields.add(fieldName);
86
+ }
87
+ }
88
+ return richTextFields;
89
+ }
90
+
91
+ /**
92
+ * Transform a title string that may contain {{cms.field}} to an Astro expression.
93
+ * Returns the transformed title suitable for use in a JSX attribute.
94
+ */
95
+ function transformTitleExpression(
96
+ title: string,
97
+ binding: string,
98
+ richTextFields: Set<string>,
99
+ wrapFn?: string
100
+ ): string {
101
+ if (!/\{\{cms\./.test(title)) {
102
+ return `"${escapeJSX(title)}"`;
103
+ }
104
+
105
+ const w = (expr: string) => wrapFn ? `${wrapFn}(${expr})` : expr;
106
+
107
+ // Full match: entire title is a single {{cms.field}}
108
+ const fullMatch = title.match(/^\{\{cms\.([^}]+)\}\}$/);
109
+ if (fullMatch) {
110
+ return `{${w(`${binding}.data.${fullMatch[1].trim()}`)}}`;
111
+ }
112
+
113
+ // Mixed content: "Page - {{cms.title}}" -> {`Page - ${entry.data.title}`}
114
+ const replaced = title.replace(/\{\{cms\.([^}]+)\}\}/g, (_, fieldPath) => {
115
+ return `\${${w(`${binding}.data.${fieldPath.trim()}`)}}`;
116
+ });
117
+ return `{\`${replaced}\`}`;
118
+ }
119
+
120
+ /**
121
+ * Extract the path prefix from a URL pattern.
122
+ * E.g., "/blog/{{slug}}" -> "blog/"
123
+ * E.g., "/posts/{{slug}}" -> "posts/"
124
+ */
125
+ function extractPathPrefix(urlPattern: string): string {
126
+ // Remove leading slash, then remove the slug placeholder and everything after
127
+ const withoutLeading = urlPattern.replace(/^\//, '');
128
+ const idx = withoutLeading.indexOf('{{');
129
+ if (idx <= 0) return '';
130
+ return withoutLeading.substring(0, idx);
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // getStaticPaths generator
135
+ // ---------------------------------------------------------------------------
136
+
137
+ function buildGetStaticPaths(
138
+ schema: CMSSchema,
139
+ isMultiLocale: boolean,
140
+ i18nConfig: I18nConfig,
141
+ locale?: string
142
+ ): string {
143
+ const collectionId = schema.id;
144
+ const slugField = schema.slugField || 'slug';
145
+ const pathPrefix = extractPathPrefix(schema.urlPattern);
146
+ const targetLocale = locale || i18nConfig.defaultLocale;
147
+
148
+ if (!isMultiLocale) {
149
+ // Single-locale version: resolve slug for this specific locale
150
+ // Route file is at blog/[slug].astro (or pl/blog/[slug].astro for non-default)
151
+ // If i18n values exist, resolve for the target locale
152
+ const slugExpr = i18nConfig.locales.length > 1
153
+ ? `entry.data.${slugField}?.${targetLocale} || entry.data.${slugField} || entry.id`
154
+ : `entry.data.${slugField} || entry.id`;
155
+
156
+ return [
157
+ `export async function getStaticPaths() {`,
158
+ ` const entries = await getCollection('${collectionId}');`,
159
+ ` return entries.map(entry => ({`,
160
+ ` params: { slug: ${slugExpr} },`,
161
+ ` props: { entry },`,
162
+ ` }));`,
163
+ `}`,
164
+ ``,
165
+ `const { entry } = Astro.props;`,
166
+ ].join('\n');
167
+ }
168
+
169
+ // Multi-locale version: enumerate items x locales
170
+ // Route file is at [...slug].astro (top level), so slug includes full path
171
+ const defaultLocale = i18nConfig.defaultLocale;
172
+ const locales = i18nConfig.locales;
173
+
174
+ const lines: string[] = [
175
+ `export async function getStaticPaths() {`,
176
+ ` const entries = await getCollection('${collectionId}');`,
177
+ ` const paths = [];`,
178
+ ` for (const entry of entries) {`,
179
+ ];
180
+
181
+ for (const locale of locales) {
182
+ const code = locale.code;
183
+ const slugExpr = `entry.data.${slugField}?.${code} || entry.data.${slugField} || entry.id`;
184
+
185
+ if (code === defaultLocale) {
186
+ // Default locale: include path prefix but no locale prefix
187
+ // e.g., /blog/{{slug}} → slug = "blog/hello"
188
+ if (pathPrefix) {
189
+ lines.push(
190
+ ` paths.push({`,
191
+ ` params: { slug: \`${pathPrefix}\${${slugExpr}}\` },`,
192
+ ` props: { entry, locale: '${code}' },`,
193
+ ` });`
194
+ );
195
+ } else {
196
+ lines.push(
197
+ ` paths.push({`,
198
+ ` params: { slug: ${slugExpr} },`,
199
+ ` props: { entry, locale: '${code}' },`,
200
+ ` });`
201
+ );
202
+ }
203
+ } else {
204
+ // Non-default locale: locale prefix + path prefix + slug
205
+ // e.g., slug = "pl/blog/witaj"
206
+ lines.push(
207
+ ` paths.push({`,
208
+ ` params: { slug: \`${code}/${pathPrefix}\${${slugExpr}}\` },`,
209
+ ` props: { entry, locale: '${code}' },`,
210
+ ` });`
211
+ );
212
+ }
213
+ }
214
+
215
+ lines.push(
216
+ ` }`,
217
+ ` return paths;`,
218
+ `}`,
219
+ ``,
220
+ `const { entry, locale = '${defaultLocale}' } = Astro.props;`
221
+ );
222
+
223
+ return lines.join('\n');
224
+ }
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // Main emitter
228
+ // ---------------------------------------------------------------------------
229
+
230
+ /**
231
+ * Generate a CMS template .astro page file with getStaticPaths()
232
+ */
233
+ export function emitCMSPage(options: CMSPageEmitOptions): string {
234
+ const {
235
+ pageData,
236
+ globalComponents,
237
+ cmsSchema,
238
+ title,
239
+ meta,
240
+ locale,
241
+ theme,
242
+ fontPreloads,
243
+ libraryTags,
244
+ scriptPaths,
245
+ layoutImportPath,
246
+ fileDepth,
247
+ ssrFallbacks,
248
+ pageName,
249
+ breakpoints: breakpointsOpt,
250
+ imageMetadataMap,
251
+ i18nConfig,
252
+ isMultiLocale,
253
+ slugMappings,
254
+ } = options;
255
+
256
+ const breakpoints = breakpointsOpt ?? DEFAULT_BREAKPOINTS;
257
+ const binding = 'entry';
258
+ const richTextFields = collectRichTextFields(cmsSchema);
259
+ const wrapFn = 'r';
260
+
261
+ const root = pageData.root;
262
+ if (!root) {
263
+ return buildEmptyCMSPage(
264
+ layoutImportPath,
265
+ title,
266
+ meta,
267
+ locale,
268
+ theme,
269
+ fontPreloads,
270
+ libraryTags,
271
+ scriptPaths,
272
+ cmsSchema,
273
+ isMultiLocale,
274
+ i18nConfig,
275
+ binding,
276
+ richTextFields
277
+ );
278
+ }
279
+
280
+ // Build the Astro emit context with CMS mode enabled
281
+ const ctx: AstroEmitContext = {
282
+ imports: new Set<string>(),
283
+ isComponentDef: false,
284
+ componentProps: {},
285
+ globalComponents,
286
+ indent: 1, // inside BaseLayout
287
+ ssrFallbacks,
288
+ elementPath: [0],
289
+ fileType: 'page',
290
+ fileName: pageName,
291
+ breakpoints,
292
+ imageMetadataMap,
293
+ locale,
294
+ cmsMode: true,
295
+ cmsEntryBinding: binding,
296
+ cmsRichTextFields: richTextFields,
297
+ cmsWrapFn: wrapFn,
298
+ slugMappings,
299
+ i18nDefaultLocale: i18nConfig.defaultLocale,
300
+ imageFormat: options.imageFormat,
301
+ };
302
+
303
+ // Emit the template body
304
+ const templateBody = nodeToAstro(root, ctx);
305
+
306
+ // Build frontmatter with imports
307
+ const importLines: string[] = [];
308
+ importLines.push(`import { getCollection } from 'astro:content';`);
309
+ importLines.push(`import BaseLayout from '${layoutImportPath}';`);
310
+
311
+ // Sort component imports alphabetically
312
+ const componentImports = Array.from(ctx.imports).sort();
313
+ for (const comp of componentImports) {
314
+ const path = componentImportPath(fileDepth, comp);
315
+ importLines.push(`import ${comp} from '${path}';`);
316
+ }
317
+
318
+ // Build getStaticPaths
319
+ const staticPaths = buildGetStaticPaths(cmsSchema, isMultiLocale, i18nConfig, locale);
320
+
321
+ // Build script paths array
322
+ const scriptsArrayLiteral = scriptPaths.length > 0
323
+ ? `[${scriptPaths.map((s) => `"${s}"`).join(', ')}]`
324
+ : '[]';
325
+
326
+ // Build library tags literal
327
+ const libraryTagsLiteral = `{ headCSS: \`${escapeTemplateLiteral(libraryTags.headCSS || '')}\`, headJS: \`${escapeTemplateLiteral(libraryTags.headJS || '')}\`, bodyEndJS: \`${escapeTemplateLiteral(libraryTags.bodyEndJS || '')}\` }`;
328
+
329
+ // Escape meta first, then transform CMS templates ({{cms.X}} survives escaping intact)
330
+ const escapedMeta = escapeTemplateLiteral(meta).replace(
331
+ /\{\{cms\.([^}]+)\}\}/g,
332
+ (_, fieldPath) => `\${${wrapFn}(${binding}.data.${fieldPath.trim()})}`
333
+ );
334
+ const escapedFontPreloads = escapeTemplateLiteral(fontPreloads);
335
+
336
+ // Transform title for CMS entry data
337
+ const titleExpr = transformTitleExpression(title, binding, richTextFields, wrapFn);
338
+
339
+ // i18n resolver helper — resolves {_i18n: true, en: "...", pl: "..."} to the correct locale string
340
+ const resolverHelper = `function r(v) {
341
+ if (v && typeof v === 'object' && v._i18n) return v['${locale}'] ?? v['${i18nConfig.defaultLocale}'] ?? Object.values(v).find(x => x !== true && x !== undefined) ?? '';
342
+ return v ?? '';
343
+ }`;
344
+
345
+ return `---
346
+ ${importLines.join('\n')}
347
+
348
+ ${staticPaths}
349
+
350
+ ${resolverHelper}
351
+ ---
352
+ <BaseLayout
353
+ title=${titleExpr}
354
+ meta={\`${escapedMeta}\`}
355
+ scripts={${scriptsArrayLiteral}}
356
+ locale="${locale}"
357
+ theme="${theme}"
358
+ fontPreloads={\`${escapedFontPreloads}\`}
359
+ libraryTags={${libraryTagsLiteral}}
360
+ >
361
+ <div id="root">
362
+ ${templateBody} </div>
363
+ </BaseLayout>
364
+ `;
365
+ }
366
+
367
+ /**
368
+ * Build an empty CMS page with just the layout wrapper and getStaticPaths
369
+ */
370
+ function buildEmptyCMSPage(
371
+ layoutImport: string,
372
+ title: string,
373
+ meta: string,
374
+ locale: string,
375
+ theme: string,
376
+ fontPreloads: string,
377
+ libraryTags: { headCSS?: string; headJS?: string; bodyEndJS?: string },
378
+ scriptPaths: string[],
379
+ cmsSchema: CMSSchema,
380
+ isMultiLocale: boolean,
381
+ i18nConfig: I18nConfig,
382
+ binding: string,
383
+ richTextFields: Set<string>
384
+ ): string {
385
+ const escapedMeta = escapeTemplateLiteral(meta);
386
+ const escapedFontPreloads = escapeTemplateLiteral(fontPreloads);
387
+ const scriptsArrayLiteral = scriptPaths.length > 0
388
+ ? `[${scriptPaths.map((s) => `"${s}"`).join(', ')}]`
389
+ : '[]';
390
+ const libraryTagsLiteral = `{ headCSS: \`${escapeTemplateLiteral(libraryTags.headCSS || '')}\`, headJS: \`${escapeTemplateLiteral(libraryTags.headJS || '')}\`, bodyEndJS: \`${escapeTemplateLiteral(libraryTags.bodyEndJS || '')}\` }`;
391
+
392
+ const wrapFn = 'r';
393
+ const staticPaths = buildGetStaticPaths(cmsSchema, isMultiLocale, i18nConfig, locale);
394
+ const titleExpr = transformTitleExpression(title, binding, richTextFields, wrapFn);
395
+
396
+ const resolverHelper = `function r(v) {
397
+ if (v && typeof v === 'object' && v._i18n) return v['${locale}'] ?? v['${i18nConfig.defaultLocale}'] ?? Object.values(v).find(x => x !== true && x !== undefined) ?? '';
398
+ return v ?? '';
399
+ }`;
400
+
401
+ return `---
402
+ import { getCollection } from 'astro:content';
403
+ import BaseLayout from '${layoutImport}';
404
+
405
+ ${staticPaths}
406
+
407
+ ${resolverHelper}
408
+ ---
409
+ <BaseLayout
410
+ title=${titleExpr}
411
+ meta={\`${escapedMeta}\`}
412
+ scripts={${scriptsArrayLiteral}}
413
+ locale="${locale}"
414
+ theme="${theme}"
415
+ fontPreloads={\`${escapedFontPreloads}\`}
416
+ libraryTags={${libraryTagsLiteral}}
417
+ >
418
+ </BaseLayout>
419
+ `;
420
+ }