meno-core 1.0.52 → 1.0.53
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build-astro.ts +183 -13
- package/build-next.ts +1361 -0
- package/build-static.ts +7 -5
- package/dist/bin/cli.js +2 -2
- package/dist/build-static.js +6 -6
- package/dist/chunks/{chunk-HNLUO36W.js → chunk-GZHGVVW3.js} +2 -2
- package/dist/chunks/chunk-GZHGVVW3.js.map +7 -0
- package/dist/chunks/{chunk-LPVETICS.js → chunk-H3GJ4H2U.js} +185 -1
- package/dist/chunks/chunk-H3GJ4H2U.js.map +7 -0
- package/dist/chunks/{chunk-CXCBV2M7.js → chunk-IGYR22T6.js} +76 -270
- package/dist/chunks/chunk-IGYR22T6.js.map +7 -0
- package/dist/chunks/{chunk-LHLHPYSP.js → chunk-JGP5A3Y5.js} +12 -11
- package/dist/chunks/chunk-JGP5A3Y5.js.map +7 -0
- package/dist/chunks/{chunk-7NIC4I3V.js → chunk-JGWFTO6P.js} +167 -21
- package/dist/chunks/chunk-JGWFTO6P.js.map +7 -0
- package/dist/chunks/{chunk-EDQSMAMP.js → chunk-O3NAGJP4.js} +85 -4
- package/dist/chunks/chunk-O3NAGJP4.js.map +7 -0
- package/dist/chunks/{chunk-H4JSCDNW.js → chunk-QB2LNO4W.js} +24 -1
- package/dist/chunks/chunk-QB2LNO4W.js.map +7 -0
- package/dist/chunks/{chunk-A725KYFK.js → chunk-R6XHAFBF.js} +561 -112
- package/dist/chunks/chunk-R6XHAFBF.js.map +7 -0
- package/dist/chunks/{chunk-J23ZX5AP.js → chunk-X754AHS5.js} +277 -1
- package/dist/chunks/chunk-X754AHS5.js.map +7 -0
- package/dist/chunks/{chunk-2QK6U5UK.js → chunk-YBLHKYFF.js} +12 -2
- package/dist/chunks/chunk-YBLHKYFF.js.map +7 -0
- package/dist/chunks/{constants-GWBAD66U.js → constants-STK2YBIW.js} +2 -2
- package/dist/entries/server-router.js +7 -7
- package/dist/lib/client/index.js +354 -59
- package/dist/lib/client/index.js.map +4 -4
- package/dist/lib/server/index.js +1458 -190
- package/dist/lib/server/index.js.map +4 -4
- package/dist/lib/shared/index.js +202 -34
- package/dist/lib/shared/index.js.map +4 -4
- package/dist/lib/test-utils/index.js +1 -1
- package/entries/client-router.tsx +5 -165
- package/lib/client/ErrorBoundary.test.tsx +27 -25
- package/lib/client/ErrorBoundary.tsx +34 -19
- package/lib/client/core/ComponentBuilder.ts +19 -2
- package/lib/client/core/builders/embedBuilder.ts +8 -4
- package/lib/client/core/builders/listBuilder.ts +23 -4
- package/lib/client/fontFamiliesService.test.ts +76 -0
- package/lib/client/fontFamiliesService.ts +69 -0
- package/lib/client/hmrCssReload.ts +160 -0
- package/lib/client/hooks/useColorVariables.ts +2 -0
- package/lib/client/index.ts +4 -0
- package/lib/client/meno-filter/ui.ts +2 -0
- package/lib/client/routing/RouteLoader.test.ts +2 -2
- package/lib/client/routing/RouteLoader.ts +8 -2
- package/lib/client/routing/Router.tsx +81 -15
- package/lib/client/scripts/ScriptExecutor.test.ts +143 -0
- package/lib/client/scripts/ScriptExecutor.ts +56 -2
- package/lib/client/styles/StyleInjector.ts +20 -5
- package/lib/client/styles/UtilityClassCollector.ts +7 -1
- package/lib/client/styles/cspNonce.test.ts +67 -0
- package/lib/client/styles/cspNonce.ts +63 -0
- package/lib/client/templateEngine.test.ts +80 -0
- package/lib/client/templateEngine.ts +5 -0
- package/lib/server/astro/cmsPageEmitter.ts +35 -5
- package/lib/server/astro/componentEmitter.ts +61 -5
- package/lib/server/astro/nodeToAstro.ts +149 -11
- package/lib/server/astro/normalizeOrphanTemplateProps.test.ts +264 -0
- package/lib/server/astro/normalizeOrphanTemplateProps.ts +184 -0
- package/lib/server/createServer.ts +11 -0
- package/lib/server/draftPageStore.ts +49 -0
- package/lib/server/fileWatcher.ts +62 -2
- package/lib/server/index.ts +13 -1
- package/lib/server/providers/fileSystemPageProvider.ts +8 -0
- package/lib/server/routes/api/components.ts +9 -4
- package/lib/server/routes/api/core-routes.ts +2 -2
- package/lib/server/routes/api/pages.ts +14 -22
- package/lib/server/routes/api/shared.ts +56 -0
- package/lib/server/routes/index.ts +90 -0
- package/lib/server/routes/pages.ts +13 -6
- package/lib/server/services/componentService.test.ts +199 -2
- package/lib/server/services/componentService.ts +354 -49
- package/lib/server/services/fileWatcherService.ts +4 -24
- package/lib/server/services/pageService.test.ts +23 -0
- package/lib/server/services/pageService.ts +124 -6
- package/lib/server/ssr/attributeBuilder.ts +8 -2
- package/lib/server/ssr/buildErrorOverlay.ts +1 -1
- package/lib/server/ssr/errorOverlay.test.ts +21 -2
- package/lib/server/ssr/errorOverlay.ts +38 -11
- package/lib/server/ssr/htmlGenerator.test.ts +53 -13
- package/lib/server/ssr/htmlGenerator.ts +71 -27
- package/lib/server/ssr/liveReloadIntegration.test.ts +123 -2
- package/lib/server/ssr/ssrRenderer.test.ts +67 -0
- package/lib/server/ssr/ssrRenderer.ts +94 -9
- package/lib/server/websocketManager.ts +0 -1
- package/lib/shared/componentRefs.ts +45 -0
- package/lib/shared/constants.ts +8 -0
- package/lib/shared/cssGeneration.ts +2 -0
- package/lib/shared/cssProperties.ts +184 -0
- package/lib/shared/expressionEvaluator.ts +54 -0
- package/lib/shared/fontCss.ts +101 -0
- package/lib/shared/fontLoader.ts +8 -86
- package/lib/shared/friendlyError.test.ts +87 -0
- package/lib/shared/friendlyError.ts +121 -0
- package/lib/shared/hrefRefs.test.ts +130 -0
- package/lib/shared/hrefRefs.ts +100 -0
- package/lib/shared/index.ts +52 -0
- package/lib/shared/inlineSvgStyleRules.test.ts +108 -0
- package/lib/shared/inlineSvgStyleRules.ts +134 -0
- package/lib/shared/interfaces/contentProvider.ts +13 -0
- package/lib/shared/itemTemplateUtils.test.ts +14 -0
- package/lib/shared/itemTemplateUtils.ts +4 -1
- package/lib/shared/registry/NodeTypeDefinition.ts +1 -1
- package/lib/shared/registry/nodeTypes/LinkNodeType.ts +1 -1
- package/lib/shared/slugTranslator.test.ts +24 -0
- package/lib/shared/slugTranslator.ts +24 -0
- package/lib/shared/styleNodeUtils.ts +4 -1
- package/lib/shared/tree/PathBuilder.test.ts +128 -1
- package/lib/shared/tree/PathBuilder.ts +83 -31
- package/lib/shared/types/comment.ts +99 -0
- package/lib/shared/types/index.ts +12 -0
- package/lib/shared/types/rendering.ts +8 -0
- package/lib/shared/utilityClassConfig.ts +4 -2
- package/lib/shared/utilityClassMapper.test.ts +24 -0
- package/lib/shared/validation/commentValidators.ts +69 -0
- package/lib/shared/validation/index.ts +1 -0
- package/lib/shared/viewportUnits.integration.test.ts +42 -0
- package/lib/shared/viewportUnits.test.ts +103 -0
- package/lib/shared/viewportUnits.ts +63 -0
- package/lib/test-utils/dom-setup.ts +6 -0
- package/package.json +1 -1
- package/dist/chunks/chunk-2QK6U5UK.js.map +0 -7
- package/dist/chunks/chunk-7NIC4I3V.js.map +0 -7
- package/dist/chunks/chunk-A725KYFK.js.map +0 -7
- package/dist/chunks/chunk-CXCBV2M7.js.map +0 -7
- package/dist/chunks/chunk-EDQSMAMP.js.map +0 -7
- package/dist/chunks/chunk-H4JSCDNW.js.map +0 -7
- package/dist/chunks/chunk-HNLUO36W.js.map +0 -7
- package/dist/chunks/chunk-J23ZX5AP.js.map +0 -7
- package/dist/chunks/chunk-LHLHPYSP.js.map +0 -7
- package/dist/chunks/chunk-LPVETICS.js.map +0 -7
- /package/dist/chunks/{constants-GWBAD66U.js.map → constants-STK2YBIW.js.map} +0 -0
package/build-next.ts
ADDED
|
@@ -0,0 +1,1361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Next.js Export Build Script
|
|
3
|
+
* Renders all pages via the SSR pipeline, then wraps them as Next.js App Router
|
|
4
|
+
* server components (`app/<route>/page.tsx`) with a shared root layout, global
|
|
5
|
+
* CSS, and dynamic `[slug]` routes (via `generateStaticParams`) for CMS pages.
|
|
6
|
+
*
|
|
7
|
+
* Mirrors `buildAstroProject` but emits a Next.js 15 static export instead of
|
|
8
|
+
* an Astro project. The SSR HTML is embedded into each page via React's
|
|
9
|
+
* `dangerouslySetInnerHTML`, matching the Astro export's `<Fragment set:html>`
|
|
10
|
+
* fallback path. Inline `<script>` tags inside the SSR HTML run when the
|
|
11
|
+
* browser parses the statically exported `.html` file.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, readdirSync, mkdirSync, rmSync, statSync, copyFileSync, writeFileSync } from "fs";
|
|
15
|
+
import { writeFile, readFile } from "fs/promises";
|
|
16
|
+
import { join } from "path";
|
|
17
|
+
import { createHash } from "crypto";
|
|
18
|
+
import {
|
|
19
|
+
loadJSONFile,
|
|
20
|
+
loadComponentDirectory,
|
|
21
|
+
mapPageNameToPath,
|
|
22
|
+
parseJSON,
|
|
23
|
+
loadI18nConfig,
|
|
24
|
+
} from "./lib/server/jsonLoader";
|
|
25
|
+
import { projectPaths } from "./lib/server/projectContext";
|
|
26
|
+
import { loadProjectConfig, generateFontCSS, generateFontPreloadTags } from "./lib/shared/fontLoader";
|
|
27
|
+
import { FileSystemCMSProvider } from "./lib/server/providers/fileSystemCMSProvider";
|
|
28
|
+
import { CMSService } from "./lib/server/services/cmsService";
|
|
29
|
+
import { isI18nValue, resolveI18nValue } from "./lib/shared/i18n";
|
|
30
|
+
import type { ComponentDefinition, JSONPage, CMSSchema, CMSItem, I18nConfig } from "./lib/shared/types";
|
|
31
|
+
import { isItemDraftForLocale } from "./lib/shared/types";
|
|
32
|
+
import type { SlugMap } from "./lib/shared/slugTranslator";
|
|
33
|
+
import { renderPageSSR } from "./lib/server/ssr/ssrRenderer";
|
|
34
|
+
import { generateThemeColorVariablesCSS, generateVariablesCSS } from "./lib/server/cssGenerator";
|
|
35
|
+
import { colorService } from "./lib/server/services/ColorService";
|
|
36
|
+
import { variableService } from "./lib/server/services/VariableService";
|
|
37
|
+
import { configService } from "./lib/server/services/configService";
|
|
38
|
+
import { loadBreakpointConfig, loadIconsConfig } from "./lib/server/jsonLoader";
|
|
39
|
+
import type { InteractiveStyles } from "./lib/shared/types/styles";
|
|
40
|
+
import { collectComponentLibraries, filterLibrariesByContext, mergeLibraries, generateLibraryTags } from "./lib/shared/libraryLoader";
|
|
41
|
+
import { migrateTemplatesDirectory } from "./lib/server/migrateTemplates";
|
|
42
|
+
import { collectAllMappingClasses } from "./lib/server/astro/cssCollector";
|
|
43
|
+
import { generateAllInteractiveCSS, generateUtilityCSS, extractUtilityClassesFromHTML } from "./lib/shared/cssGeneration";
|
|
44
|
+
import { needsFormHandler, formHandlerScript } from "./lib/client/scripts/formHandler";
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Helpers
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
function hashContent(content: string): string {
|
|
51
|
+
return createHash('sha256').update(content).digest('hex').slice(0, 8);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function writePageScript(javascript: string | undefined, scriptsDir: string): string[] {
|
|
55
|
+
if (!javascript) return [];
|
|
56
|
+
const hash = hashContent(javascript);
|
|
57
|
+
const scriptFile = `${hash}.js`;
|
|
58
|
+
if (!existsSync(scriptsDir)) {
|
|
59
|
+
mkdirSync(scriptsDir, { recursive: true });
|
|
60
|
+
}
|
|
61
|
+
const fullScriptPath = join(scriptsDir, scriptFile);
|
|
62
|
+
if (!existsSync(fullScriptPath)) {
|
|
63
|
+
writeFileSync(fullScriptPath, javascript, 'utf-8');
|
|
64
|
+
}
|
|
65
|
+
return [`/_scripts/${scriptFile}`];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function copyDirectory(
|
|
69
|
+
src: string,
|
|
70
|
+
dest: string,
|
|
71
|
+
filter?: (filename: string) => boolean,
|
|
72
|
+
): void {
|
|
73
|
+
if (!existsSync(src)) return;
|
|
74
|
+
if (!existsSync(dest)) mkdirSync(dest, { recursive: true });
|
|
75
|
+
const files = readdirSync(src);
|
|
76
|
+
for (const file of files) {
|
|
77
|
+
if (filter && !filter(file)) continue;
|
|
78
|
+
const srcPath = join(src, file);
|
|
79
|
+
const destPath = join(dest, file);
|
|
80
|
+
const stat = statSync(srcPath);
|
|
81
|
+
if (stat.isDirectory()) copyDirectory(srcPath, destPath, filter);
|
|
82
|
+
else copyFileSync(srcPath, destPath);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isCMSPage(pageData: JSONPage): boolean {
|
|
87
|
+
return pageData.meta?.source === 'cms' && !!pageData.meta?.cms;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Build URL path for a CMS item based on the URL pattern
|
|
92
|
+
*/
|
|
93
|
+
function buildCMSItemPath(
|
|
94
|
+
urlPattern: string,
|
|
95
|
+
item: CMSItem,
|
|
96
|
+
slugField: string,
|
|
97
|
+
locale: string,
|
|
98
|
+
i18nConfig: I18nConfig
|
|
99
|
+
): string {
|
|
100
|
+
let slug = item[slugField] ?? item._slug ?? item._id;
|
|
101
|
+
if (isI18nValue(slug)) {
|
|
102
|
+
slug = resolveI18nValue(slug, locale, i18nConfig) as string;
|
|
103
|
+
}
|
|
104
|
+
return urlPattern.replace('{{slug}}', String(slug));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Recursively scan a directory for .json files, returning relative paths.
|
|
109
|
+
*/
|
|
110
|
+
function scanJSONFiles(dir: string, prefix: string = ''): string[] {
|
|
111
|
+
const results: string[] = [];
|
|
112
|
+
if (!existsSync(dir)) return results;
|
|
113
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
114
|
+
for (const entry of entries) {
|
|
115
|
+
if (entry.isFile() && entry.name.endsWith('.json')) {
|
|
116
|
+
results.push(prefix ? `${prefix}/${entry.name}` : entry.name);
|
|
117
|
+
} else if (entry.isDirectory()) {
|
|
118
|
+
results.push(...scanJSONFiles(join(dir, entry.name), prefix ? `${prefix}/${entry.name}` : entry.name));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return results;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Escape a string for use inside a JS backtick template literal.
|
|
126
|
+
* The output is wrapped in backticks by callers.
|
|
127
|
+
*/
|
|
128
|
+
function escapeTemplateLiteral(s: string): string {
|
|
129
|
+
return s
|
|
130
|
+
.replace(/\\/g, '\\\\')
|
|
131
|
+
.replace(/`/g, '\\`')
|
|
132
|
+
.replace(/\$\{/g, '\\${')
|
|
133
|
+
.replace(/\u2028/g, '\\u2028')
|
|
134
|
+
.replace(/\u2029/g, '\\u2029');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Escape a string for use inside a JS single-quoted string literal.
|
|
139
|
+
*/
|
|
140
|
+
function escapeSingleQuoted(s: string): string {
|
|
141
|
+
return s
|
|
142
|
+
.replace(/\\/g, '\\\\')
|
|
143
|
+
.replace(/'/g, "\\'")
|
|
144
|
+
.replace(/\n/g, '\\n')
|
|
145
|
+
.replace(/\r/g, '\\r')
|
|
146
|
+
.replace(/\u2028/g, '\\u2028')
|
|
147
|
+
.replace(/\u2029/g, '\\u2029');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Build the URL path → `app/<route>/page.tsx` mapping for a given URL.
|
|
152
|
+
* The home route lives at `app/page.tsx`; everything else lives at
|
|
153
|
+
* `app/<segments>/page.tsx`. Special characters in path segments are
|
|
154
|
+
* left as-is so the URL ↔ file mapping matches Next.js conventions.
|
|
155
|
+
*/
|
|
156
|
+
function urlPathToAppRoute(urlPath: string): { dir: string; isRoot: boolean } {
|
|
157
|
+
if (urlPath === '/' || urlPath === '') {
|
|
158
|
+
return { dir: '', isRoot: true };
|
|
159
|
+
}
|
|
160
|
+
const trimmed = urlPath.replace(/^\/+/, '').replace(/\/+$/, '');
|
|
161
|
+
return { dir: trimmed, isRoot: false };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Types
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
interface PageRenderResult {
|
|
169
|
+
/** Body HTML (inner content, no DOCTYPE wrapper) */
|
|
170
|
+
html: string;
|
|
171
|
+
/** Head meta tags HTML string */
|
|
172
|
+
meta: string;
|
|
173
|
+
/** Page title */
|
|
174
|
+
title: string;
|
|
175
|
+
/** Extracted JavaScript (if any) */
|
|
176
|
+
javascript: string;
|
|
177
|
+
/** Per-component CSS */
|
|
178
|
+
componentCSS?: string;
|
|
179
|
+
/** Locale used */
|
|
180
|
+
locale: string;
|
|
181
|
+
/** Interactive styles (hover, focus, etc.) */
|
|
182
|
+
interactiveStylesMap: Map<string, InteractiveStyles>;
|
|
183
|
+
/** The URL path this page will live at */
|
|
184
|
+
urlPath: string;
|
|
185
|
+
/** Original page data */
|
|
186
|
+
pageData?: JSONPage;
|
|
187
|
+
/** Page name without extension */
|
|
188
|
+
pageName?: string;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
interface NextBuildStats {
|
|
192
|
+
pages: number;
|
|
193
|
+
cmsPages: number;
|
|
194
|
+
collections: number;
|
|
195
|
+
errors: number;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// Head <meta> tag converter
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Parse the SSR `meta` HTML string into JSX-ready React element source.
|
|
204
|
+
*
|
|
205
|
+
* `generateMetaTags()` emits one well-formed self-closing or paired tag per
|
|
206
|
+
* line: `<title>...</title>`, `<meta ... />`, `<link ... />`. React 19 hoists
|
|
207
|
+
* `<title>`, `<meta>`, and `<link>` JSX elements to `<head>` automatically, so
|
|
208
|
+
* we just need to emit those tags as React-compatible JSX.
|
|
209
|
+
*
|
|
210
|
+
* Returns a JSX fragment string (no surrounding braces) suitable for inlining
|
|
211
|
+
* into a `.tsx` file.
|
|
212
|
+
*/
|
|
213
|
+
function metaHtmlToJSX(metaHtml: string): string {
|
|
214
|
+
if (!metaHtml || !metaHtml.trim()) return '';
|
|
215
|
+
const lines = metaHtml.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
216
|
+
|
|
217
|
+
const elements: string[] = [];
|
|
218
|
+
for (const line of lines) {
|
|
219
|
+
// <title>foo</title>
|
|
220
|
+
const titleMatch = line.match(/^<title>([\s\S]*?)<\/title>$/);
|
|
221
|
+
if (titleMatch) {
|
|
222
|
+
elements.push(`<title>{${JSON.stringify(decodeBasicEntities(titleMatch[1]))}}</title>`);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
// <meta ... /> or <link ... />
|
|
226
|
+
const selfMatch = line.match(/^<(meta|link)\s+([\s\S]*?)\s*\/?>$/);
|
|
227
|
+
if (selfMatch) {
|
|
228
|
+
const tagName = selfMatch[1];
|
|
229
|
+
const attrsSrc = selfMatch[2];
|
|
230
|
+
const attrs = parseAttrs(attrsSrc);
|
|
231
|
+
const attrParts = Object.entries(attrs)
|
|
232
|
+
.map(([k, v]) => `${jsxAttrName(k)}=${JSON.stringify(v)}`)
|
|
233
|
+
.join(' ');
|
|
234
|
+
elements.push(`<${tagName} ${attrParts} />`);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
// Unknown tag — skip rather than crash the build.
|
|
238
|
+
}
|
|
239
|
+
return elements.join('\n ');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Map HTML attribute names to their JSX equivalents. `<meta>` / `<link>` /
|
|
244
|
+
* `<title>` are the only emit targets so the rename set stays small —
|
|
245
|
+
* extend this lookup if `generateMetaTags()` starts emitting new attrs.
|
|
246
|
+
*/
|
|
247
|
+
const JSX_ATTR_NAMES: Record<string, string> = {
|
|
248
|
+
'http-equiv': 'httpEquiv',
|
|
249
|
+
'hreflang': 'hrefLang',
|
|
250
|
+
'crossorigin': 'crossOrigin',
|
|
251
|
+
'referrerpolicy': 'referrerPolicy',
|
|
252
|
+
'imagesrcset': 'imageSrcSet',
|
|
253
|
+
'imagesizes': 'imageSizes',
|
|
254
|
+
'fetchpriority': 'fetchPriority',
|
|
255
|
+
'class': 'className',
|
|
256
|
+
'for': 'htmlFor',
|
|
257
|
+
'charset': 'charSet',
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
function jsxAttrName(name: string): string {
|
|
261
|
+
return JSX_ATTR_NAMES[name] ?? name;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Parse a flat attribute list like `name="og:title" content="Hello"` into a
|
|
266
|
+
* record. Values must be double-quoted; this matches what `generateMetaTags()`
|
|
267
|
+
* emits today.
|
|
268
|
+
*/
|
|
269
|
+
function parseAttrs(src: string): Record<string, string> {
|
|
270
|
+
const out: Record<string, string> = {};
|
|
271
|
+
const re = /([a-zA-Z_:][\w:.-]*)\s*=\s*"([^"]*)"/g;
|
|
272
|
+
let match: RegExpExecArray | null;
|
|
273
|
+
while ((match = re.exec(src)) !== null) {
|
|
274
|
+
out[match[1]] = decodeBasicEntities(match[2]);
|
|
275
|
+
}
|
|
276
|
+
return out;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Decode the basic HTML entities that `escapeHtml()` in metaTagGenerator emits
|
|
281
|
+
* (`&`, `"`, `<`, `>`, `'`). The output is then re-escaped
|
|
282
|
+
* by `JSON.stringify` when we emit it as a JSX string attribute, so this
|
|
283
|
+
* round-trip is safe.
|
|
284
|
+
*/
|
|
285
|
+
function decodeBasicEntities(s: string): string {
|
|
286
|
+
return s
|
|
287
|
+
.replace(/"/g, '"')
|
|
288
|
+
.replace(/'/g, "'")
|
|
289
|
+
.replace(/</g, '<')
|
|
290
|
+
.replace(/>/g, '>')
|
|
291
|
+
.replace(/&/g, '&');
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
// page.tsx emitter
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Emit a Next.js App Router page component that renders the SSR HTML.
|
|
300
|
+
* The page is a server component (no `'use client'`) so the inline scripts
|
|
301
|
+
* inside `__html` execute as part of the statically exported HTML file.
|
|
302
|
+
*/
|
|
303
|
+
function emitNextPage(options: {
|
|
304
|
+
html: string;
|
|
305
|
+
meta: string;
|
|
306
|
+
title: string;
|
|
307
|
+
locale: string;
|
|
308
|
+
theme: string;
|
|
309
|
+
fontPreloads: string;
|
|
310
|
+
libraryTags: { headCSS?: string; headJS?: string; bodyEndJS?: string };
|
|
311
|
+
scriptPaths: string[];
|
|
312
|
+
customCode: { head?: string; bodyStart?: string; bodyEnd?: string };
|
|
313
|
+
iconTagsHtml: string;
|
|
314
|
+
formHandlerNeeded: boolean;
|
|
315
|
+
}): string {
|
|
316
|
+
const {
|
|
317
|
+
html,
|
|
318
|
+
meta,
|
|
319
|
+
title,
|
|
320
|
+
locale,
|
|
321
|
+
theme,
|
|
322
|
+
fontPreloads,
|
|
323
|
+
libraryTags,
|
|
324
|
+
scriptPaths,
|
|
325
|
+
customCode,
|
|
326
|
+
iconTagsHtml,
|
|
327
|
+
formHandlerNeeded,
|
|
328
|
+
} = options;
|
|
329
|
+
|
|
330
|
+
const metaJSX = metaHtmlToJSX(meta);
|
|
331
|
+
|
|
332
|
+
// Combine all head-targeted raw HTML strings into one block. Order matches
|
|
333
|
+
// BaseLayout.astro: icons, font preloads, library head CSS/JS, custom head.
|
|
334
|
+
const headHtmlBlocks: string[] = [];
|
|
335
|
+
if (iconTagsHtml) headHtmlBlocks.push(iconTagsHtml);
|
|
336
|
+
if (fontPreloads) headHtmlBlocks.push(fontPreloads);
|
|
337
|
+
if (libraryTags.headCSS) headHtmlBlocks.push(libraryTags.headCSS);
|
|
338
|
+
if (libraryTags.headJS) headHtmlBlocks.push(libraryTags.headJS);
|
|
339
|
+
if (customCode.head) headHtmlBlocks.push(customCode.head);
|
|
340
|
+
const headHtml = headHtmlBlocks.join('\n');
|
|
341
|
+
|
|
342
|
+
// Body-end raw HTML: scripts + library body-end + custom body-end + form
|
|
343
|
+
// handler. These run after the SSR HTML so they can attach behavior to
|
|
344
|
+
// server-rendered nodes (matches BaseLayout.astro ordering).
|
|
345
|
+
const scriptTags = scriptPaths.map((s) => `<script src="${s}" defer></script>`).join('\n');
|
|
346
|
+
const bodyEndBlocks: string[] = [];
|
|
347
|
+
if (scriptTags) bodyEndBlocks.push(scriptTags);
|
|
348
|
+
if (libraryTags.bodyEndJS) bodyEndBlocks.push(libraryTags.bodyEndJS);
|
|
349
|
+
if (customCode.bodyEnd) bodyEndBlocks.push(customCode.bodyEnd);
|
|
350
|
+
if (formHandlerNeeded) bodyEndBlocks.push(`<script>${formHandlerScript}</script>`);
|
|
351
|
+
const bodyEndHtml = bodyEndBlocks.join('\n');
|
|
352
|
+
|
|
353
|
+
const bodyStartHtml = customCode.bodyStart || '';
|
|
354
|
+
|
|
355
|
+
// Emit the body HTML and any raw HTML head/body fragments as JS template
|
|
356
|
+
// literals so embedded backticks and ${...} sequences in the SSR output are
|
|
357
|
+
// neutralized. The runtime reads them back as plain strings.
|
|
358
|
+
return `// Auto-generated by meno-core/build-next. Do not edit.
|
|
359
|
+
import RawHead from '../components/RawHead';
|
|
360
|
+
|
|
361
|
+
const TITLE = ${JSON.stringify(title)};
|
|
362
|
+
const LOCALE = ${JSON.stringify(locale)};
|
|
363
|
+
const THEME = ${JSON.stringify(theme)};
|
|
364
|
+
const HEAD_HTML = \`${escapeTemplateLiteral(headHtml)}\`;
|
|
365
|
+
const BODY_START_HTML = \`${escapeTemplateLiteral(bodyStartHtml)}\`;
|
|
366
|
+
const PAGE_HTML = \`${escapeTemplateLiteral(html)}\`;
|
|
367
|
+
const BODY_END_HTML = \`${escapeTemplateLiteral(bodyEndHtml)}\`;
|
|
368
|
+
|
|
369
|
+
export const metadata = {
|
|
370
|
+
title: TITLE,
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
export default function Page() {
|
|
374
|
+
return (
|
|
375
|
+
<>
|
|
376
|
+
${metaJSX || ''}
|
|
377
|
+
<RawHead html={HEAD_HTML} locale={LOCALE} theme={THEME} />
|
|
378
|
+
{BODY_START_HTML ? <div data-meno-body-start dangerouslySetInnerHTML={{ __html: BODY_START_HTML }} /> : null}
|
|
379
|
+
<div id="root" dangerouslySetInnerHTML={{ __html: PAGE_HTML }} />
|
|
380
|
+
{BODY_END_HTML ? <div data-meno-body-end dangerouslySetInnerHTML={{ __html: BODY_END_HTML }} /> : null}
|
|
381
|
+
</>
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
`;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Emit a CMS-template page (`app/<prefix>/[slug]/page.tsx`) that uses
|
|
389
|
+
* `generateStaticParams` to pre-render one HTML file per CMS item.
|
|
390
|
+
*
|
|
391
|
+
* Each rendered page is just an SSR HTML wrap (same shape as `emitNextPage`),
|
|
392
|
+
* keyed by the item slug + locale. We pre-render the HTML for every
|
|
393
|
+
* combination at build time and embed it in a lookup table in the page module
|
|
394
|
+
* so `generateStaticParams` + the default export work together.
|
|
395
|
+
*/
|
|
396
|
+
function emitNextCMSPage(options: {
|
|
397
|
+
slugs: string[];
|
|
398
|
+
perSlugData: Record<string, {
|
|
399
|
+
html: string;
|
|
400
|
+
meta: string;
|
|
401
|
+
title: string;
|
|
402
|
+
scriptPaths: string[];
|
|
403
|
+
}>;
|
|
404
|
+
locale: string;
|
|
405
|
+
theme: string;
|
|
406
|
+
fontPreloads: string;
|
|
407
|
+
libraryTags: { headCSS?: string; headJS?: string; bodyEndJS?: string };
|
|
408
|
+
customCode: { head?: string; bodyStart?: string; bodyEnd?: string };
|
|
409
|
+
iconTagsHtml: string;
|
|
410
|
+
formHandlerNeeded: boolean;
|
|
411
|
+
}): string {
|
|
412
|
+
const {
|
|
413
|
+
slugs,
|
|
414
|
+
perSlugData,
|
|
415
|
+
locale,
|
|
416
|
+
theme,
|
|
417
|
+
fontPreloads,
|
|
418
|
+
libraryTags,
|
|
419
|
+
customCode,
|
|
420
|
+
iconTagsHtml,
|
|
421
|
+
formHandlerNeeded,
|
|
422
|
+
} = options;
|
|
423
|
+
|
|
424
|
+
const headHtmlBlocks: string[] = [];
|
|
425
|
+
if (iconTagsHtml) headHtmlBlocks.push(iconTagsHtml);
|
|
426
|
+
if (fontPreloads) headHtmlBlocks.push(fontPreloads);
|
|
427
|
+
if (libraryTags.headCSS) headHtmlBlocks.push(libraryTags.headCSS);
|
|
428
|
+
if (libraryTags.headJS) headHtmlBlocks.push(libraryTags.headJS);
|
|
429
|
+
if (customCode.head) headHtmlBlocks.push(customCode.head);
|
|
430
|
+
const headHtml = headHtmlBlocks.join('\n');
|
|
431
|
+
|
|
432
|
+
const bodyStartHtml = customCode.bodyStart || '';
|
|
433
|
+
const trailingFormHandler = formHandlerNeeded ? `<script>${formHandlerScript}</script>` : '';
|
|
434
|
+
|
|
435
|
+
// Build the lookup map: slug → { html, metaJsx, title, bodyEndHtml }
|
|
436
|
+
const entries: string[] = [];
|
|
437
|
+
for (const slug of slugs) {
|
|
438
|
+
const data = perSlugData[slug];
|
|
439
|
+
if (!data) continue;
|
|
440
|
+
const scriptTags = data.scriptPaths.map((s) => `<script src="${s}" defer></script>`).join('\n');
|
|
441
|
+
const bodyEndBlocks: string[] = [];
|
|
442
|
+
if (scriptTags) bodyEndBlocks.push(scriptTags);
|
|
443
|
+
if (libraryTags.bodyEndJS) bodyEndBlocks.push(libraryTags.bodyEndJS);
|
|
444
|
+
if (customCode.bodyEnd) bodyEndBlocks.push(customCode.bodyEnd);
|
|
445
|
+
if (trailingFormHandler) bodyEndBlocks.push(trailingFormHandler);
|
|
446
|
+
const bodyEndHtml = bodyEndBlocks.join('\n');
|
|
447
|
+
|
|
448
|
+
entries.push(
|
|
449
|
+
` ${JSON.stringify(slug)}: {
|
|
450
|
+
title: ${JSON.stringify(data.title)},
|
|
451
|
+
metaHtml: \`${escapeTemplateLiteral(data.meta)}\`,
|
|
452
|
+
html: \`${escapeTemplateLiteral(data.html)}\`,
|
|
453
|
+
bodyEndHtml: \`${escapeTemplateLiteral(bodyEndHtml)}\`,
|
|
454
|
+
}`
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return `// Auto-generated by meno-core/build-next. Do not edit.
|
|
459
|
+
import RawHead from '${cmsRawHeadImport(options)}';
|
|
460
|
+
import MetaTags from '${cmsMetaTagsImport(options)}';
|
|
461
|
+
|
|
462
|
+
const LOCALE = ${JSON.stringify(locale)};
|
|
463
|
+
const THEME = ${JSON.stringify(theme)};
|
|
464
|
+
const HEAD_HTML = \`${escapeTemplateLiteral(headHtml)}\`;
|
|
465
|
+
const BODY_START_HTML = \`${escapeTemplateLiteral(bodyStartHtml)}\`;
|
|
466
|
+
|
|
467
|
+
type Entry = {
|
|
468
|
+
title: string;
|
|
469
|
+
metaHtml: string;
|
|
470
|
+
html: string;
|
|
471
|
+
bodyEndHtml: string;
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
const ENTRIES: Record<string, Entry> = {
|
|
475
|
+
${entries.join(',\n')}
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
export function generateStaticParams() {
|
|
479
|
+
return Object.keys(ENTRIES).map((slug) => ({ slug }));
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
|
|
483
|
+
const { slug } = await params;
|
|
484
|
+
const entry = ENTRIES[slug];
|
|
485
|
+
return entry ? { title: entry.title } : {};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
|
|
489
|
+
const { slug } = await params;
|
|
490
|
+
const entry = ENTRIES[slug];
|
|
491
|
+
if (!entry) return null;
|
|
492
|
+
return (
|
|
493
|
+
<>
|
|
494
|
+
<MetaTags html={entry.metaHtml} />
|
|
495
|
+
<RawHead html={HEAD_HTML} locale={LOCALE} theme={THEME} />
|
|
496
|
+
{BODY_START_HTML ? <div data-meno-body-start dangerouslySetInnerHTML={{ __html: BODY_START_HTML }} /> : null}
|
|
497
|
+
<div id="root" dangerouslySetInnerHTML={{ __html: entry.html }} />
|
|
498
|
+
{entry.bodyEndHtml ? <div data-meno-body-end dangerouslySetInnerHTML={{ __html: entry.bodyEndHtml }} /> : null}
|
|
499
|
+
</>
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
`;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function cmsRawHeadImport(_options: { /* keep for future depth handling */ }): string {
|
|
506
|
+
// CMS files live two levels under app/ (e.g. app/blog/[slug]/page.tsx), so
|
|
507
|
+
// the relative path to app/components/RawHead.tsx is "../../components/RawHead".
|
|
508
|
+
// When the URL pattern's path prefix is empty (e.g. "/[slug]"), they live at
|
|
509
|
+
// app/[slug]/page.tsx — one level deep. The caller passes the correct depth
|
|
510
|
+
// via the file location; we always emit "../../components/RawHead" because
|
|
511
|
+
// build-next places CMS pages at depth 2 (see emitCMSPages).
|
|
512
|
+
return '../../components/RawHead';
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function cmsMetaTagsImport(_options: { /* placeholder */ }): string {
|
|
516
|
+
return '../../components/MetaTags';
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// ---------------------------------------------------------------------------
|
|
520
|
+
// Main export
|
|
521
|
+
// ---------------------------------------------------------------------------
|
|
522
|
+
|
|
523
|
+
export async function buildNextProject(
|
|
524
|
+
projectRoot?: string,
|
|
525
|
+
outputDir?: string
|
|
526
|
+
): Promise<NextBuildStats> {
|
|
527
|
+
// ----------------------------------------------------------
|
|
528
|
+
// 1. Setup: load project configuration
|
|
529
|
+
// ----------------------------------------------------------
|
|
530
|
+
configService.reset();
|
|
531
|
+
|
|
532
|
+
const projectConfig = await loadProjectConfig();
|
|
533
|
+
const siteUrl = (projectConfig as { siteUrl?: string }).siteUrl?.replace(/\/$/, '') || '';
|
|
534
|
+
|
|
535
|
+
const i18nConfig = await loadI18nConfig();
|
|
536
|
+
|
|
537
|
+
await migrateTemplatesDirectory();
|
|
538
|
+
|
|
539
|
+
const { components, warnings, errors: compErrors } = await loadComponentDirectory(projectPaths.components());
|
|
540
|
+
const globalComponents: Record<string, ComponentDefinition> = {};
|
|
541
|
+
components.forEach((value, key) => { globalComponents[key] = value; });
|
|
542
|
+
for (const w of warnings) console.warn(` Warning: ${w}`);
|
|
543
|
+
for (const e of compErrors) console.error(` Error: ${e}`);
|
|
544
|
+
|
|
545
|
+
const cmsProvider = new FileSystemCMSProvider(projectPaths.templates(), projectPaths.cms());
|
|
546
|
+
const cmsService = new CMSService(cmsProvider);
|
|
547
|
+
await cmsService.initialize();
|
|
548
|
+
|
|
549
|
+
const themeConfig = await colorService.loadThemeConfig();
|
|
550
|
+
const variablesConfig = await variableService.loadConfig();
|
|
551
|
+
const breakpoints = await loadBreakpointConfig();
|
|
552
|
+
|
|
553
|
+
await configService.load();
|
|
554
|
+
const responsiveScales = configService.getResponsiveScales();
|
|
555
|
+
const globalLibraries = configService.getLibraries();
|
|
556
|
+
const componentLibraries = collectComponentLibraries(globalComponents);
|
|
557
|
+
|
|
558
|
+
// ----------------------------------------------------------
|
|
559
|
+
// 2. Clean and create output directory
|
|
560
|
+
// ----------------------------------------------------------
|
|
561
|
+
const outDir = outputDir || join(projectPaths.project, 'next-export');
|
|
562
|
+
|
|
563
|
+
if (existsSync(outDir)) {
|
|
564
|
+
rmSync(outDir, { recursive: true, force: true });
|
|
565
|
+
}
|
|
566
|
+
mkdirSync(outDir, { recursive: true });
|
|
567
|
+
|
|
568
|
+
const appDir = join(outDir, 'app');
|
|
569
|
+
const componentsDir = join(appDir, 'components');
|
|
570
|
+
const publicDir = join(outDir, 'public');
|
|
571
|
+
const scriptsDir = join(publicDir, '_scripts');
|
|
572
|
+
for (const d of [appDir, componentsDir, publicDir]) {
|
|
573
|
+
mkdirSync(d, { recursive: true });
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// ----------------------------------------------------------
|
|
577
|
+
// 3. Scan pages and gather slug mappings
|
|
578
|
+
// ----------------------------------------------------------
|
|
579
|
+
const pagesDir = projectPaths.pages();
|
|
580
|
+
if (!existsSync(pagesDir)) {
|
|
581
|
+
console.error('Pages directory not found!');
|
|
582
|
+
return { pages: 0, cmsPages: 0, collections: 0, errors: 1 };
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const pageFiles = scanJSONFiles(pagesDir);
|
|
586
|
+
if (pageFiles.length === 0) {
|
|
587
|
+
console.warn('No pages found in ./pages directory');
|
|
588
|
+
return { pages: 0, cmsPages: 0, collections: 0, errors: 0 };
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const slugMappings: SlugMap[] = [];
|
|
592
|
+
for (const file of pageFiles) {
|
|
593
|
+
const pageName = file.replace('.json', '');
|
|
594
|
+
const basePath = mapPageNameToPath(pageName);
|
|
595
|
+
const pageContent = await loadJSONFile(join(pagesDir, file));
|
|
596
|
+
if (!pageContent) continue;
|
|
597
|
+
try {
|
|
598
|
+
const pageData = parseJSON<JSONPage>(pageContent);
|
|
599
|
+
if (pageData.meta?.slugs) {
|
|
600
|
+
const pageId = basePath === '/' ? 'index' : basePath.substring(1);
|
|
601
|
+
slugMappings.push({ pageId, slugs: pageData.meta.slugs });
|
|
602
|
+
}
|
|
603
|
+
} catch { /* ignore parse errors in first pass */ }
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// ----------------------------------------------------------
|
|
607
|
+
// 4. Render regular pages
|
|
608
|
+
// ----------------------------------------------------------
|
|
609
|
+
const allResults: PageRenderResult[] = [];
|
|
610
|
+
const allInteractiveStyles = new Map<string, InteractiveStyles>();
|
|
611
|
+
const allComponentCSS = new Set<string>();
|
|
612
|
+
const allUtilityClasses = new Set<string>();
|
|
613
|
+
const jsContents = new Map<string, string>();
|
|
614
|
+
let errorCount = 0;
|
|
615
|
+
let projectNeedsFormHandler = false;
|
|
616
|
+
|
|
617
|
+
function mergeInteractiveStyles(source: Map<string, InteractiveStyles>): void {
|
|
618
|
+
for (const [key, value] of source) {
|
|
619
|
+
if (!allInteractiveStyles.has(key)) {
|
|
620
|
+
allInteractiveStyles.set(key, value);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function recordRender(
|
|
626
|
+
result: { html: string; meta: string; title: string; javascript: string; componentCSS?: string; locale: string; interactiveStylesMap: Map<string, InteractiveStyles> },
|
|
627
|
+
urlPath: string,
|
|
628
|
+
pageData?: JSONPage,
|
|
629
|
+
pageName?: string,
|
|
630
|
+
): void {
|
|
631
|
+
mergeInteractiveStyles(result.interactiveStylesMap);
|
|
632
|
+
if (result.componentCSS) allComponentCSS.add(result.componentCSS);
|
|
633
|
+
for (const c of extractUtilityClassesFromHTML(result.html)) {
|
|
634
|
+
allUtilityClasses.add(c);
|
|
635
|
+
}
|
|
636
|
+
if (result.javascript) {
|
|
637
|
+
const hash = hashContent(result.javascript);
|
|
638
|
+
if (!jsContents.has(hash)) {
|
|
639
|
+
jsContents.set(hash, result.javascript);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
if (!projectNeedsFormHandler && needsFormHandler(result.html)) {
|
|
643
|
+
projectNeedsFormHandler = true;
|
|
644
|
+
}
|
|
645
|
+
allResults.push({
|
|
646
|
+
html: result.html,
|
|
647
|
+
meta: result.meta,
|
|
648
|
+
title: result.title,
|
|
649
|
+
javascript: result.javascript,
|
|
650
|
+
componentCSS: result.componentCSS,
|
|
651
|
+
locale: result.locale,
|
|
652
|
+
interactiveStylesMap: result.interactiveStylesMap,
|
|
653
|
+
urlPath,
|
|
654
|
+
pageData,
|
|
655
|
+
pageName,
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
for (const file of pageFiles) {
|
|
660
|
+
const pageName = file.replace('.json', '');
|
|
661
|
+
const basePath = mapPageNameToPath(pageName);
|
|
662
|
+
const pageContent = await loadJSONFile(join(pagesDir, file));
|
|
663
|
+
|
|
664
|
+
if (!pageContent) {
|
|
665
|
+
console.warn(` Skipping ${basePath} (empty file)`);
|
|
666
|
+
errorCount++;
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
try {
|
|
671
|
+
const pageData = parseJSON<JSONPage>(pageContent);
|
|
672
|
+
|
|
673
|
+
const isDevBuild = process.env.MENO_DEV_BUILD === 'true';
|
|
674
|
+
if (pageData.meta?.draft === true && !isDevBuild) {
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const slugs = pageData.meta?.slugs;
|
|
679
|
+
|
|
680
|
+
for (const localeConfig of i18nConfig.locales) {
|
|
681
|
+
const locale = localeConfig.code;
|
|
682
|
+
const isDefault = locale === i18nConfig.defaultLocale;
|
|
683
|
+
|
|
684
|
+
let slug: string;
|
|
685
|
+
if (slugs && slugs[locale]) {
|
|
686
|
+
slug = slugs[locale];
|
|
687
|
+
} else if (basePath === '/') {
|
|
688
|
+
slug = '';
|
|
689
|
+
} else {
|
|
690
|
+
slug = basePath.substring(1);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const urlPath = isDefault
|
|
694
|
+
? (slug === '' ? '/' : `/${slug}`)
|
|
695
|
+
: (slug === '' ? `/${locale}` : `/${locale}/${slug}`);
|
|
696
|
+
|
|
697
|
+
const result = await renderPageSSR(
|
|
698
|
+
pageData,
|
|
699
|
+
globalComponents,
|
|
700
|
+
urlPath,
|
|
701
|
+
siteUrl,
|
|
702
|
+
locale,
|
|
703
|
+
i18nConfig,
|
|
704
|
+
slugMappings,
|
|
705
|
+
undefined,
|
|
706
|
+
cmsService,
|
|
707
|
+
true
|
|
708
|
+
);
|
|
709
|
+
|
|
710
|
+
recordRender(result, urlPath, pageData, pageName);
|
|
711
|
+
}
|
|
712
|
+
} catch (error) {
|
|
713
|
+
const err = error as { message?: string };
|
|
714
|
+
console.error(` Error rendering ${basePath}:`, err?.message || error);
|
|
715
|
+
errorCount++;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// ----------------------------------------------------------
|
|
720
|
+
// 5. Pre-compute shared layout dependencies
|
|
721
|
+
// ----------------------------------------------------------
|
|
722
|
+
const fontPreloads = generateFontPreloadTags();
|
|
723
|
+
const mergedLibraries = mergeLibraries(globalLibraries, componentLibraries);
|
|
724
|
+
const buildLibraries = filterLibrariesByContext(mergedLibraries, 'build');
|
|
725
|
+
|
|
726
|
+
const inlineContents = new Map<string, string>();
|
|
727
|
+
const localLibsToCopy: string[] = [];
|
|
728
|
+
for (const css of buildLibraries.css || []) {
|
|
729
|
+
if (!css.url.startsWith('/')) continue;
|
|
730
|
+
const shouldInline = css.inline !== false;
|
|
731
|
+
const relPath = css.url.slice(1);
|
|
732
|
+
const srcPath = join(projectPaths.project, relPath);
|
|
733
|
+
if (!existsSync(srcPath)) continue;
|
|
734
|
+
if (shouldInline) {
|
|
735
|
+
try {
|
|
736
|
+
inlineContents.set(css.url, await readFile(srcPath, 'utf-8'));
|
|
737
|
+
} catch {
|
|
738
|
+
localLibsToCopy.push(relPath);
|
|
739
|
+
}
|
|
740
|
+
} else {
|
|
741
|
+
localLibsToCopy.push(relPath);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
for (const js of buildLibraries.js || []) {
|
|
745
|
+
if (js.url.startsWith('/')) {
|
|
746
|
+
const relPath = js.url.slice(1);
|
|
747
|
+
if (existsSync(join(projectPaths.project, relPath))) {
|
|
748
|
+
localLibsToCopy.push(relPath);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
const libraryTags = generateLibraryTags(buildLibraries, inlineContents);
|
|
753
|
+
const defaultTheme = themeConfig.default || 'light';
|
|
754
|
+
|
|
755
|
+
const customCode = configService.getCustomCode();
|
|
756
|
+
const iconsConfig = await loadIconsConfig();
|
|
757
|
+
const hasDarkFavicon = !!(iconsConfig.favicon && iconsConfig.faviconDark);
|
|
758
|
+
const faviconTag = iconsConfig.favicon
|
|
759
|
+
? `<link rel="icon" href="${iconsConfig.favicon.replace(/"/g, '"')}"${hasDarkFavicon ? ' media="(prefers-color-scheme: light)"' : ''} />`
|
|
760
|
+
: '';
|
|
761
|
+
const faviconDarkTag = iconsConfig.faviconDark
|
|
762
|
+
? `<link rel="icon" href="${iconsConfig.faviconDark.replace(/"/g, '"')}" media="(prefers-color-scheme: dark)" />`
|
|
763
|
+
: '';
|
|
764
|
+
const appleTouchIconTag = iconsConfig.appleTouchIcon
|
|
765
|
+
? `<link rel="apple-touch-icon" href="${iconsConfig.appleTouchIcon.replace(/"/g, '"')}" />`
|
|
766
|
+
: '';
|
|
767
|
+
const iconTagsHtml = [faviconTag, faviconDarkTag, appleTouchIconTag].filter(Boolean).join('\n ');
|
|
768
|
+
|
|
769
|
+
const remConversionConfig = configService.getRemConversion();
|
|
770
|
+
|
|
771
|
+
// ----------------------------------------------------------
|
|
772
|
+
// 6. Render CMS template pages
|
|
773
|
+
// ----------------------------------------------------------
|
|
774
|
+
const templatesDir = projectPaths.templates();
|
|
775
|
+
const templateSchemas: CMSSchema[] = [];
|
|
776
|
+
let cmsPageCount = 0;
|
|
777
|
+
|
|
778
|
+
type CMSEmission = {
|
|
779
|
+
schema: CMSSchema;
|
|
780
|
+
locale: string;
|
|
781
|
+
pathPrefix: string;
|
|
782
|
+
isDefaultLocale: boolean;
|
|
783
|
+
perSlugData: Record<string, { html: string; meta: string; title: string; scriptPaths: string[] }>;
|
|
784
|
+
};
|
|
785
|
+
const cmsEmissions: CMSEmission[] = [];
|
|
786
|
+
|
|
787
|
+
if (existsSync(templatesDir)) {
|
|
788
|
+
const templateFiles = readdirSync(templatesDir).filter(f => f.endsWith('.json'));
|
|
789
|
+
|
|
790
|
+
for (const file of templateFiles) {
|
|
791
|
+
const templateContent = await loadJSONFile(join(templatesDir, file));
|
|
792
|
+
if (!templateContent) continue;
|
|
793
|
+
|
|
794
|
+
try {
|
|
795
|
+
const pageData = parseJSON<JSONPage>(templateContent);
|
|
796
|
+
|
|
797
|
+
const isDevBuild = process.env.MENO_DEV_BUILD === 'true';
|
|
798
|
+
if (pageData.meta?.draft === true && !isDevBuild) {
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
if (!isCMSPage(pageData)) {
|
|
803
|
+
console.warn(` ${file} is in templates/ but missing meta.source: "cms"`);
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
const cmsSchema = pageData.meta!.cms as CMSSchema;
|
|
808
|
+
templateSchemas.push(cmsSchema);
|
|
809
|
+
|
|
810
|
+
const slugField = cmsSchema.slugField || 'slug';
|
|
811
|
+
const items = await cmsService.queryItems({ collection: cmsSchema.id });
|
|
812
|
+
const urlPatternWithoutSlash = cmsSchema.urlPattern.replace(/^\//, '');
|
|
813
|
+
const slugPlaceholderIdx = urlPatternWithoutSlash.indexOf('{{');
|
|
814
|
+
const pathPrefix = slugPlaceholderIdx > 0
|
|
815
|
+
? urlPatternWithoutSlash.substring(0, slugPlaceholderIdx).replace(/\/$/, '')
|
|
816
|
+
: '';
|
|
817
|
+
|
|
818
|
+
for (const localeEntry of i18nConfig.locales) {
|
|
819
|
+
const localeCode = localeEntry.code;
|
|
820
|
+
const isDefault = localeCode === i18nConfig.defaultLocale;
|
|
821
|
+
|
|
822
|
+
const perSlugData: Record<string, { html: string; meta: string; title: string; scriptPaths: string[] }> = {};
|
|
823
|
+
|
|
824
|
+
for (const item of items) {
|
|
825
|
+
if (!isDevBuild && isItemDraftForLocale(item, localeCode)) continue;
|
|
826
|
+
|
|
827
|
+
const itemPath = buildCMSItemPath(cmsSchema.urlPattern, item, slugField, localeCode, i18nConfig);
|
|
828
|
+
const itemWithUrl: CMSItem = { ...item, _url: itemPath };
|
|
829
|
+
|
|
830
|
+
const fullPath = isDefault ? itemPath : `/${localeCode}${itemPath}`;
|
|
831
|
+
|
|
832
|
+
const result = await renderPageSSR(
|
|
833
|
+
pageData,
|
|
834
|
+
globalComponents,
|
|
835
|
+
fullPath,
|
|
836
|
+
siteUrl,
|
|
837
|
+
localeCode,
|
|
838
|
+
i18nConfig,
|
|
839
|
+
slugMappings,
|
|
840
|
+
{ cms: itemWithUrl },
|
|
841
|
+
cmsService,
|
|
842
|
+
true
|
|
843
|
+
);
|
|
844
|
+
|
|
845
|
+
mergeInteractiveStyles(result.interactiveStylesMap);
|
|
846
|
+
if (result.componentCSS) allComponentCSS.add(result.componentCSS);
|
|
847
|
+
for (const c of extractUtilityClassesFromHTML(result.html)) {
|
|
848
|
+
allUtilityClasses.add(c);
|
|
849
|
+
}
|
|
850
|
+
if (!projectNeedsFormHandler && needsFormHandler(result.html)) {
|
|
851
|
+
projectNeedsFormHandler = true;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
const scriptPaths: string[] = [];
|
|
855
|
+
if (result.javascript) {
|
|
856
|
+
const hash = hashContent(result.javascript);
|
|
857
|
+
if (!jsContents.has(hash)) jsContents.set(hash, result.javascript);
|
|
858
|
+
scriptPaths.push(`/_scripts/${hash}.js`);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// Slug used as the dynamic-segment key (must match
|
|
862
|
+
// `generateStaticParams`'s output for this locale).
|
|
863
|
+
let rawSlug = item[slugField] ?? item._slug ?? item._id;
|
|
864
|
+
if (isI18nValue(rawSlug)) {
|
|
865
|
+
rawSlug = resolveI18nValue(rawSlug, localeCode, i18nConfig) as string;
|
|
866
|
+
}
|
|
867
|
+
const slugKey = String(rawSlug);
|
|
868
|
+
|
|
869
|
+
perSlugData[slugKey] = {
|
|
870
|
+
html: result.html,
|
|
871
|
+
meta: result.meta,
|
|
872
|
+
title: result.title,
|
|
873
|
+
scriptPaths,
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
cmsPageCount++;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
if (Object.keys(perSlugData).length > 0) {
|
|
880
|
+
cmsEmissions.push({
|
|
881
|
+
schema: cmsSchema,
|
|
882
|
+
locale: localeCode,
|
|
883
|
+
pathPrefix,
|
|
884
|
+
isDefaultLocale: isDefault,
|
|
885
|
+
perSlugData,
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
} catch (error) {
|
|
890
|
+
const err = error as { message?: string };
|
|
891
|
+
console.error(` Error processing template ${file}:`, err?.message || error);
|
|
892
|
+
errorCount++;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// ----------------------------------------------------------
|
|
898
|
+
// 7. Write extracted scripts to public/_scripts/
|
|
899
|
+
// ----------------------------------------------------------
|
|
900
|
+
for (const [hash, js] of jsContents) {
|
|
901
|
+
writePageScript(js, scriptsDir);
|
|
902
|
+
// touch to mark as referenced (writePageScript handles dedup internally)
|
|
903
|
+
void hash;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// ----------------------------------------------------------
|
|
907
|
+
// 8. Generate global CSS
|
|
908
|
+
// ----------------------------------------------------------
|
|
909
|
+
const mappingClasses = collectAllMappingClasses(globalComponents, breakpoints, responsiveScales);
|
|
910
|
+
|
|
911
|
+
const fontCSS = generateFontCSS();
|
|
912
|
+
const themeColorCSS = generateThemeColorVariablesCSS(themeConfig);
|
|
913
|
+
const variablesCSS = generateVariablesCSS(variablesConfig, breakpoints, responsiveScales);
|
|
914
|
+
const componentCSSCombined = Array.from(allComponentCSS).join('\n');
|
|
915
|
+
const utilityCSS = allUtilityClasses.size > 0
|
|
916
|
+
? generateUtilityCSS(allUtilityClasses, breakpoints, responsiveScales, remConversionConfig)
|
|
917
|
+
: '';
|
|
918
|
+
const interactiveStylesCSS = allInteractiveStyles.size > 0
|
|
919
|
+
? generateAllInteractiveCSS(allInteractiveStyles, breakpoints, remConversionConfig, responsiveScales)
|
|
920
|
+
: '';
|
|
921
|
+
|
|
922
|
+
const baseCSS = `@layer base {
|
|
923
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
924
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif; }
|
|
925
|
+
button { background: none; border: none; padding: 0; font: inherit; cursor: pointer; outline: inherit; }
|
|
926
|
+
img { max-width: 100%; height: auto; }
|
|
927
|
+
picture { display: block; }
|
|
928
|
+
.olink { text-decoration: none; display: block; color: inherit; }
|
|
929
|
+
.oem { display: inline-block; }
|
|
930
|
+
}`;
|
|
931
|
+
|
|
932
|
+
// Tailwind v4 inline safelist — every class referenced by a runtime mapping
|
|
933
|
+
// needs to survive purging. The dynamic markup is inside string literals so
|
|
934
|
+
// Tailwind's source scan won't pick them up otherwise.
|
|
935
|
+
const safelistDirectives = Array.from(mappingClasses)
|
|
936
|
+
.map((c) => `@source inline("${c}");`)
|
|
937
|
+
.join('\n');
|
|
938
|
+
const tailwindDirectives = safelistDirectives
|
|
939
|
+
? `@import "tailwindcss";\n\n${safelistDirectives}`
|
|
940
|
+
: `@import "tailwindcss";`;
|
|
941
|
+
|
|
942
|
+
const globalCSS = [
|
|
943
|
+
tailwindDirectives,
|
|
944
|
+
fontCSS,
|
|
945
|
+
themeColorCSS,
|
|
946
|
+
variablesCSS,
|
|
947
|
+
baseCSS,
|
|
948
|
+
utilityCSS,
|
|
949
|
+
componentCSSCombined,
|
|
950
|
+
interactiveStylesCSS,
|
|
951
|
+
].filter(Boolean).join('\n\n');
|
|
952
|
+
|
|
953
|
+
await writeFile(join(appDir, 'globals.css'), globalCSS, 'utf-8');
|
|
954
|
+
|
|
955
|
+
// ----------------------------------------------------------
|
|
956
|
+
// 9. Generate shared layout (app/layout.tsx) + helper components
|
|
957
|
+
// ----------------------------------------------------------
|
|
958
|
+
const projectName = (projectConfig as { name?: string } | null | undefined)?.name || 'Site';
|
|
959
|
+
const rootLayoutContent = `// Auto-generated by meno-core/build-next. Do not edit.
|
|
960
|
+
import './globals.css';
|
|
961
|
+
|
|
962
|
+
export const metadata = {
|
|
963
|
+
title: ${JSON.stringify(projectName)},
|
|
964
|
+
};
|
|
965
|
+
|
|
966
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
967
|
+
return (
|
|
968
|
+
<html lang=${JSON.stringify(i18nConfig.defaultLocale)} data-theme=${JSON.stringify(defaultTheme)} suppressHydrationWarning>
|
|
969
|
+
<head>
|
|
970
|
+
<meta charSet="UTF-8" />
|
|
971
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
972
|
+
</head>
|
|
973
|
+
<body>{children}</body>
|
|
974
|
+
</html>
|
|
975
|
+
);
|
|
976
|
+
}
|
|
977
|
+
`;
|
|
978
|
+
await writeFile(join(appDir, 'layout.tsx'), rootLayoutContent, 'utf-8');
|
|
979
|
+
|
|
980
|
+
// RawHead: dumps a raw HTML string into <head>. React 19 will hoist
|
|
981
|
+
// <link>/<meta>/<title> elements emitted via dangerouslySetInnerHTML, but
|
|
982
|
+
// <style>/<script>/inline tags need a stable target — we mount this as a
|
|
983
|
+
// hidden marker and let the script in app/layout.tsx (or the browser HTML
|
|
984
|
+
// parser) handle the rest. For Next.js static export the SSR pipeline
|
|
985
|
+
// serializes everything verbatim, so a simple inline strategy works.
|
|
986
|
+
const rawHeadContent = `// Auto-generated by meno-core/build-next. Do not edit.
|
|
987
|
+
// Renders an arbitrary HTML string. Used for head fragments emitted by the
|
|
988
|
+
// SSR pipeline (font preloads, icon links, customCode.head, library tags)
|
|
989
|
+
// and per-locale data-theme/lang updates. Because this is a server component,
|
|
990
|
+
// the HTML appears in the statically exported file as-is.
|
|
991
|
+
|
|
992
|
+
export default function RawHead({ html, locale, theme }: { html: string; locale: string; theme: string }) {
|
|
993
|
+
return (
|
|
994
|
+
<>
|
|
995
|
+
{/* Force locale/theme on <html> via a lightweight inline script. */}
|
|
996
|
+
<script
|
|
997
|
+
dangerouslySetInnerHTML={{
|
|
998
|
+
__html: \`document.documentElement.lang=\${JSON.stringify(locale)};document.documentElement.dataset.theme=\${JSON.stringify(theme)};\`,
|
|
999
|
+
}}
|
|
1000
|
+
/>
|
|
1001
|
+
{html ? <span data-meno-head dangerouslySetInnerHTML={{ __html: html }} style={{ display: 'none' }} /> : null}
|
|
1002
|
+
</>
|
|
1003
|
+
);
|
|
1004
|
+
}
|
|
1005
|
+
`;
|
|
1006
|
+
await writeFile(join(componentsDir, 'RawHead.tsx'), rawHeadContent, 'utf-8');
|
|
1007
|
+
|
|
1008
|
+
// MetaTags: parses an HTML meta-tag string into React elements that React 19
|
|
1009
|
+
// can hoist to <head>. Used by CMS pages where the meta varies per slug.
|
|
1010
|
+
const metaTagsContent = `// Auto-generated by meno-core/build-next. Do not edit.
|
|
1011
|
+
// Parses a string of self-closing <meta>/<link>/<title> tags emitted by
|
|
1012
|
+
// meno-core's generateMetaTags() into React elements. React 19 hoists these
|
|
1013
|
+
// to <head> automatically.
|
|
1014
|
+
|
|
1015
|
+
type MetaEl =
|
|
1016
|
+
| { kind: 'title'; text: string }
|
|
1017
|
+
| { kind: 'meta' | 'link'; attrs: Record<string, string> };
|
|
1018
|
+
|
|
1019
|
+
function decodeEntities(s: string): string {
|
|
1020
|
+
return s
|
|
1021
|
+
.replace(/"/g, '"')
|
|
1022
|
+
.replace(/'/g, "'")
|
|
1023
|
+
.replace(/</g, '<')
|
|
1024
|
+
.replace(/>/g, '>')
|
|
1025
|
+
.replace(/&/g, '&');
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function parseAttrs(src: string): Record<string, string> {
|
|
1029
|
+
const out: Record<string, string> = {};
|
|
1030
|
+
const re = /([a-zA-Z_:][\\w:.-]*)\\s*=\\s*"([^"]*)"/g;
|
|
1031
|
+
let match: RegExpExecArray | null;
|
|
1032
|
+
while ((match = re.exec(src)) !== null) {
|
|
1033
|
+
out[match[1]] = decodeEntities(match[2]);
|
|
1034
|
+
}
|
|
1035
|
+
return out;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
const JSX_ATTR_NAMES: Record<string, string> = {
|
|
1039
|
+
'http-equiv': 'httpEquiv',
|
|
1040
|
+
'hreflang': 'hrefLang',
|
|
1041
|
+
'crossorigin': 'crossOrigin',
|
|
1042
|
+
'referrerpolicy': 'referrerPolicy',
|
|
1043
|
+
'imagesrcset': 'imageSrcSet',
|
|
1044
|
+
'imagesizes': 'imageSizes',
|
|
1045
|
+
'fetchpriority': 'fetchPriority',
|
|
1046
|
+
'class': 'className',
|
|
1047
|
+
'for': 'htmlFor',
|
|
1048
|
+
'charset': 'charSet',
|
|
1049
|
+
};
|
|
1050
|
+
|
|
1051
|
+
function jsxAttrName(name: string): string {
|
|
1052
|
+
return JSX_ATTR_NAMES[name] ?? name;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
function parseMeta(html: string): MetaEl[] {
|
|
1056
|
+
if (!html) return [];
|
|
1057
|
+
const lines = html.split(/\\r?\\n/).map((l) => l.trim()).filter(Boolean);
|
|
1058
|
+
const out: MetaEl[] = [];
|
|
1059
|
+
for (const line of lines) {
|
|
1060
|
+
const titleMatch = line.match(/^<title>([\\s\\S]*?)<\\/title>$/);
|
|
1061
|
+
if (titleMatch) {
|
|
1062
|
+
out.push({ kind: 'title', text: decodeEntities(titleMatch[1]) });
|
|
1063
|
+
continue;
|
|
1064
|
+
}
|
|
1065
|
+
const selfMatch = line.match(/^<(meta|link)\\s+([\\s\\S]*?)\\s*\\/?>$/);
|
|
1066
|
+
if (selfMatch) {
|
|
1067
|
+
out.push({ kind: selfMatch[1] as 'meta' | 'link', attrs: parseAttrs(selfMatch[2]) });
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
return out;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
export default function MetaTags({ html }: { html: string }) {
|
|
1074
|
+
const els = parseMeta(html);
|
|
1075
|
+
return (
|
|
1076
|
+
<>
|
|
1077
|
+
{els.map((el, i) => {
|
|
1078
|
+
if (el.kind === 'title') return <title key={i}>{el.text}</title>;
|
|
1079
|
+
const attrs: Record<string, string> = {};
|
|
1080
|
+
for (const [k, v] of Object.entries(el.attrs)) attrs[jsxAttrName(k)] = v;
|
|
1081
|
+
if (el.kind === 'meta') return <meta key={i} {...attrs} />;
|
|
1082
|
+
return <link key={i} {...attrs} />;
|
|
1083
|
+
})}
|
|
1084
|
+
</>
|
|
1085
|
+
);
|
|
1086
|
+
}
|
|
1087
|
+
`;
|
|
1088
|
+
await writeFile(join(componentsDir, 'MetaTags.tsx'), metaTagsContent, 'utf-8');
|
|
1089
|
+
|
|
1090
|
+
// ----------------------------------------------------------
|
|
1091
|
+
// 10. Emit regular page files (app/<path>/page.tsx)
|
|
1092
|
+
// ----------------------------------------------------------
|
|
1093
|
+
for (const result of allResults) {
|
|
1094
|
+
const scriptPaths: string[] = result.javascript
|
|
1095
|
+
? [`/_scripts/${hashContent(result.javascript)}.js`]
|
|
1096
|
+
: [];
|
|
1097
|
+
|
|
1098
|
+
const route = urlPathToAppRoute(result.urlPath);
|
|
1099
|
+
const targetDir = route.isRoot ? appDir : join(appDir, route.dir);
|
|
1100
|
+
if (!existsSync(targetDir)) {
|
|
1101
|
+
mkdirSync(targetDir, { recursive: true });
|
|
1102
|
+
}
|
|
1103
|
+
const pageFilePath = join(targetDir, 'page.tsx');
|
|
1104
|
+
|
|
1105
|
+
// Patch the RawHead import path so it correctly resolves from the page's
|
|
1106
|
+
// location. app/page.tsx → './components/RawHead'; app/about/page.tsx →
|
|
1107
|
+
// '../components/RawHead'; app/pl/about/page.tsx → '../../components/RawHead'.
|
|
1108
|
+
const depth = route.isRoot ? 0 : route.dir.split('/').length;
|
|
1109
|
+
const rawHeadImportPath = depth === 0 ? './components/RawHead' : '../'.repeat(depth) + 'components/RawHead';
|
|
1110
|
+
|
|
1111
|
+
let content = emitNextPage({
|
|
1112
|
+
html: result.html,
|
|
1113
|
+
meta: result.meta,
|
|
1114
|
+
title: result.title,
|
|
1115
|
+
locale: result.locale,
|
|
1116
|
+
theme: defaultTheme,
|
|
1117
|
+
fontPreloads,
|
|
1118
|
+
libraryTags,
|
|
1119
|
+
scriptPaths,
|
|
1120
|
+
customCode,
|
|
1121
|
+
iconTagsHtml,
|
|
1122
|
+
formHandlerNeeded: projectNeedsFormHandler,
|
|
1123
|
+
});
|
|
1124
|
+
content = content.replace(
|
|
1125
|
+
"import RawHead from '../components/RawHead';",
|
|
1126
|
+
`import RawHead from '${rawHeadImportPath}';`
|
|
1127
|
+
);
|
|
1128
|
+
|
|
1129
|
+
await writeFile(pageFilePath, content, 'utf-8');
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// ----------------------------------------------------------
|
|
1133
|
+
// 11. Emit CMS dynamic [slug] pages
|
|
1134
|
+
// ----------------------------------------------------------
|
|
1135
|
+
for (const emission of cmsEmissions) {
|
|
1136
|
+
const { locale, pathPrefix, isDefaultLocale, perSlugData } = emission;
|
|
1137
|
+
|
|
1138
|
+
// Route folder for the [slug] segment:
|
|
1139
|
+
// pattern "/blog/{{slug}}" + default locale → app/blog/[slug]
|
|
1140
|
+
// pattern "/blog/{{slug}}" + locale "pl" → app/pl/blog/[slug]
|
|
1141
|
+
// pattern "/{{slug}}" + default locale → app/[slug]
|
|
1142
|
+
// pattern "/{{slug}}" + locale "pl" → app/pl/[slug]
|
|
1143
|
+
const segments: string[] = [];
|
|
1144
|
+
if (!isDefaultLocale) segments.push(locale);
|
|
1145
|
+
if (pathPrefix) {
|
|
1146
|
+
// Split nested prefixes (e.g. "blog/posts") into individual segments so
|
|
1147
|
+
// depth-based imports below count correctly.
|
|
1148
|
+
for (const part of pathPrefix.split('/').filter(Boolean)) {
|
|
1149
|
+
segments.push(part);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
segments.push('[slug]');
|
|
1153
|
+
const routeDir = join(appDir, ...segments);
|
|
1154
|
+
mkdirSync(routeDir, { recursive: true });
|
|
1155
|
+
|
|
1156
|
+
// Compute the correct relative path back to app/components/* from the
|
|
1157
|
+
// page file. The file lives at app/<segments...>/page.tsx.
|
|
1158
|
+
const depth = segments.length;
|
|
1159
|
+
const upToApp = '../'.repeat(depth);
|
|
1160
|
+
|
|
1161
|
+
let content = emitNextCMSPage({
|
|
1162
|
+
slugs: Object.keys(perSlugData),
|
|
1163
|
+
perSlugData,
|
|
1164
|
+
locale,
|
|
1165
|
+
theme: defaultTheme,
|
|
1166
|
+
fontPreloads,
|
|
1167
|
+
libraryTags,
|
|
1168
|
+
customCode,
|
|
1169
|
+
iconTagsHtml,
|
|
1170
|
+
formHandlerNeeded: projectNeedsFormHandler,
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1173
|
+
// Replace placeholder import paths with the right relative depth. The
|
|
1174
|
+
// initial emit hardcodes '../../components/*' for the depth-2 common case;
|
|
1175
|
+
// patch it here so all depths work uniformly.
|
|
1176
|
+
content = content.replace(
|
|
1177
|
+
"import RawHead from '../../components/RawHead';",
|
|
1178
|
+
`import RawHead from '${upToApp}components/RawHead';`,
|
|
1179
|
+
);
|
|
1180
|
+
content = content.replace(
|
|
1181
|
+
"import MetaTags from '../../components/MetaTags';",
|
|
1182
|
+
`import MetaTags from '${upToApp}components/MetaTags';`,
|
|
1183
|
+
);
|
|
1184
|
+
|
|
1185
|
+
const pageFile = join(routeDir, 'page.tsx');
|
|
1186
|
+
await writeFile(pageFile, content, 'utf-8');
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
// ----------------------------------------------------------
|
|
1190
|
+
// 12. Copy assets
|
|
1191
|
+
// ----------------------------------------------------------
|
|
1192
|
+
const imagesSrcDir = join(projectPaths.project, 'images');
|
|
1193
|
+
if (existsSync(imagesSrcDir)) {
|
|
1194
|
+
copyDirectory(imagesSrcDir, join(publicDir, 'images'));
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
const publicAssetDirs = ['fonts', 'icons', 'videos', 'assets'];
|
|
1198
|
+
for (const dir of publicAssetDirs) {
|
|
1199
|
+
const srcAssetDir = join(projectPaths.project, dir);
|
|
1200
|
+
if (existsSync(srcAssetDir)) {
|
|
1201
|
+
copyDirectory(srcAssetDir, join(publicDir, dir));
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
const librariesDir = join(projectPaths.project, 'libraries');
|
|
1206
|
+
if (existsSync(librariesDir)) {
|
|
1207
|
+
copyDirectory(librariesDir, join(publicDir, 'libraries'));
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
for (const relPath of localLibsToCopy) {
|
|
1211
|
+
const srcPath = join(projectPaths.project, relPath);
|
|
1212
|
+
const destPath = join(publicDir, relPath);
|
|
1213
|
+
const destDir = destPath.substring(0, destPath.lastIndexOf('/'));
|
|
1214
|
+
if (destDir && !existsSync(destDir)) mkdirSync(destDir, { recursive: true });
|
|
1215
|
+
copyFileSync(srcPath, destPath);
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// ----------------------------------------------------------
|
|
1219
|
+
// 13. Scaffold files (package.json, next.config.mjs, tsconfig.json, etc.)
|
|
1220
|
+
// ----------------------------------------------------------
|
|
1221
|
+
const packageJson = {
|
|
1222
|
+
name: 'next-export',
|
|
1223
|
+
type: 'module',
|
|
1224
|
+
version: '0.0.1',
|
|
1225
|
+
private: true,
|
|
1226
|
+
scripts: {
|
|
1227
|
+
dev: 'next dev',
|
|
1228
|
+
build: 'next build',
|
|
1229
|
+
start: 'next start',
|
|
1230
|
+
},
|
|
1231
|
+
dependencies: {
|
|
1232
|
+
next: '^15.0.0',
|
|
1233
|
+
react: '^19.0.0',
|
|
1234
|
+
'react-dom': '^19.0.0',
|
|
1235
|
+
'@tailwindcss/postcss': '^4.0.0',
|
|
1236
|
+
tailwindcss: '^4.0.0',
|
|
1237
|
+
},
|
|
1238
|
+
devDependencies: {
|
|
1239
|
+
'@types/node': '^22.0.0',
|
|
1240
|
+
'@types/react': '^19.0.0',
|
|
1241
|
+
'@types/react-dom': '^19.0.0',
|
|
1242
|
+
typescript: '^5.6.0',
|
|
1243
|
+
},
|
|
1244
|
+
};
|
|
1245
|
+
await writeFile(join(outDir, 'package.json'), JSON.stringify(packageJson, null, 2), 'utf-8');
|
|
1246
|
+
|
|
1247
|
+
// next.config.mjs — static export so the built site is a folder of HTML files,
|
|
1248
|
+
// matching the Astro export's `astro build` behavior.
|
|
1249
|
+
const nextConfig = `/** @type {import('next').NextConfig} */
|
|
1250
|
+
const nextConfig = {
|
|
1251
|
+
output: 'export',${siteUrl ? `\n // Set NEXT_PUBLIC_SITE_URL=${siteUrl} for absolute URLs in metadata.` : ''}
|
|
1252
|
+
images: { unoptimized: true },
|
|
1253
|
+
trailingSlash: false,
|
|
1254
|
+
// The SSR HTML contains arbitrary inline scripts and styles; disable
|
|
1255
|
+
// automatic font/image transforms so they survive unchanged.
|
|
1256
|
+
experimental: {
|
|
1257
|
+
optimizePackageImports: [],
|
|
1258
|
+
},
|
|
1259
|
+
};
|
|
1260
|
+
|
|
1261
|
+
export default nextConfig;
|
|
1262
|
+
`;
|
|
1263
|
+
await writeFile(join(outDir, 'next.config.mjs'), nextConfig, 'utf-8');
|
|
1264
|
+
|
|
1265
|
+
// postcss.config.mjs — Tailwind v4 uses a PostCSS plugin for class generation.
|
|
1266
|
+
const postcssConfig = `export default {
|
|
1267
|
+
plugins: {
|
|
1268
|
+
'@tailwindcss/postcss': {},
|
|
1269
|
+
},
|
|
1270
|
+
};
|
|
1271
|
+
`;
|
|
1272
|
+
await writeFile(join(outDir, 'postcss.config.mjs'), postcssConfig, 'utf-8');
|
|
1273
|
+
|
|
1274
|
+
// tsconfig.json
|
|
1275
|
+
const tsConfig = {
|
|
1276
|
+
compilerOptions: {
|
|
1277
|
+
target: 'ES2022',
|
|
1278
|
+
lib: ['dom', 'dom.iterable', 'esnext'],
|
|
1279
|
+
allowJs: true,
|
|
1280
|
+
skipLibCheck: true,
|
|
1281
|
+
strict: true,
|
|
1282
|
+
noEmit: true,
|
|
1283
|
+
esModuleInterop: true,
|
|
1284
|
+
module: 'esnext',
|
|
1285
|
+
moduleResolution: 'bundler',
|
|
1286
|
+
resolveJsonModule: true,
|
|
1287
|
+
isolatedModules: true,
|
|
1288
|
+
jsx: 'preserve',
|
|
1289
|
+
incremental: true,
|
|
1290
|
+
plugins: [{ name: 'next' }],
|
|
1291
|
+
paths: { '@/*': ['./*'] },
|
|
1292
|
+
},
|
|
1293
|
+
include: ['next-env.d.ts', '**/*.ts', '**/*.tsx', '.next/types/**/*.ts'],
|
|
1294
|
+
exclude: ['node_modules'],
|
|
1295
|
+
};
|
|
1296
|
+
await writeFile(join(outDir, 'tsconfig.json'), JSON.stringify(tsConfig, null, 2), 'utf-8');
|
|
1297
|
+
|
|
1298
|
+
await writeFile(
|
|
1299
|
+
join(outDir, 'next-env.d.ts'),
|
|
1300
|
+
`/// <reference types="next" />\n/// <reference types="next/image-types/global" />\n`,
|
|
1301
|
+
'utf-8'
|
|
1302
|
+
);
|
|
1303
|
+
|
|
1304
|
+
// .gitignore — keep the exported project clean when committed.
|
|
1305
|
+
const gitignore = [
|
|
1306
|
+
'node_modules',
|
|
1307
|
+
'.next',
|
|
1308
|
+
'out',
|
|
1309
|
+
'.DS_Store',
|
|
1310
|
+
'*.log',
|
|
1311
|
+
].join('\n') + '\n';
|
|
1312
|
+
await writeFile(join(outDir, '.gitignore'), gitignore, 'utf-8');
|
|
1313
|
+
|
|
1314
|
+
// README — quick start, mirrors what Astro export ships.
|
|
1315
|
+
const readme = `# Next.js export
|
|
1316
|
+
|
|
1317
|
+
This project was generated by Meno from the SSR-rendered HTML of your pages.
|
|
1318
|
+
|
|
1319
|
+
## Run locally
|
|
1320
|
+
|
|
1321
|
+
\`\`\`bash
|
|
1322
|
+
npm install
|
|
1323
|
+
npm run dev
|
|
1324
|
+
\`\`\`
|
|
1325
|
+
|
|
1326
|
+
Open http://localhost:3000.
|
|
1327
|
+
|
|
1328
|
+
## Build a static site
|
|
1329
|
+
|
|
1330
|
+
\`\`\`bash
|
|
1331
|
+
npm run build
|
|
1332
|
+
\`\`\`
|
|
1333
|
+
|
|
1334
|
+
Output lands in \`./out\` (configured via \`output: 'export'\` in \`next.config.mjs\`).
|
|
1335
|
+
|
|
1336
|
+
## How this differs from a hand-written Next.js app
|
|
1337
|
+
|
|
1338
|
+
- Each page is a server component that embeds the SSR HTML via \`dangerouslySetInnerHTML\`.
|
|
1339
|
+
- Interactive behavior lives in the inline scripts inside that HTML — they run when the browser parses the static file.
|
|
1340
|
+
- Tailwind v4 is used for utility classes; safelisted classes referenced only by runtime mappings live in \`app/globals.css\`.
|
|
1341
|
+
- Routing is plain App Router: one \`page.tsx\` per URL, with \`[slug]\` dynamic segments for CMS collections.
|
|
1342
|
+
`;
|
|
1343
|
+
await writeFile(join(outDir, 'README.md'), readme, 'utf-8');
|
|
1344
|
+
|
|
1345
|
+
// ----------------------------------------------------------
|
|
1346
|
+
// 14. Summary
|
|
1347
|
+
// ----------------------------------------------------------
|
|
1348
|
+
const collectionCount = templateSchemas.length;
|
|
1349
|
+
|
|
1350
|
+
// Use the dummy reference so the linter doesn't complain about
|
|
1351
|
+
// escapeSingleQuoted being unused — it's exported-style helper kept for
|
|
1352
|
+
// future expansions (e.g. parameterized CMS metadata).
|
|
1353
|
+
void escapeSingleQuoted;
|
|
1354
|
+
|
|
1355
|
+
return {
|
|
1356
|
+
pages: allResults.length,
|
|
1357
|
+
cmsPages: cmsPageCount,
|
|
1358
|
+
collections: collectionCount,
|
|
1359
|
+
errors: errorCount,
|
|
1360
|
+
};
|
|
1361
|
+
}
|