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/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 `&lt;${inner.replace(/</g, "&lt;")}&gt;`;
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
+ }