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
|
@@ -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.
|
|
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, '&').replace(/"/g, '"');
|
|
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
|
+
}
|