twynejs 1.0.1 → 1.0.3

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.d.ts CHANGED
@@ -30,6 +30,8 @@ export interface PagesPluginOptions {
30
30
  * URLs). The page with id `"index"` is always emitted as the top-level
31
31
  * `index.html`, never `index/index.html`.
32
32
  *
33
+ * Ignored when `singleFile` is enabled.
34
+ *
33
35
  * @default false
34
36
  */
35
37
  prettyUrls?: boolean;
@@ -60,6 +62,9 @@ export interface PagesPluginOptions {
60
62
  * Wrap/replace the emitted HTML shell entirely. Receives the computed
61
63
  * script/style tags and page metadata; must return a full HTML document.
62
64
  * Falls back to a minimal built-in template.
65
+ *
66
+ * Not used when `singleFile` is enabled — the single-file shell has its
67
+ * own hash-router bootstrap and is built independently of `template`.
63
68
  */
64
69
  template?: (ctx: PageRenderContext) => string;
65
70
  /** Minify emitted HTML with html-minifier-terser. @default false */
@@ -134,6 +139,9 @@ export interface PagesPluginOptions {
134
139
  * Put each compiled Markdown page into its own dynamically loaded chunk
135
140
  * instead of embedding all Markdown HTML into the main bundle.
136
141
  *
142
+ * Forced back to `false` when `singleFile` is enabled — a split chunk
143
+ * would defeat the point of a single-file export.
144
+ *
137
145
  * @default false
138
146
  */
139
147
  splitMarkdown?: boolean;
@@ -157,6 +165,40 @@ export interface PagesPluginOptions {
157
165
  * @default "twynejs/jsx-runtime"
158
166
  */
159
167
  jsxRuntimePath?: string;
168
+ /**
169
+ * Emit the whole site as a single `index.html` file with client-side,
170
+ * hash-based routing (`#/guide/install`) instead of one HTML file per
171
+ * page. Everything needed to render every "component"/"markdown" page —
172
+ * JS and CSS — is inlined into that one file, so the result can be
173
+ * opened directly via `file://`, embedded, or dropped onto any static
174
+ * host with no server-side rewrites.
175
+ *
176
+ * What this implies automatically, so you normally don't need to set
177
+ * them yourself:
178
+ *
179
+ * - Rollup output is forced to `inlineDynamicImports: true` (like
180
+ * `singleBundle`), so every page ends up in one JS chunk.
181
+ * - `splitMarkdown` is ignored (forced off) and all build data is
182
+ * inlined regardless of size — there is nowhere else to put it.
183
+ *
184
+ * Limitations:
185
+ *
186
+ * - Only `"component"` and `"markdown"` pages participate in the
187
+ * router. Liquid (`.html`) and EJS (`.ejs`) pages are build-time
188
+ * *static* documents and are not compatible with a client-side hash
189
+ * router; if any exist, they are skipped with a build warning.
190
+ * - The shared client `entry` is responsible for reading
191
+ * `window[globalVar]` on load, calling `pages[id].load()` to mount it,
192
+ * and listening for the `window` `"pagechange"` `CustomEvent`
193
+ * (`event.detail.id`) to re-render when the hash changes, since the
194
+ * plugin only owns the HTML shell and the routing bootstrap, not your
195
+ * app's rendering logic.
196
+ * - `template`/`prettyUrls`/per-page output file naming are not used;
197
+ * the output is always a single `index.html`.
198
+ *
199
+ * @default false
200
+ */
201
+ singleFile?: boolean;
160
202
  }
161
203
  export interface PageRenderContext {
162
204
  id: string;
package/dist/index.js CHANGED
@@ -48,6 +48,17 @@ function escapeBareAngles(html) {
48
48
  return `&lt;${inner.replace(/</g, "&lt;")}&gt;`;
49
49
  });
50
50
  }
51
+ /**
52
+ * Escape `</script` inside a string that will be embedded verbatim into an
53
+ * inline `<script>` tag. The HTML tokenizer ends a `<script>` element the
54
+ * moment it sees the literal text `</script`, regardless of whether that
55
+ * text sits inside a JS string/comment — without this, bundled code that
56
+ * happens to contain that substring (e.g. inside a template literal)
57
+ * would truncate the page.
58
+ */
59
+ function escapeScriptClose(code) {
60
+ return code.replace(/<\/script/gi, "<\\/script");
61
+ }
51
62
  let resolvedStyles = new Map();
52
63
  const DEFAULT_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"];
53
64
  export function pagesPlugin(options = {}) {
@@ -61,9 +72,12 @@ export function pagesPlugin(options = {}) {
61
72
  "/__devtools-oxc/",
62
73
  ...(options.ignoredPathnames ?? []),
63
74
  ].filter((prefix, index, prefixes) => prefixes.indexOf(prefix) === index);
64
- const splitMarkdown = options.splitMarkdown ?? false;
65
75
  const verbose = options.verbose ?? false;
66
76
  const singleBundle = options.singleBundle ?? false;
77
+ // Single-file export needs everything in one JS chunk and everything
78
+ // inlined, so it forces off `splitMarkdown` regardless of what was set.
79
+ const singleFile = options.singleFile ?? false;
80
+ const splitMarkdown = (options.splitMarkdown ?? false) && !singleFile;
67
81
  const removeConsole = options.removeConsole ?? false;
68
82
  const relativePath = options.relativePaths ?? false;
69
83
  const addRawMarkdown = options.addRawMarkdown ?? false;
@@ -392,6 +406,10 @@ export function pagesPlugin(options = {}) {
392
406
  const styles = page.styles
393
407
  .map((style) => styleImports.get(style))
394
408
  .join(", ");
409
+ // Exposed to the client so an app (in particular a `singleFile`
410
+ // hash-router shell) can update `document.title` itself when the
411
+ // active page changes, without a full page reload.
412
+ const titleField = `title: ${JSON.stringify(getTitle(page.id))},`;
395
413
  /* Liquid/EJS template pages are fully static (rendered at build
396
414
  * time) — they are served/emitted as-is and do not need a
397
415
  * client-side entry in the pages map (which would bloat the
@@ -404,6 +422,7 @@ export function pagesPlugin(options = {}) {
404
422
  return ` ${JSON.stringify(page.id)}: {
405
423
  id: ${JSON.stringify(page.id)},
406
424
  type: "component",
425
+ ${titleField}
407
426
  load: () => import(${JSON.stringify(page.importPath ?? page.scriptSource)}),
408
427
  styles: []
409
428
  }`;
@@ -419,6 +438,7 @@ export function pagesPlugin(options = {}) {
419
438
  return ` ${JSON.stringify(page.id)}: {
420
439
  id: ${JSON.stringify(page.id)},
421
440
  type: "markdown",
441
+ ${titleField}
422
442
  load: () => import(${JSON.stringify(markdownModuleId)}),
423
443
  styles: [${styles}]
424
444
  }`;
@@ -429,6 +449,7 @@ export function pagesPlugin(options = {}) {
429
449
  return ` ${JSON.stringify(page.id)}: {
430
450
  id: ${JSON.stringify(page.id)},
431
451
  type: "markdown",
452
+ ${titleField}
432
453
  ${markdownField}
433
454
  html: ${JSON.stringify(page.html ?? "")},
434
455
  styles: [${styles}]
@@ -437,14 +458,17 @@ export function pagesPlugin(options = {}) {
437
458
  // Große Build-Daten (z. B. gerendertes Markdown) nicht inline in
438
459
  // den Entry packen, sondern in einen eigenen Chunk auslagern, der
439
460
  // erst beim Laden der Seite per import() geholt wird.
461
+ // In `singleFile` mode there is nowhere else to put a lazily
462
+ // loaded chunk, so everything is always inlined regardless of size.
440
463
  const dataSize = typeof page.buildData === "string" ? page.buildData.length : 1024;
441
- const inlineData = dataSize < 8 * 1024;
464
+ const inlineData = singleFile || dataSize < 8 * 1024;
442
465
  const dataField = inlineData
443
466
  ? `data: ${JSON.stringify(page.buildData ?? null)},`
444
467
  : `loadData: () => import(${JSON.stringify(DATA_PREFIX + page.id)}).then((m) => m.default),`;
445
468
  return ` ${JSON.stringify(page.id)}: {
446
469
  id: ${JSON.stringify(page.id)},
447
470
  type: "component",
471
+ ${titleField}
448
472
  load: () => import(${JSON.stringify(page.importPath)}),
449
473
  ${dataField}
450
474
  styles: [${styles}]
@@ -490,6 +514,10 @@ declare module "virtual:pages" {
490
514
  export interface ComponentPage {
491
515
  id: string;
492
516
  type: "component";
517
+ /** Resolved via the plugin's \`title\` option. Handy for setting
518
+ * \`document.title\` yourself when routing client-side (e.g. in
519
+ * \`singleFile\` / hash-router mode). */
520
+ title: string;
493
521
  data?: unknown;
494
522
  /** Lazy-loaded build data (large payloads). */
495
523
  loadData?: () => Promise<unknown>;
@@ -500,6 +528,7 @@ declare module "virtual:pages" {
500
528
  export interface MarkdownPage {
501
529
  id: string;
502
530
  type: "markdown";
531
+ title: string;
503
532
  markdown?: string;
504
533
  html: string;
505
534
  styles: string[];
@@ -513,14 +542,20 @@ declare module "virtual:pages" {
513
542
  }>;
514
543
  }
515
544
 
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;
545
+ // Note: there is intentionally no exported "LiquidPage" variant here.
546
+ // Liquid/EJS template pages are rendered fully at build time as
547
+ // standalone static documents; when one of them also has a sibling
548
+ // client script (\`page.[tj]s\`), it is exposed through \`virtual:pages\`
549
+ // as an ordinary ComponentPage (\`type: "component"\`) so the app can
550
+ // mount it like any other page. Without a sibling script it never
551
+ // appears in \`pages\` at all. A runtime object shaped like
552
+ // \`{ type: "liquid", ... }\` is never actually produced, so including
553
+ // it in this union previously made every \`pages[id]\` access widen to
554
+ // \`ComponentPage | LiquidPage\` after narrowing out "markdown", even
555
+ // though \`LiquidPage\` has no \`load\` — causing spurious
556
+ // "Property 'load' does not exist on type 'LiquidPage'" errors in
557
+ // consumers under \`tsc -b\`.
558
+ export type PageEntry = ComponentPage | MarkdownPage;
524
559
 
525
560
  export const pages: Record<string, PageEntry>;
526
561
  }
@@ -580,6 +615,31 @@ ${ctx.content}
580
615
  </body>
581
616
  </html>
582
617
  `;
618
+ }
619
+ /**
620
+ * Bootstrap script for `singleFile` mode: derives the current page id
621
+ * from `location.hash` (`#/guide/install` → `"guide/install"`, empty →
622
+ * `rootId`), exposes it on `window[globalVar]` just like the multi-page
623
+ * template does, and re-derives it on every `hashchange`, dispatching a
624
+ * `"pagechange"` `CustomEvent` (`event.detail.id`) so the app's entry can
625
+ * re-render without a full page reload.
626
+ */
627
+ function singleFileBootstrapScript(rootId) {
628
+ return `(function () {
629
+ var DEFAULT_PAGE = ${JSON.stringify(rootId)};
630
+ function currentPageId() {
631
+ var hash = window.location.hash || "";
632
+ hash = hash.replace(/^#\\/?/, "").replace(/\\/+$/, "");
633
+ return hash || DEFAULT_PAGE;
634
+ }
635
+ window.${globalVar} = currentPageId();
636
+ window.addEventListener("hashchange", function () {
637
+ window.${globalVar} = currentPageId();
638
+ window.dispatchEvent(
639
+ new CustomEvent("pagechange", { detail: { id: window.${globalVar} } })
640
+ );
641
+ });
642
+ })();`;
583
643
  }
584
644
  return {
585
645
  name: "vite-plugin-pages-ssg",
@@ -624,6 +684,7 @@ ${ctx.content}
624
684
  export default ${JSON.stringify({
625
685
  id: page.id,
626
686
  type: "markdown",
687
+ title: getTitle(page.id),
627
688
  ...(addRawMarkdown ? { markdown: page.markdown ?? "" } : {}),
628
689
  html: page.html ?? "",
629
690
  })};
@@ -686,6 +747,12 @@ ${ctx.content}
686
747
  // Serve each page's HTML on its own dev URL (e.g. /guide/installation
687
748
  // or /guide/installation.html), mirroring what generateBundle emits
688
749
  // for production, so `vite dev` is multi-page too — not just the build.
750
+ //
751
+ // Note: `singleFile` only changes the *production build* output.
752
+ // The dev server keeps serving one URL per page id as usual — the
753
+ // hash-router bootstrap only exists in the emitted single-file
754
+ // production HTML — since normal multi-URL dev navigation is more
755
+ // convenient while developing.
689
756
  const entry = "/" +
690
757
  (options.entry ?? "src/main.ts")
691
758
  .replace(/\\/g, "/")
@@ -816,9 +883,9 @@ ${ctx.content}
816
883
  res.end(transformed);
817
884
  });
818
885
  },
819
- // for single bundle
886
+ // for single bundle / single file
820
887
  config() {
821
- if (!singleBundle && !removeConsole) {
888
+ if (!singleBundle && !removeConsole && !singleFile) {
822
889
  return {};
823
890
  }
824
891
  const esbuild = {};
@@ -829,7 +896,9 @@ ${ctx.content}
829
896
  esbuild.drop = ["console"];
830
897
  }
831
898
  const build = {};
832
- if (singleBundle) {
899
+ if (singleBundle || singleFile) {
900
+ // `singleFile` needs exactly one JS chunk to inline into the HTML
901
+ // shell, same requirement as `singleBundle`.
833
902
  build.rollupOptions = {
834
903
  output: {
835
904
  inlineDynamicImports: true,
@@ -889,6 +958,27 @@ ${ctx.content}
889
958
  return;
890
959
  }
891
960
  const entryJsChunk = jsChunk;
961
+ /*
962
+ * Single-file export: skip the normal per-page HTML emission
963
+ * entirely — the actual `index.html` is assembled in `writeBundle`
964
+ * instead (see below), once every other plugin's `generateBundle`
965
+ * hook (including Vite's own internal dynamic-import / preload
966
+ * handling, e.g. its `__VITE_PRELOAD__` substitution) has already
967
+ * run. Reading `entryJsChunk.code` here, in this plugin's own
968
+ * `generateBundle` (registered with `enforce: "pre"`, so it can run
969
+ * *before* Vite's internal build plugins), would risk capturing
970
+ * unfinished code and inlining an unresolved `__VITE_PRELOAD__`
971
+ * placeholder straight into the page.
972
+ */
973
+ if (singleFile) {
974
+ const skippedPages = pages.filter((page) => page.type === "liquid" || page.type === "ejs");
975
+ if (skippedPages.length > 0) {
976
+ this.warn(`[vite-plugin-pages-ssg] singleFile: skipping ${skippedPages.length} Liquid/EJS page(s) (${skippedPages
977
+ .map((page) => page.id)
978
+ .join(", ")}) — they are static, build-time-rendered documents and are not compatible with the single-file hash router. Only "component" and "markdown" pages are bundled into index.html.`);
979
+ }
980
+ return;
981
+ }
892
982
  for (const page of pages) {
893
983
  const htmlFileName = outputFileName(page.id);
894
984
  const htmlDir = path.dirname(htmlFileName);
@@ -1053,6 +1143,109 @@ ${ctx.content}
1053
1143
  });
1054
1144
  }
1055
1145
  },
1146
+ /*
1147
+ * Runs strictly after every plugin's `generateBundle` hook has
1148
+ * finished and Rollup has already written the bundle to disk — so by
1149
+ * now Vite's own internal transforms (dynamic-import preload
1150
+ * handling, `__VITE_PRELOAD__` substitution, etc.) are guaranteed to
1151
+ * be fully resolved in `bundle[...].code`. This is what makes it safe
1152
+ * to read the entry chunk's final code and inline it, unlike doing
1153
+ * the same thing inside `generateBundle` (see the comment there).
1154
+ */
1155
+ async writeBundle(outputOptions, bundle) {
1156
+ if (!singleFile) {
1157
+ return;
1158
+ }
1159
+ const entryJsChunk = Object.values(bundle).find((item) => item.type === "chunk" &&
1160
+ item.isEntry &&
1161
+ item.fileName.endsWith(".js"));
1162
+ if (!entryJsChunk || entryJsChunk.type !== "chunk") {
1163
+ this.error("[vite-plugin-pages-ssg] singleFile: could not find the generated entry .js chunk while writing the bundle.");
1164
+ return;
1165
+ }
1166
+ const otherChunkCount = Object.values(bundle).filter((item) => item.type === "chunk" && item !== entryJsChunk).length;
1167
+ if (otherChunkCount > 0) {
1168
+ this.warn(`[vite-plugin-pages-ssg] singleFile: found ${otherChunkCount} extra JS chunk(s) besides the entry chunk that could not be inlined into index.html. They will still be left as separate files, so the build is not a true single file — check for a custom "manualChunks"/"output" config that overrides "inlineDynamicImports".`);
1169
+ }
1170
+ const cssParts = [];
1171
+ const cssFileNames = [];
1172
+ for (const [fileName, item] of Object.entries(bundle)) {
1173
+ if (item.type === "asset" && fileName.toLowerCase().endsWith(".css")) {
1174
+ const source = item.source;
1175
+ cssParts.push(typeof source === "string"
1176
+ ? source
1177
+ : Buffer.from(source).toString("utf8"));
1178
+ cssFileNames.push(fileName);
1179
+ }
1180
+ }
1181
+ const cssContent = cssParts.join("\n");
1182
+ const bundledPages = pages.filter((page) => page.type === "component" || page.type === "markdown");
1183
+ const rootId = bundledPages.some((page) => page.id === "index")
1184
+ ? "index"
1185
+ : (bundledPages[0]?.id ?? "index");
1186
+ const title = getTitle(rootId);
1187
+ const head = getHead(rootId);
1188
+ const bootstrapScript = singleFileBootstrapScript(rootId);
1189
+ const inlineJs = escapeScriptClose(entryJsChunk.code);
1190
+ let html = `<!doctype html>
1191
+ <html lang="en">
1192
+ <head>
1193
+ <meta charset="UTF-8" />
1194
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
1195
+ <title>${escapeHtml(title)}</title>
1196
+ ${cssContent ? `<style>\n${cssContent}\n </style>` : ""}
1197
+ ${head}
1198
+ </head>
1199
+ <body>
1200
+ <div id="app"></div>
1201
+
1202
+ <script>
1203
+ ${bootstrapScript}
1204
+ </script>
1205
+
1206
+ <script type="module">
1207
+ ${inlineJs}
1208
+ </script>
1209
+ </body>
1210
+ </html>
1211
+ `;
1212
+ if (options.minify) {
1213
+ html = await minify(html, {
1214
+ collapseWhitespace: true,
1215
+ removeComments: true,
1216
+ removeRedundantAttributes: true,
1217
+ removeEmptyAttributes: true,
1218
+ useShortDoctype: true,
1219
+ minifyCSS: true,
1220
+ minifyJS: true,
1221
+ });
1222
+ }
1223
+ const outDir = path.resolve(config.root, outputOptions.dir ?? config.build.outDir ?? "dist");
1224
+ fs.writeFileSync(path.join(outDir, "index.html"), html, "utf8");
1225
+ // The entry chunk and every CSS asset are now embedded directly in
1226
+ // index.html (already written above) — remove the now-redundant
1227
+ // loose files from disk so only index.html remains.
1228
+ const filesToRemove = [entryJsChunk.fileName, ...cssFileNames];
1229
+ const dirsTouched = new Set();
1230
+ for (const fileName of filesToRemove) {
1231
+ const filePath = path.join(outDir, fileName);
1232
+ if (fs.existsSync(filePath)) {
1233
+ fs.rmSync(filePath);
1234
+ dirsTouched.add(path.dirname(filePath));
1235
+ }
1236
+ }
1237
+ // Clean up asset directories (e.g. "assets/") left empty behind.
1238
+ for (const dir of dirsTouched) {
1239
+ if (dir !== outDir &&
1240
+ fs.existsSync(dir) &&
1241
+ fs.readdirSync(dir).length === 0) {
1242
+ fs.rmdirSync(dir);
1243
+ }
1244
+ }
1245
+ if (verbose) {
1246
+ console.log(`[vite-plugin-pages-ssg] Emitted single-file build: index.html (${bundledPages.length} page(s) bundled)`);
1247
+ }
1248
+ },
1056
1249
  };
1057
1250
  }
1058
1251
  function getPageId(url, pages) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "twynejs",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "twine A Vite SSG plugin that turns .ts/.tsx pages, Markdown docs and Liquid/EJS templates into a multi-page static build with a virtual:pages module.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",