twynejs 1.0.0
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/README.md +3 -0
- package/dist/index.d.ts +180 -0
- package/dist/index.js +1122 -0
- package/dist/jsx-runtime.d.ts +20 -0
- package/dist/jsx-runtime.js +132 -0
- package/dist/styles.d.ts +1 -0
- package/dist/styles.js +17 -0
- package/package.json +67 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1122 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { minify } from "html-minifier-terser";
|
|
5
|
+
import { parseMarkdown } from "@shadowdara/dlib";
|
|
6
|
+
import { transformWithEsbuild } from "vite";
|
|
7
|
+
import { tsImport } from "tsx/esm/api";
|
|
8
|
+
import { escapeHtml } from "./jsx-runtime.js";
|
|
9
|
+
import hljs from "highlight.js/lib/common";
|
|
10
|
+
import { Liquid } from "liquidjs";
|
|
11
|
+
import ejs from "ejs";
|
|
12
|
+
const MARKDOWN_PREFIX = "virtual:page-markdown:";
|
|
13
|
+
const RESOLVED_MARKDOWN_PREFIX = "\0" + MARKDOWN_PREFIX;
|
|
14
|
+
const VIRTUAL_MODULE_ID = "virtual:pages";
|
|
15
|
+
const RESOLVED_VIRTUAL_MODULE_ID = "\0" + VIRTUAL_MODULE_ID;
|
|
16
|
+
const STYLE_PREFIX = "virtual:page-style:";
|
|
17
|
+
const RESOLVED_STYLE_PREFIX = "\0" + STYLE_PREFIX;
|
|
18
|
+
const DATA_PREFIX = "virtual:page-data:";
|
|
19
|
+
const RESOLVED_DATA_PREFIX = "\0" + DATA_PREFIX;
|
|
20
|
+
/**
|
|
21
|
+
* Placeholder rendered in place of `{{ JS_SCRIPT }}` while Liquid runs.
|
|
22
|
+
* The real script tag (pointing at the built `page.[tj]s`) is only known
|
|
23
|
+
* after bundling (build) or via the dev URL (dev), so it is substituted
|
|
24
|
+
* after rendering. Uses a plain ASCII token so liquidjs / minifiers do
|
|
25
|
+
* not mangle it.
|
|
26
|
+
*/
|
|
27
|
+
const LIQUID_SCRIPT_PLACEHOLDER = "__PAGES_LIQUID_JS_SCRIPT__";
|
|
28
|
+
/**
|
|
29
|
+
* Build-data modules (`page.build.ts`, `index.build.tsx`, …) run only at
|
|
30
|
+
* build time via `loadBuildData()`. They must never become a client page
|
|
31
|
+
* or land in the production bundle.
|
|
32
|
+
*/
|
|
33
|
+
function isBuildDataFile(filePath) {
|
|
34
|
+
return /\.build\.[^.]+$/i.test(path.basename(filePath));
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Escape `<...>` sequences that are NOT valid HTML tags, so the output can
|
|
38
|
+
* safely pass through `html-minifier-terser` (which uses a strict HTML
|
|
39
|
+
* parser). Without this, things like
|
|
40
|
+
* `Co-authored-by: Copilot <foo@users.noreply.github.com>` survive
|
|
41
|
+
* `parseMarkdown({ sanitize: false })` as raw text and crash the minifier.
|
|
42
|
+
*
|
|
43
|
+
* Heuristic: a `<` is kept as-is only when it starts a plausible tag
|
|
44
|
+
* (letter, `/`, `!`), otherwise both `<` and its matching `>` are escaped.
|
|
45
|
+
*/
|
|
46
|
+
function escapeBareAngles(html) {
|
|
47
|
+
return html.replace(/<([^a-zA-Z/!][^>]*)>/g, (_m, inner) => {
|
|
48
|
+
return `<${inner.replace(/</g, "<")}>`;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
let resolvedStyles = new Map();
|
|
52
|
+
const DEFAULT_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"];
|
|
53
|
+
export function pagesPlugin(options = {}) {
|
|
54
|
+
const ignoredPathnames = [
|
|
55
|
+
"/@",
|
|
56
|
+
"/node_modules/",
|
|
57
|
+
"/src/",
|
|
58
|
+
"/__devframes_plugin_terminals/",
|
|
59
|
+
"/__devtools-vite/",
|
|
60
|
+
"/__devtools-rolldown/",
|
|
61
|
+
"/__devtools-oxc/",
|
|
62
|
+
...(options.ignoredPathnames ?? []),
|
|
63
|
+
].filter((prefix, index, prefixes) => prefixes.indexOf(prefix) === index);
|
|
64
|
+
const splitMarkdown = options.splitMarkdown ?? false;
|
|
65
|
+
const verbose = options.verbose ?? false;
|
|
66
|
+
const singleBundle = options.singleBundle ?? false;
|
|
67
|
+
const removeConsole = options.removeConsole ?? false;
|
|
68
|
+
const relativePath = options.relativePaths ?? false;
|
|
69
|
+
const addRawMarkdown = options.addRawMarkdown ?? false;
|
|
70
|
+
const pagesDirOpt = options.pagesDir ?? "pages";
|
|
71
|
+
const docsDir = options.docsDir ?? "docs";
|
|
72
|
+
const extensions = (options.extensions ?? DEFAULT_EXTENSIONS).map((ext) => ext.startsWith(".") ? ext.toLowerCase() : `.${ext.toLowerCase()}`);
|
|
73
|
+
const globalVar = options.globalVar ?? "PAGE_ID";
|
|
74
|
+
const prettyUrls = options.prettyUrls ?? false;
|
|
75
|
+
const shouldIgnore = options.ignore ?? ((id) => id.split("/").pop().startsWith("_"));
|
|
76
|
+
const getTitle = options.title ?? ((id) => id);
|
|
77
|
+
const getHead = (id) => {
|
|
78
|
+
const head = options.head;
|
|
79
|
+
if (typeof head === "function") {
|
|
80
|
+
return head(id);
|
|
81
|
+
}
|
|
82
|
+
if (head && typeof head === "object") {
|
|
83
|
+
return head[id] ?? "";
|
|
84
|
+
}
|
|
85
|
+
return head ?? "";
|
|
86
|
+
};
|
|
87
|
+
const writeDts = options.dts ?? true;
|
|
88
|
+
const dtsPathOpt = options.dtsPath ?? "src/pages.d.ts";
|
|
89
|
+
const liquidTemplateRoot = options.liquidTemplateRoot ?? "src/templates";
|
|
90
|
+
const jsxRuntimePath = options.jsxRuntimePath ?? "twynejs/jsx-runtime";
|
|
91
|
+
let config;
|
|
92
|
+
let pages = [];
|
|
93
|
+
function getPagesDir() {
|
|
94
|
+
return path.resolve(config.root, pagesDirOpt);
|
|
95
|
+
}
|
|
96
|
+
function resolveStylePath(style) {
|
|
97
|
+
const clean = style.replace(/^\/+/, "");
|
|
98
|
+
return path.resolve(config.root, clean);
|
|
99
|
+
}
|
|
100
|
+
// Render a Liquid template with the given data at build time.
|
|
101
|
+
//
|
|
102
|
+
// Used for `X.html` pages: the template is read from disk and rendered
|
|
103
|
+
// with the data returned by the sibling `X.build.[tj]s` module's `build()`
|
|
104
|
+
// function, producing fully static HTML.
|
|
105
|
+
async function renderLiquidTemplate(template, data) {
|
|
106
|
+
// liquidjs braucht einen absoluten Pfad als `root`, damit
|
|
107
|
+
// `{% include header.html %}` die Partials-Dateien findet.
|
|
108
|
+
// `jekyllInclude: true` erlaubt die Jekyll-Syntax ohne Quotes
|
|
109
|
+
// (sonst würde `header.html` als Variablen-Zugriff geparst).
|
|
110
|
+
const engine = new Liquid({
|
|
111
|
+
root: path.resolve(config.root, liquidTemplateRoot),
|
|
112
|
+
jekyllInclude: true,
|
|
113
|
+
});
|
|
114
|
+
return engine.parseAndRender(template, data);
|
|
115
|
+
}
|
|
116
|
+
// Render an EJS template with the given data at build time.
|
|
117
|
+
// Used for `X.ejs` pages (same data flow as Liquid).
|
|
118
|
+
async function renderEjsTemplate(template, data, sourceFile) {
|
|
119
|
+
// `filename` only matters for `include`-relative paths in EJS.
|
|
120
|
+
return ejs.render(template, data, {
|
|
121
|
+
filename: sourceFile,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
function getAssetPath(htmlFileName, assetFileName) {
|
|
125
|
+
const normalizedAsset = assetFileName
|
|
126
|
+
.replace(/^\/+/, "")
|
|
127
|
+
.replace(/\\/g, "/");
|
|
128
|
+
// Explizit relative Pfade gewünscht
|
|
129
|
+
if (options.relativePaths) {
|
|
130
|
+
return path.posix
|
|
131
|
+
.relative(path.posix.dirname(htmlFileName), normalizedAsset)
|
|
132
|
+
.replace(/\\/g, "/");
|
|
133
|
+
}
|
|
134
|
+
// Ansonsten Vite's `base` verwenden
|
|
135
|
+
const base = config.base.replace(/\/+$/, "");
|
|
136
|
+
// base: "./"
|
|
137
|
+
if (config.base === "./") {
|
|
138
|
+
return path.posix
|
|
139
|
+
.relative(path.posix.dirname(htmlFileName), normalizedAsset)
|
|
140
|
+
.replace(/\\/g, "/");
|
|
141
|
+
}
|
|
142
|
+
// base: "/"
|
|
143
|
+
if (!base) {
|
|
144
|
+
return `/${normalizedAsset}`;
|
|
145
|
+
}
|
|
146
|
+
// base: "/my-app/"
|
|
147
|
+
return `${base}/${normalizedAsset}`;
|
|
148
|
+
}
|
|
149
|
+
async function preparePages() {
|
|
150
|
+
pages = [...scanPages(), ...(await scanDocs())].sort((a, b) => a.id.localeCompare(b.id));
|
|
151
|
+
for (const page of pages) {
|
|
152
|
+
if (page.type !== "component" &&
|
|
153
|
+
page.type !== "liquid" &&
|
|
154
|
+
page.type !== "ejs") {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
page.buildData = await loadBuildData(page);
|
|
158
|
+
// Liquid pages: render the .html template with the build data at
|
|
159
|
+
// build time, so the emitted page is fully static HTML. `JS_SCRIPT`
|
|
160
|
+
// is rendered as a placeholder here — the real script tag is
|
|
161
|
+
// substituted after bundling (or via the dev URL in dev mode).
|
|
162
|
+
if (page.type === "liquid") {
|
|
163
|
+
page.html = await renderLiquidTemplate(fs.readFileSync(page.source, "utf8"), {
|
|
164
|
+
...(page.buildData ?? {}),
|
|
165
|
+
JS_SCRIPT: LIQUID_SCRIPT_PLACEHOLDER,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
// EJS pages: same data flow as Liquid, but rendered with EJS.
|
|
169
|
+
if (page.type === "ejs") {
|
|
170
|
+
page.html = await renderEjsTemplate(fs.readFileSync(page.source, "utf8"), {
|
|
171
|
+
...(page.buildData ?? {}),
|
|
172
|
+
JS_SCRIPT: LIQUID_SCRIPT_PLACEHOLDER,
|
|
173
|
+
}, page.source);
|
|
174
|
+
}
|
|
175
|
+
if (verbose) {
|
|
176
|
+
console.log(`[vite-plugin-pages-ssg] Build data loaded: ${page.id}`, page.buildData);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
async function scanDocs() {
|
|
181
|
+
const root = path.resolve(config.root, docsDir);
|
|
182
|
+
if (!fs.existsSync(root)) {
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
const pages = [];
|
|
186
|
+
async function walk(directory) {
|
|
187
|
+
for (const entry of fs.readdirSync(directory, {
|
|
188
|
+
withFileTypes: true,
|
|
189
|
+
})) {
|
|
190
|
+
const fullPath = path.join(directory, entry.name);
|
|
191
|
+
if (entry.isDirectory()) {
|
|
192
|
+
await walk(fullPath);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (entry.isFile() &&
|
|
196
|
+
path.extname(entry.name).toLowerCase() === ".md") {
|
|
197
|
+
const relativePath = path.relative(root, fullPath);
|
|
198
|
+
const id = relativePath.replace(/\\/g, "/").replace(/\.md$/i, "");
|
|
199
|
+
const markdown = fs.readFileSync(fullPath, "utf8");
|
|
200
|
+
let html = parseMarkdown(markdown, {
|
|
201
|
+
sanitize: false,
|
|
202
|
+
highlight(code, language) {
|
|
203
|
+
if (!hljs.getLanguage(language)) {
|
|
204
|
+
return code;
|
|
205
|
+
}
|
|
206
|
+
return hljs.highlight(code, {
|
|
207
|
+
language,
|
|
208
|
+
}).value;
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
if (options.minify) {
|
|
212
|
+
html = await minify(escapeBareAngles(html), {
|
|
213
|
+
collapseWhitespace: true,
|
|
214
|
+
removeComments: true,
|
|
215
|
+
removeRedundantAttributes: true,
|
|
216
|
+
minifyCSS: true,
|
|
217
|
+
minifyJS: true,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
pages.push({
|
|
221
|
+
id,
|
|
222
|
+
source: fullPath,
|
|
223
|
+
type: "markdown",
|
|
224
|
+
...(addRawMarkdown ? { markdown } : {}),
|
|
225
|
+
html,
|
|
226
|
+
styles: options.styles?.[id] ?? [],
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
await walk(root);
|
|
232
|
+
return pages;
|
|
233
|
+
}
|
|
234
|
+
function scanPages() {
|
|
235
|
+
const root = getPagesDir();
|
|
236
|
+
if (!fs.existsSync(root)) {
|
|
237
|
+
throw new Error(`[vite-plugin-pages-ssg] Pages directory does not exist: ${root}`);
|
|
238
|
+
}
|
|
239
|
+
const componentFiles = [];
|
|
240
|
+
const htmlFiles = [];
|
|
241
|
+
const ejsFiles = [];
|
|
242
|
+
function walk(directory) {
|
|
243
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
244
|
+
const fullPath = path.join(directory, entry.name);
|
|
245
|
+
if (entry.isDirectory()) {
|
|
246
|
+
walk(fullPath);
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (!entry.isFile()) {
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
// Build-data modules (page.build.ts / page.build.js / ...) are not
|
|
253
|
+
// pages themselves — they only feed data into their sibling page
|
|
254
|
+
// and must not be scanned as client entries.
|
|
255
|
+
if (isBuildDataFile(entry.name)) {
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
const extension = path.extname(entry.name).toLowerCase();
|
|
259
|
+
if (extension === ".html") {
|
|
260
|
+
htmlFiles.push(fullPath);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (extension === ".ejs") {
|
|
264
|
+
ejsFiles.push(fullPath);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (extensions.includes(extension)) {
|
|
268
|
+
componentFiles.push(fullPath);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
walk(root);
|
|
273
|
+
const componentPages = componentFiles
|
|
274
|
+
.sort()
|
|
275
|
+
.map((file) => {
|
|
276
|
+
const relativePath = path.relative(root, file).replace(/\\/g, "/");
|
|
277
|
+
const ext = path.extname(relativePath);
|
|
278
|
+
const id = relativePath.slice(0, -ext.length);
|
|
279
|
+
// Root-absolute specifier, e.g. "/pages/guide/installation.ts".
|
|
280
|
+
// Vite resolves a leading "/" against `config.root` in both dev
|
|
281
|
+
// and build, so this works for the dynamic import() calls the
|
|
282
|
+
// virtual module hands to the shared entry.
|
|
283
|
+
const importPath = "/" + path.relative(config.root, file).replace(/\\/g, "/");
|
|
284
|
+
return {
|
|
285
|
+
id,
|
|
286
|
+
source: file,
|
|
287
|
+
importPath,
|
|
288
|
+
type: "component",
|
|
289
|
+
styles: options.styles?.[id] ?? [],
|
|
290
|
+
};
|
|
291
|
+
})
|
|
292
|
+
.filter((page) => !shouldIgnore(page.id));
|
|
293
|
+
// Template pages: every `X.html` / `X.ejs` becomes a build-time
|
|
294
|
+
// rendered template page. A sibling `X.build.[tj]s` is optional —
|
|
295
|
+
// it only supplies the render data via its `build()` export. Without
|
|
296
|
+
// it the template renders with an empty context.
|
|
297
|
+
//
|
|
298
|
+
// - .html => Liquid
|
|
299
|
+
// - .ejs => EJS
|
|
300
|
+
const liquidPages = [];
|
|
301
|
+
for (const file of htmlFiles.sort()) {
|
|
302
|
+
const relativePath = path.relative(root, file).replace(/\\/g, "/");
|
|
303
|
+
let id = relativePath.slice(0, -".html".length);
|
|
304
|
+
if (path.posix.basename(id) === "page") {
|
|
305
|
+
id = path.posix.join(path.posix.dirname(id), "index");
|
|
306
|
+
}
|
|
307
|
+
if (shouldIgnore(id)) {
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (componentPages.some((page) => page.id === id)) {
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
const baseName = file.slice(0, -".html".length);
|
|
314
|
+
const scriptSource = [".ts", ".tsx", ".js", ".jsx"]
|
|
315
|
+
.map((ext) => baseName + ext)
|
|
316
|
+
.find((candidate) => fs.existsSync(candidate) && !isBuildDataFile(candidate));
|
|
317
|
+
liquidPages.push({
|
|
318
|
+
id,
|
|
319
|
+
source: file,
|
|
320
|
+
type: "liquid",
|
|
321
|
+
...(scriptSource ? { scriptSource } : {}),
|
|
322
|
+
styles: options.styles?.[id] ?? [],
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
for (const file of ejsFiles.sort()) {
|
|
326
|
+
const relativePath = path.relative(root, file).replace(/\\/g, "/");
|
|
327
|
+
let id = relativePath.slice(0, -".ejs".length);
|
|
328
|
+
if (path.posix.basename(id) === "page") {
|
|
329
|
+
id = path.posix.join(path.posix.dirname(id), "index");
|
|
330
|
+
}
|
|
331
|
+
if (shouldIgnore(id)) {
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
if (componentPages.some((page) => page.id === id)) {
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
const baseName = file.slice(0, -".ejs".length);
|
|
338
|
+
const scriptSource = [".ts", ".tsx", ".js", ".jsx"]
|
|
339
|
+
.map((ext) => baseName + ext)
|
|
340
|
+
.find((candidate) => fs.existsSync(candidate) && !isBuildDataFile(candidate));
|
|
341
|
+
liquidPages.push({
|
|
342
|
+
id,
|
|
343
|
+
source: file,
|
|
344
|
+
type: "ejs",
|
|
345
|
+
...(scriptSource ? { scriptSource } : {}),
|
|
346
|
+
styles: options.styles?.[id] ?? [],
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
// A `page.js`/`page.ts` next to a template page is used as the client
|
|
350
|
+
// script source, not as its own route — the template index page
|
|
351
|
+
// takes precedence.
|
|
352
|
+
const liquidIds = new Set(liquidPages.map((page) => page.id));
|
|
353
|
+
const duplicateComponentIds = componentPages
|
|
354
|
+
.map((page) => page.id)
|
|
355
|
+
.filter((id) => {
|
|
356
|
+
// id "<dir>/page" conflicts with template index "<dir>/index".
|
|
357
|
+
if (!id.endsWith("/page")) {
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
return liquidIds.has(id.slice(0, -"/page".length) + "/index");
|
|
361
|
+
});
|
|
362
|
+
const filteredComponentPages = componentPages.filter((page) => !duplicateComponentIds.includes(page.id));
|
|
363
|
+
return [...filteredComponentPages, ...liquidPages];
|
|
364
|
+
}
|
|
365
|
+
function createVirtualModule() {
|
|
366
|
+
if (verbose) {
|
|
367
|
+
console.log("[vite-plugin-pages-ssg] Creating virtual module", pages.map((p) => ({
|
|
368
|
+
id: p.id,
|
|
369
|
+
type: p.type,
|
|
370
|
+
buildData: p.type === "component" ? p.buildData : undefined,
|
|
371
|
+
})));
|
|
372
|
+
}
|
|
373
|
+
const styleImports = new Map();
|
|
374
|
+
for (const page of pages) {
|
|
375
|
+
for (const style of page.styles) {
|
|
376
|
+
if (!styleImports.has(style)) {
|
|
377
|
+
styleImports.set(style, `style_${styleImports.size}`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
const imports = [...styleImports.entries()]
|
|
382
|
+
.map(([style, variable]) => {
|
|
383
|
+
const resolved = resolvedStyles.get(style);
|
|
384
|
+
if (!resolved) {
|
|
385
|
+
throw new Error(`[vite-plugin-pages-ssg] Style was not resolved: ${style}`);
|
|
386
|
+
}
|
|
387
|
+
return `import ${variable} from ${JSON.stringify(resolved + "?url")};`;
|
|
388
|
+
})
|
|
389
|
+
.join("\n");
|
|
390
|
+
const entries = pages
|
|
391
|
+
.map((page) => {
|
|
392
|
+
const styles = page.styles
|
|
393
|
+
.map((style) => styleImports.get(style))
|
|
394
|
+
.join(", ");
|
|
395
|
+
/* Liquid/EJS template pages are fully static (rendered at build
|
|
396
|
+
* time) — they are served/emitted as-is and do not need a
|
|
397
|
+
* client-side entry in the pages map (which would bloat the
|
|
398
|
+
* main bundle). */
|
|
399
|
+
if (page.type === "liquid" || page.type === "ejs") {
|
|
400
|
+
// Client scripts (`page.[tj]s`) are bundled separately and injected
|
|
401
|
+
// via `{{ JS_SCRIPT }}`. Build-data modules must not be imported
|
|
402
|
+
// here — that would pull them into the production bundle.
|
|
403
|
+
if (page.scriptSource && !isBuildDataFile(page.scriptSource)) {
|
|
404
|
+
return ` ${JSON.stringify(page.id)}: {
|
|
405
|
+
id: ${JSON.stringify(page.id)},
|
|
406
|
+
type: "component",
|
|
407
|
+
load: () => import(${JSON.stringify(page.importPath ?? page.scriptSource)}),
|
|
408
|
+
styles: []
|
|
409
|
+
}`;
|
|
410
|
+
}
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
if (page.type === "markdown") {
|
|
414
|
+
const styles = page.styles
|
|
415
|
+
.map((style) => styleImports.get(style))
|
|
416
|
+
.join(", ");
|
|
417
|
+
if (splitMarkdown) {
|
|
418
|
+
const markdownModuleId = `${MARKDOWN_PREFIX}${page.id}`;
|
|
419
|
+
return ` ${JSON.stringify(page.id)}: {
|
|
420
|
+
id: ${JSON.stringify(page.id)},
|
|
421
|
+
type: "markdown",
|
|
422
|
+
load: () => import(${JSON.stringify(markdownModuleId)}),
|
|
423
|
+
styles: [${styles}]
|
|
424
|
+
}`;
|
|
425
|
+
}
|
|
426
|
+
const markdownField = addRawMarkdown
|
|
427
|
+
? `markdown: ${JSON.stringify(page.markdown ?? "")},`
|
|
428
|
+
: "";
|
|
429
|
+
return ` ${JSON.stringify(page.id)}: {
|
|
430
|
+
id: ${JSON.stringify(page.id)},
|
|
431
|
+
type: "markdown",
|
|
432
|
+
${markdownField}
|
|
433
|
+
html: ${JSON.stringify(page.html ?? "")},
|
|
434
|
+
styles: [${styles}]
|
|
435
|
+
}`;
|
|
436
|
+
}
|
|
437
|
+
// Große Build-Daten (z. B. gerendertes Markdown) nicht inline in
|
|
438
|
+
// den Entry packen, sondern in einen eigenen Chunk auslagern, der
|
|
439
|
+
// erst beim Laden der Seite per import() geholt wird.
|
|
440
|
+
const dataSize = typeof page.buildData === "string" ? page.buildData.length : 1024;
|
|
441
|
+
const inlineData = dataSize < 8 * 1024;
|
|
442
|
+
const dataField = inlineData
|
|
443
|
+
? `data: ${JSON.stringify(page.buildData ?? null)},`
|
|
444
|
+
: `loadData: () => import(${JSON.stringify(DATA_PREFIX + page.id)}).then((m) => m.default),`;
|
|
445
|
+
return ` ${JSON.stringify(page.id)}: {
|
|
446
|
+
id: ${JSON.stringify(page.id)},
|
|
447
|
+
type: "component",
|
|
448
|
+
load: () => import(${JSON.stringify(page.importPath)}),
|
|
449
|
+
${dataField}
|
|
450
|
+
styles: [${styles}]
|
|
451
|
+
}`;
|
|
452
|
+
})
|
|
453
|
+
.filter((entry) => entry != null)
|
|
454
|
+
.join(",\n");
|
|
455
|
+
return `${imports}
|
|
456
|
+
|
|
457
|
+
export const pages = {
|
|
458
|
+
${entries}
|
|
459
|
+
};
|
|
460
|
+
`;
|
|
461
|
+
}
|
|
462
|
+
function writeTypeDeclaration() {
|
|
463
|
+
const dtsPath = path.resolve(config.root, dtsPathOpt);
|
|
464
|
+
const content = `// Auto-generated by vite-plugin-pages-ssg. Do not edit by hand.
|
|
465
|
+
|
|
466
|
+
// Ambient module declaration for the \`virtual:pages\` module the plugin
|
|
467
|
+
// provides. Commit this file to your project (it doesn't need to be
|
|
468
|
+
// regenerated — its content doesn't depend on which pages actually exist,
|
|
469
|
+
// only on the shape of PageEntry/PageModule).
|
|
470
|
+
//
|
|
471
|
+
// Why a static file instead of relying on the plugin's auto-generated
|
|
472
|
+
// \`pages.d.ts\`: that file is written by the plugin's \`configResolved\` hook,
|
|
473
|
+
// which only runs once Vite itself starts. \`tsc -b\` runs standalone and
|
|
474
|
+
// never triggers Vite, so on a clean checkout \`tsc -b && vite build\` fails
|
|
475
|
+
// before Vite ever gets a chance to write it. Keeping a static copy in
|
|
476
|
+
// source control sidesteps the ordering problem completely.
|
|
477
|
+
|
|
478
|
+
declare module "virtual:pages" {
|
|
479
|
+
|
|
480
|
+
export interface PageModule {
|
|
481
|
+
/** Called with the mount element on the client. */
|
|
482
|
+
default: (el: HTMLElement, data?: unknown) => void | Promise<void>;
|
|
483
|
+
|
|
484
|
+
/** Executed only during the Vite build in Node.js. */
|
|
485
|
+
build?: () => unknown | Promise<unknown>;
|
|
486
|
+
|
|
487
|
+
[key: string]: unknown;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
export interface ComponentPage {
|
|
491
|
+
id: string;
|
|
492
|
+
type: "component";
|
|
493
|
+
data?: unknown;
|
|
494
|
+
/** Lazy-loaded build data (large payloads). */
|
|
495
|
+
loadData?: () => Promise<unknown>;
|
|
496
|
+
load: () => Promise<PageModule>;
|
|
497
|
+
styles: string[];
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export interface MarkdownPage {
|
|
501
|
+
id: string;
|
|
502
|
+
type: "markdown";
|
|
503
|
+
markdown?: string;
|
|
504
|
+
html: string;
|
|
505
|
+
styles: string[];
|
|
506
|
+
load: () => Promise<{
|
|
507
|
+
default: {
|
|
508
|
+
id: string;
|
|
509
|
+
type: "markdown";
|
|
510
|
+
markdown?: string;
|
|
511
|
+
html: string;
|
|
512
|
+
};
|
|
513
|
+
}>;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
export interface LiquidPage {
|
|
517
|
+
id: string;
|
|
518
|
+
type: "liquid";
|
|
519
|
+
html: string;
|
|
520
|
+
styles: string[];
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export type PageEntry = ComponentPage | MarkdownPage | LiquidPage;
|
|
524
|
+
|
|
525
|
+
export const pages: Record<string, PageEntry>;
|
|
526
|
+
}
|
|
527
|
+
`;
|
|
528
|
+
fs.writeFileSync(dtsPath, content, "utf8");
|
|
529
|
+
}
|
|
530
|
+
function outputFileName(id) {
|
|
531
|
+
if (!prettyUrls) {
|
|
532
|
+
return `${id}.html`;
|
|
533
|
+
}
|
|
534
|
+
if (id === "index") {
|
|
535
|
+
return "index.html";
|
|
536
|
+
}
|
|
537
|
+
if (id.endsWith("/index")) {
|
|
538
|
+
return `${id.slice(0, -"/index".length)}/index.html`;
|
|
539
|
+
}
|
|
540
|
+
return `${id}/index.html`;
|
|
541
|
+
}
|
|
542
|
+
function defaultTemplate(ctx) {
|
|
543
|
+
/*
|
|
544
|
+
* Pre-rendered content (e.g. Liquid pages): plain static HTML document
|
|
545
|
+
* without the client-side bootstrapping shell.
|
|
546
|
+
*/
|
|
547
|
+
if (ctx.content != null) {
|
|
548
|
+
return `<!doctype html>
|
|
549
|
+
<html lang="en">
|
|
550
|
+
<head>
|
|
551
|
+
<meta charset="UTF-8" />
|
|
552
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
553
|
+
<title>${escapeHtml(ctx.title)}</title>
|
|
554
|
+
${ctx.styleTag}
|
|
555
|
+
${ctx.head}
|
|
556
|
+
</head>
|
|
557
|
+
<body>
|
|
558
|
+
${ctx.content}
|
|
559
|
+
</body>
|
|
560
|
+
</html>
|
|
561
|
+
`;
|
|
562
|
+
}
|
|
563
|
+
return `<!doctype html>
|
|
564
|
+
<html lang="en">
|
|
565
|
+
<head>
|
|
566
|
+
<meta charset="UTF-8" />
|
|
567
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
568
|
+
<title>${escapeHtml(ctx.title)}</title>
|
|
569
|
+
${ctx.styleTag}
|
|
570
|
+
${ctx.head}
|
|
571
|
+
</head>
|
|
572
|
+
<body>
|
|
573
|
+
<div id="app"></div>
|
|
574
|
+
|
|
575
|
+
<script>
|
|
576
|
+
window.${ctx.globalVar} = ${JSON.stringify(ctx.id)};
|
|
577
|
+
</script>
|
|
578
|
+
|
|
579
|
+
${ctx.scriptTag}
|
|
580
|
+
</body>
|
|
581
|
+
</html>
|
|
582
|
+
`;
|
|
583
|
+
}
|
|
584
|
+
return {
|
|
585
|
+
name: "vite-plugin-pages-ssg",
|
|
586
|
+
enforce: "pre",
|
|
587
|
+
async configResolved(resolvedConfig) {
|
|
588
|
+
config = resolvedConfig;
|
|
589
|
+
await preparePages();
|
|
590
|
+
console.log(`[vite-plugin-pages-ssg] Found ${pages.length} page(s)`);
|
|
591
|
+
for (const page of pages) {
|
|
592
|
+
console.log(` - ${page.id}`);
|
|
593
|
+
}
|
|
594
|
+
if (writeDts) {
|
|
595
|
+
writeTypeDeclaration();
|
|
596
|
+
}
|
|
597
|
+
},
|
|
598
|
+
resolveId(id) {
|
|
599
|
+
if (id === VIRTUAL_MODULE_ID) {
|
|
600
|
+
return RESOLVED_VIRTUAL_MODULE_ID;
|
|
601
|
+
}
|
|
602
|
+
if (id.startsWith(MARKDOWN_PREFIX)) {
|
|
603
|
+
return RESOLVED_MARKDOWN_PREFIX + id.slice(MARKDOWN_PREFIX.length);
|
|
604
|
+
}
|
|
605
|
+
if (id.startsWith(STYLE_PREFIX)) {
|
|
606
|
+
return RESOLVED_STYLE_PREFIX + id.slice(STYLE_PREFIX.length);
|
|
607
|
+
}
|
|
608
|
+
if (id.startsWith(DATA_PREFIX)) {
|
|
609
|
+
return RESOLVED_DATA_PREFIX + id.slice(DATA_PREFIX.length);
|
|
610
|
+
}
|
|
611
|
+
return null;
|
|
612
|
+
},
|
|
613
|
+
load(id) {
|
|
614
|
+
if (id === RESOLVED_VIRTUAL_MODULE_ID) {
|
|
615
|
+
return createVirtualModule();
|
|
616
|
+
}
|
|
617
|
+
if (id.startsWith(RESOLVED_MARKDOWN_PREFIX)) {
|
|
618
|
+
const pageId = id.slice(RESOLVED_MARKDOWN_PREFIX.length);
|
|
619
|
+
const page = pages.find((page) => page.type === "markdown" && page.id === pageId);
|
|
620
|
+
if (!page) {
|
|
621
|
+
throw new Error(`[vite-plugin-pages-ssg] Markdown page not found: ${pageId}`);
|
|
622
|
+
}
|
|
623
|
+
return `
|
|
624
|
+
export default ${JSON.stringify({
|
|
625
|
+
id: page.id,
|
|
626
|
+
type: "markdown",
|
|
627
|
+
...(addRawMarkdown ? { markdown: page.markdown ?? "" } : {}),
|
|
628
|
+
html: page.html ?? "",
|
|
629
|
+
})};
|
|
630
|
+
`;
|
|
631
|
+
}
|
|
632
|
+
if (id.startsWith(RESOLVED_STYLE_PREFIX)) {
|
|
633
|
+
const stylePath = id.slice(RESOLVED_STYLE_PREFIX.length);
|
|
634
|
+
return `export { default } from ${JSON.stringify(stylePath + "?url")};`;
|
|
635
|
+
}
|
|
636
|
+
if (id.startsWith(RESOLVED_DATA_PREFIX)) {
|
|
637
|
+
const pageId = id.slice(RESOLVED_DATA_PREFIX.length);
|
|
638
|
+
const page = pages.find((page) => page.type === "component" && page.id === pageId);
|
|
639
|
+
if (!page) {
|
|
640
|
+
throw new Error(`[vite-plugin-pages-ssg] Data page not found: ${pageId}`);
|
|
641
|
+
}
|
|
642
|
+
return `
|
|
643
|
+
export default ${JSON.stringify(page.buildData ?? null)};
|
|
644
|
+
`;
|
|
645
|
+
}
|
|
646
|
+
return null;
|
|
647
|
+
},
|
|
648
|
+
async configureServer(server) {
|
|
649
|
+
const pagesRoot = getPagesDir();
|
|
650
|
+
const docsRoot = path.resolve(config.root, docsDir);
|
|
651
|
+
await preparePages();
|
|
652
|
+
if (verbose) {
|
|
653
|
+
console.log("[vite-plugin-pages-ssg] DEV PAGES:", pages.map((p) => ({
|
|
654
|
+
id: p.id,
|
|
655
|
+
type: p.type,
|
|
656
|
+
source: p.source,
|
|
657
|
+
})));
|
|
658
|
+
}
|
|
659
|
+
server.watcher.add(pagesRoot);
|
|
660
|
+
server.watcher.add(docsRoot);
|
|
661
|
+
const rescan = async (file) => {
|
|
662
|
+
const isPageFile = file.startsWith(pagesRoot) &&
|
|
663
|
+
extensions.includes(path.extname(file).toLowerCase());
|
|
664
|
+
const isDocFile = file.startsWith(docsRoot) &&
|
|
665
|
+
path.extname(file).toLowerCase() === ".md";
|
|
666
|
+
// Liquid templates (page.html) live in the pages dir but have
|
|
667
|
+
// the .html extension, which is not in `extensions`.
|
|
668
|
+
const isLiquidFile = file.startsWith(pagesRoot) &&
|
|
669
|
+
path.extname(file).toLowerCase() === ".html";
|
|
670
|
+
if (!isPageFile && !isDocFile && !isLiquidFile) {
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
await preparePages();
|
|
674
|
+
const mod = server.moduleGraph.getModuleById(RESOLVED_VIRTUAL_MODULE_ID);
|
|
675
|
+
if (mod) {
|
|
676
|
+
server.moduleGraph.invalidateModule(mod);
|
|
677
|
+
}
|
|
678
|
+
if (writeDts) {
|
|
679
|
+
writeTypeDeclaration();
|
|
680
|
+
}
|
|
681
|
+
server.ws.send({ type: "full-reload" });
|
|
682
|
+
};
|
|
683
|
+
server.watcher.on("add", rescan);
|
|
684
|
+
server.watcher.on("unlink", rescan);
|
|
685
|
+
server.watcher.on("change", rescan);
|
|
686
|
+
// Serve each page's HTML on its own dev URL (e.g. /guide/installation
|
|
687
|
+
// or /guide/installation.html), mirroring what generateBundle emits
|
|
688
|
+
// for production, so `vite dev` is multi-page too — not just the build.
|
|
689
|
+
const entry = "/" +
|
|
690
|
+
(options.entry ?? "src/main.ts")
|
|
691
|
+
.replace(/\\/g, "/")
|
|
692
|
+
.replace(/^\/+/, "");
|
|
693
|
+
server.middlewares.use(async (req, res, next) => {
|
|
694
|
+
const url = req.url ?? "/";
|
|
695
|
+
const pathname = url.split("?")[0].split("#")[0];
|
|
696
|
+
// Vite internals
|
|
697
|
+
if (ignoredPathnames.some((prefix) => pathname.startsWith(prefix))) {
|
|
698
|
+
return next();
|
|
699
|
+
}
|
|
700
|
+
// Dateien nicht als Seiten behandeln
|
|
701
|
+
if (path.extname(pathname)) {
|
|
702
|
+
return next();
|
|
703
|
+
}
|
|
704
|
+
const id = getPageId(url, pages);
|
|
705
|
+
const page = pages.find((p) => p.id === id);
|
|
706
|
+
console.log(`[vite-plugin-pages-ssg] ${pathname} -> ${id} -> ${page ? "FOUND" : "404"}`);
|
|
707
|
+
const pageId = page?.id ?? "__404__";
|
|
708
|
+
/*
|
|
709
|
+
* Known page:
|
|
710
|
+
*
|
|
711
|
+
* / -> index
|
|
712
|
+
* /viewer -> viewer
|
|
713
|
+
* /foo/bar -> foo/bar
|
|
714
|
+
*/
|
|
715
|
+
if (page) {
|
|
716
|
+
/*
|
|
717
|
+
* Liquid pages are rendered at build time and served as static
|
|
718
|
+
* HTML. `{{ JS_SCRIPT }}` is replaced with a script tag loading
|
|
719
|
+
* the page module (`page.[tj]s`) through the dev server.
|
|
720
|
+
*/
|
|
721
|
+
if (page.type === "liquid") {
|
|
722
|
+
const scriptTag = page.scriptSource && !isBuildDataFile(page.scriptSource)
|
|
723
|
+
? `<script type="module" src="/${path
|
|
724
|
+
.relative(config.root, page.scriptSource)
|
|
725
|
+
.replace(/\\/g, "/")}"></script>`
|
|
726
|
+
: "";
|
|
727
|
+
// Liquid pages are standalone: serve the rendered content as-is,
|
|
728
|
+
// without wrapping it in the default HTML shell.
|
|
729
|
+
let content = page.html
|
|
730
|
+
?.replaceAll(LIQUID_SCRIPT_PLACEHOLDER, scriptTag)
|
|
731
|
+
// Fallback: wörtliches `{{ JS_SCRIPT }}` ersetzen
|
|
732
|
+
.replaceAll("{{ JS_SCRIPT }}", scriptTag) ?? "";
|
|
733
|
+
if (options.minify) {
|
|
734
|
+
content = await minify(content, {
|
|
735
|
+
collapseWhitespace: true,
|
|
736
|
+
removeComments: true,
|
|
737
|
+
removeRedundantAttributes: true,
|
|
738
|
+
removeEmptyAttributes: true,
|
|
739
|
+
useShortDoctype: true,
|
|
740
|
+
minifyCSS: true,
|
|
741
|
+
minifyJS: true,
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
const transformed = await server.transformIndexHtml(url, content);
|
|
745
|
+
res.statusCode = 200;
|
|
746
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
747
|
+
res.end(transformed);
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
/*
|
|
751
|
+
* EJS pages: same pre-rendered flow as Liquid — serve the
|
|
752
|
+
* rendered content with JS_SCRIPT replaced by the client entry.
|
|
753
|
+
*/
|
|
754
|
+
if (page.type === "ejs") {
|
|
755
|
+
const scriptTag = page.scriptSource && !isBuildDataFile(page.scriptSource)
|
|
756
|
+
? `<script type="module" src="/${path
|
|
757
|
+
.relative(config.root, page.scriptSource)
|
|
758
|
+
.replace(/\\/g, "/")}"></script>`
|
|
759
|
+
: "";
|
|
760
|
+
let content = page.html
|
|
761
|
+
?.replaceAll(LIQUID_SCRIPT_PLACEHOLDER, scriptTag)
|
|
762
|
+
.replaceAll("{{ JS_SCRIPT }}", scriptTag) ?? "";
|
|
763
|
+
if (options.minify) {
|
|
764
|
+
content = await minify(content, {
|
|
765
|
+
collapseWhitespace: true,
|
|
766
|
+
removeComments: true,
|
|
767
|
+
removeRedundantAttributes: true,
|
|
768
|
+
removeEmptyAttributes: true,
|
|
769
|
+
useShortDoctype: true,
|
|
770
|
+
minifyCSS: true,
|
|
771
|
+
minifyJS: true,
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
const transformed = await server.transformIndexHtml(url, content);
|
|
775
|
+
res.statusCode = 200;
|
|
776
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
777
|
+
res.end(transformed);
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
const ctx = {
|
|
781
|
+
id: page.id,
|
|
782
|
+
title: getTitle(page.id),
|
|
783
|
+
globalVar,
|
|
784
|
+
scriptTag: `<script type="module" src="${entry}"></script>`,
|
|
785
|
+
styleTag: "",
|
|
786
|
+
head: getHead(page.id),
|
|
787
|
+
};
|
|
788
|
+
const html = (options.template ?? defaultTemplate)(ctx);
|
|
789
|
+
const transformed = await server.transformIndexHtml(url, html);
|
|
790
|
+
res.statusCode = 200;
|
|
791
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
792
|
+
res.end(transformed);
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
/*
|
|
796
|
+
* Unknown page:
|
|
797
|
+
*
|
|
798
|
+
* /does-not-exist
|
|
799
|
+
* /foo/bar/baz
|
|
800
|
+
*
|
|
801
|
+
* Still boot the application, but tell it that this
|
|
802
|
+
* is a not-found route.
|
|
803
|
+
*/
|
|
804
|
+
const ctx = {
|
|
805
|
+
id: "__404__",
|
|
806
|
+
title: "Page Not Found",
|
|
807
|
+
globalVar,
|
|
808
|
+
scriptTag: `<script type="module" src="${entry}"></script>`,
|
|
809
|
+
styleTag: "",
|
|
810
|
+
head: getHead("__404__"),
|
|
811
|
+
};
|
|
812
|
+
const html = (options.template ?? defaultTemplate)(ctx);
|
|
813
|
+
const transformed = await server.transformIndexHtml(url, html);
|
|
814
|
+
res.statusCode = 404;
|
|
815
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
816
|
+
res.end(transformed);
|
|
817
|
+
});
|
|
818
|
+
},
|
|
819
|
+
// for single bundle
|
|
820
|
+
config() {
|
|
821
|
+
if (!singleBundle && !removeConsole) {
|
|
822
|
+
return {};
|
|
823
|
+
}
|
|
824
|
+
const esbuild = {};
|
|
825
|
+
if (removeConsole) {
|
|
826
|
+
// Release builds: strip console.log/warn/error/debug/info from
|
|
827
|
+
// everything esbuild transforms. Dev mode (vite dev) keeps console
|
|
828
|
+
// output — esbuild handling only applies to the production build.
|
|
829
|
+
esbuild.drop = ["console"];
|
|
830
|
+
}
|
|
831
|
+
const build = {};
|
|
832
|
+
if (singleBundle) {
|
|
833
|
+
build.rollupOptions = {
|
|
834
|
+
output: {
|
|
835
|
+
inlineDynamicImports: true,
|
|
836
|
+
},
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
return {
|
|
840
|
+
...(Object.keys(esbuild).length > 0 ? { esbuild } : {}),
|
|
841
|
+
...(Object.keys(build).length > 0 ? { build } : {}),
|
|
842
|
+
};
|
|
843
|
+
},
|
|
844
|
+
async transform(code, id) {
|
|
845
|
+
if (!id.endsWith(".tsx")) {
|
|
846
|
+
return null;
|
|
847
|
+
}
|
|
848
|
+
const result = await transformWithEsbuild(code, id, {
|
|
849
|
+
loader: "tsx",
|
|
850
|
+
target: "esnext",
|
|
851
|
+
jsxFactory: "jsx",
|
|
852
|
+
jsxFragment: "Fragment",
|
|
853
|
+
sourcemap: true,
|
|
854
|
+
});
|
|
855
|
+
return {
|
|
856
|
+
code: [
|
|
857
|
+
`import { jsx, Fragment } from ${JSON.stringify(jsxRuntimePath)};`,
|
|
858
|
+
result.code,
|
|
859
|
+
].join("\n"),
|
|
860
|
+
map: result.map,
|
|
861
|
+
};
|
|
862
|
+
},
|
|
863
|
+
async buildStart() {
|
|
864
|
+
resolvedStyles.clear();
|
|
865
|
+
for (const page of pages) {
|
|
866
|
+
for (const style of page.styles) {
|
|
867
|
+
if (resolvedStyles.has(style)) {
|
|
868
|
+
continue;
|
|
869
|
+
}
|
|
870
|
+
const resolved = await this.resolve(style, undefined, {
|
|
871
|
+
skipSelf: true,
|
|
872
|
+
});
|
|
873
|
+
if (!resolved) {
|
|
874
|
+
this.error(`[vite-plugin-pages-ssg] Could not resolve style: ${style}`);
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
resolvedStyles.set(style, resolved.id);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
},
|
|
881
|
+
async generateBundle(_outputOptions, bundle) {
|
|
882
|
+
const jsChunk = Object.values(bundle).find((item) => item.type === "chunk" &&
|
|
883
|
+
item.isEntry &&
|
|
884
|
+
item.fileName.endsWith(".js"));
|
|
885
|
+
if (!jsChunk || jsChunk.type !== "chunk") {
|
|
886
|
+
this.error("[vite-plugin-pages-ssg] Could not find the generated entry .js chunk.");
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
const entryJsChunk = jsChunk;
|
|
890
|
+
for (const page of pages) {
|
|
891
|
+
const htmlFileName = outputFileName(page.id);
|
|
892
|
+
const htmlDir = path.dirname(htmlFileName);
|
|
893
|
+
const scriptPath = getAssetPath(htmlFileName, entryJsChunk.fileName);
|
|
894
|
+
const scriptTag = `<script type="module" src="${scriptPath}"></script>`;
|
|
895
|
+
/*
|
|
896
|
+
* Find the Rollup chunk belonging to this page.
|
|
897
|
+
*/
|
|
898
|
+
const pageChunk = Object.values(bundle).find((item) => item.type === "chunk" &&
|
|
899
|
+
Object.keys(item.modules).some((moduleId) => path.resolve(moduleId) === page.source));
|
|
900
|
+
let styleTag = "";
|
|
901
|
+
const cssFiles = new Set(entryJsChunk.viteMetadata?.importedCss ?? []);
|
|
902
|
+
if (pageChunk?.type === "chunk" && pageChunk.viteMetadata) {
|
|
903
|
+
for (const cssFile of pageChunk.viteMetadata.importedCss) {
|
|
904
|
+
cssFiles.add(cssFile);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
if (cssFiles.size > 0) {
|
|
908
|
+
styleTag = [...cssFiles]
|
|
909
|
+
.map((cssFile) => {
|
|
910
|
+
const cssPath = getAssetPath(htmlFileName, cssFile);
|
|
911
|
+
return `<link rel="stylesheet" href="${cssPath}" />`;
|
|
912
|
+
})
|
|
913
|
+
.join("\n ");
|
|
914
|
+
}
|
|
915
|
+
/*
|
|
916
|
+
* Liquid pages are standalone: emit the rendered content as-is,
|
|
917
|
+
* without wrapping it in the default HTML shell. `{{ JS_SCRIPT }}`
|
|
918
|
+
* is replaced with the built page module (`page.[tj]s`), compiled
|
|
919
|
+
* with esbuild and emitted as its own asset.
|
|
920
|
+
*/
|
|
921
|
+
if (page.type === "liquid") {
|
|
922
|
+
let scriptTagForContent = "";
|
|
923
|
+
if (page.scriptSource &&
|
|
924
|
+
!isBuildDataFile(page.scriptSource) &&
|
|
925
|
+
fs.existsSync(page.scriptSource)) {
|
|
926
|
+
const scriptSourceCode = fs.readFileSync(page.scriptSource, "utf8");
|
|
927
|
+
const scriptExtension = path
|
|
928
|
+
.extname(page.scriptSource)
|
|
929
|
+
.slice(1)
|
|
930
|
+
.toLowerCase();
|
|
931
|
+
const loader = scriptExtension === "ts" || scriptExtension === "tsx"
|
|
932
|
+
? "ts"
|
|
933
|
+
: scriptExtension === "jsx"
|
|
934
|
+
? "jsx"
|
|
935
|
+
: "js";
|
|
936
|
+
const result = await transformWithEsbuild(scriptSourceCode, page.scriptSource, {
|
|
937
|
+
loader,
|
|
938
|
+
target: "esnext",
|
|
939
|
+
// Das Script wird als eigenes Asset ausgegeben und läuft
|
|
940
|
+
// nicht durch Vites normalen Minify-Pass — daher hier
|
|
941
|
+
// direkt minifizieren.
|
|
942
|
+
minify: true,
|
|
943
|
+
});
|
|
944
|
+
const scriptName = path.basename(page.scriptSource, path.extname(page.scriptSource));
|
|
945
|
+
const scriptFileName = `assets/${scriptName}.js`;
|
|
946
|
+
this.emitFile({
|
|
947
|
+
type: "asset",
|
|
948
|
+
fileName: scriptFileName,
|
|
949
|
+
source: result.code,
|
|
950
|
+
});
|
|
951
|
+
scriptTagForContent = `<script type="module" src="${getAssetPath(htmlFileName, scriptFileName)}"></script>`;
|
|
952
|
+
}
|
|
953
|
+
let liquidHtml = page.html
|
|
954
|
+
?.replaceAll(LIQUID_SCRIPT_PLACEHOLDER, scriptTagForContent)
|
|
955
|
+
.replaceAll("{{ JS_SCRIPT }}", scriptTagForContent) ?? "";
|
|
956
|
+
if (options.minify) {
|
|
957
|
+
liquidHtml = await minify(liquidHtml, {
|
|
958
|
+
collapseWhitespace: true,
|
|
959
|
+
removeComments: true,
|
|
960
|
+
removeRedundantAttributes: true,
|
|
961
|
+
removeEmptyAttributes: true,
|
|
962
|
+
useShortDoctype: true,
|
|
963
|
+
minifyCSS: true,
|
|
964
|
+
minifyJS: true,
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
this.emitFile({
|
|
968
|
+
type: "asset",
|
|
969
|
+
fileName: htmlFileName,
|
|
970
|
+
source: liquidHtml,
|
|
971
|
+
});
|
|
972
|
+
continue;
|
|
973
|
+
}
|
|
974
|
+
// EJS: already rendered in preparePages() into page.html.
|
|
975
|
+
// Same script-injection flow as Liquid: compile the client
|
|
976
|
+
// page script and replace the JS_SCRIPT placeholder.
|
|
977
|
+
if (page.type === "ejs") {
|
|
978
|
+
let scriptTagForContent = "";
|
|
979
|
+
if (page.scriptSource &&
|
|
980
|
+
!isBuildDataFile(page.scriptSource) &&
|
|
981
|
+
fs.existsSync(page.scriptSource)) {
|
|
982
|
+
const scriptSourceCode = fs.readFileSync(page.scriptSource, "utf8");
|
|
983
|
+
const scriptExtension = path
|
|
984
|
+
.extname(page.scriptSource)
|
|
985
|
+
.slice(1)
|
|
986
|
+
.toLowerCase();
|
|
987
|
+
const loader = scriptExtension === "ts" || scriptExtension === "tsx"
|
|
988
|
+
? "ts"
|
|
989
|
+
: scriptExtension === "jsx"
|
|
990
|
+
? "jsx"
|
|
991
|
+
: "js";
|
|
992
|
+
const result = await transformWithEsbuild(scriptSourceCode, page.scriptSource, {
|
|
993
|
+
loader,
|
|
994
|
+
target: "esnext",
|
|
995
|
+
minify: true,
|
|
996
|
+
});
|
|
997
|
+
const scriptName = path.basename(page.scriptSource, path.extname(page.scriptSource));
|
|
998
|
+
const scriptFileName = `assets/${scriptName}.js`;
|
|
999
|
+
this.emitFile({
|
|
1000
|
+
type: "asset",
|
|
1001
|
+
fileName: scriptFileName,
|
|
1002
|
+
source: result.code,
|
|
1003
|
+
});
|
|
1004
|
+
scriptTagForContent = `<script type="module" src="${getAssetPath(htmlFileName, scriptFileName)}"></script>`;
|
|
1005
|
+
}
|
|
1006
|
+
let ejsHtml = page.html
|
|
1007
|
+
?.replaceAll(LIQUID_SCRIPT_PLACEHOLDER, scriptTagForContent)
|
|
1008
|
+
.replaceAll("{{ JS_SCRIPT }}", scriptTagForContent) ?? "";
|
|
1009
|
+
if (options.minify) {
|
|
1010
|
+
ejsHtml = await minify(ejsHtml, {
|
|
1011
|
+
collapseWhitespace: true,
|
|
1012
|
+
removeComments: true,
|
|
1013
|
+
removeRedundantAttributes: true,
|
|
1014
|
+
removeEmptyAttributes: true,
|
|
1015
|
+
useShortDoctype: true,
|
|
1016
|
+
minifyCSS: true,
|
|
1017
|
+
minifyJS: true,
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
this.emitFile({
|
|
1021
|
+
type: "asset",
|
|
1022
|
+
fileName: htmlFileName,
|
|
1023
|
+
source: ejsHtml,
|
|
1024
|
+
});
|
|
1025
|
+
continue;
|
|
1026
|
+
}
|
|
1027
|
+
const ctx = {
|
|
1028
|
+
id: page.id,
|
|
1029
|
+
title: getTitle(page.id),
|
|
1030
|
+
globalVar,
|
|
1031
|
+
scriptTag,
|
|
1032
|
+
styleTag,
|
|
1033
|
+
head: getHead(page.id),
|
|
1034
|
+
};
|
|
1035
|
+
let html = (options.template ?? defaultTemplate)(ctx);
|
|
1036
|
+
if (options.minify) {
|
|
1037
|
+
html = await minify(html, {
|
|
1038
|
+
collapseWhitespace: true,
|
|
1039
|
+
removeComments: true,
|
|
1040
|
+
removeRedundantAttributes: true,
|
|
1041
|
+
removeEmptyAttributes: true,
|
|
1042
|
+
useShortDoctype: true,
|
|
1043
|
+
minifyCSS: true,
|
|
1044
|
+
minifyJS: true,
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
this.emitFile({
|
|
1048
|
+
type: "asset",
|
|
1049
|
+
fileName: htmlFileName,
|
|
1050
|
+
source: html,
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
},
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
function getPageId(url, pages) {
|
|
1057
|
+
let pathname = url.split("?")[0].split("#")[0];
|
|
1058
|
+
pathname = pathname.replace(/^\/+/, "");
|
|
1059
|
+
pathname = pathname.replace(/\.html$/, "");
|
|
1060
|
+
pathname = pathname.replace(/\/+$/, "");
|
|
1061
|
+
// "/" -> "index"
|
|
1062
|
+
if (!pathname) {
|
|
1063
|
+
return "index";
|
|
1064
|
+
}
|
|
1065
|
+
// Direkte Page, z. B.
|
|
1066
|
+
// /about -> about
|
|
1067
|
+
// /viewer/index -> viewer/index
|
|
1068
|
+
const directPage = pages.find((page) => page.id === pathname);
|
|
1069
|
+
if (directPage) {
|
|
1070
|
+
return directPage.id;
|
|
1071
|
+
}
|
|
1072
|
+
// Index-Page eines Verzeichnisses:
|
|
1073
|
+
//
|
|
1074
|
+
// /viewer -> viewer/index
|
|
1075
|
+
// /docs -> docs/index
|
|
1076
|
+
//
|
|
1077
|
+
const indexPage = `${pathname}/index`;
|
|
1078
|
+
if (pages.some((page) => page.id === indexPage)) {
|
|
1079
|
+
return indexPage;
|
|
1080
|
+
}
|
|
1081
|
+
return pathname;
|
|
1082
|
+
}
|
|
1083
|
+
// Function to load the build data
|
|
1084
|
+
async function loadBuildData(page) {
|
|
1085
|
+
if (page.type !== "component" &&
|
|
1086
|
+
page.type !== "liquid" &&
|
|
1087
|
+
page.type !== "ejs") {
|
|
1088
|
+
return null;
|
|
1089
|
+
}
|
|
1090
|
+
let buildFile;
|
|
1091
|
+
if (page.type === "liquid") {
|
|
1092
|
+
// page.html -> page.build.[tj]s
|
|
1093
|
+
buildFile =
|
|
1094
|
+
[".ts", ".tsx", ".js", ".jsx"]
|
|
1095
|
+
.map((ext) => page.source.slice(0, -".html".length) + `.build${ext}`)
|
|
1096
|
+
.find((candidate) => fs.existsSync(candidate)) ?? "";
|
|
1097
|
+
}
|
|
1098
|
+
else if (page.type === "ejs") {
|
|
1099
|
+
// page.ejs -> page.build.[tj]s
|
|
1100
|
+
buildFile =
|
|
1101
|
+
[".ts", ".tsx", ".js", ".jsx"]
|
|
1102
|
+
.map((ext) => page.source.slice(0, -".ejs".length) + `.build${ext}`)
|
|
1103
|
+
.find((candidate) => fs.existsSync(candidate)) ?? "";
|
|
1104
|
+
}
|
|
1105
|
+
else {
|
|
1106
|
+
buildFile = page.source.replace(/\.(tsx?|jsx?)$/, ".build.$1");
|
|
1107
|
+
}
|
|
1108
|
+
if (!buildFile || !fs.existsSync(buildFile)) {
|
|
1109
|
+
return null;
|
|
1110
|
+
}
|
|
1111
|
+
try {
|
|
1112
|
+
const fileUrl = pathToFileURL(buildFile).href;
|
|
1113
|
+
const module = await tsImport(fileUrl, import.meta.url);
|
|
1114
|
+
if (typeof module.build !== "function") {
|
|
1115
|
+
return null;
|
|
1116
|
+
}
|
|
1117
|
+
return await module.build();
|
|
1118
|
+
}
|
|
1119
|
+
catch (error) {
|
|
1120
|
+
throw new Error(`[vite-plugin-pages-ssg] Failed to execute build() for "${page.id}":\n${String(error)}`);
|
|
1121
|
+
}
|
|
1122
|
+
}
|