twynejs 1.0.1 → 1.0.2

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[];
@@ -516,6 +545,7 @@ declare module "virtual:pages" {
516
545
  export interface LiquidPage {
517
546
  id: string;
518
547
  type: "liquid";
548
+ title: string;
519
549
  html: string;
520
550
  styles: string[];
521
551
  }
@@ -580,6 +610,31 @@ ${ctx.content}
580
610
  </body>
581
611
  </html>
582
612
  `;
613
+ }
614
+ /**
615
+ * Bootstrap script for `singleFile` mode: derives the current page id
616
+ * from `location.hash` (`#/guide/install` → `"guide/install"`, empty →
617
+ * `rootId`), exposes it on `window[globalVar]` just like the multi-page
618
+ * template does, and re-derives it on every `hashchange`, dispatching a
619
+ * `"pagechange"` `CustomEvent` (`event.detail.id`) so the app's entry can
620
+ * re-render without a full page reload.
621
+ */
622
+ function singleFileBootstrapScript(rootId) {
623
+ return `(function () {
624
+ var DEFAULT_PAGE = ${JSON.stringify(rootId)};
625
+ function currentPageId() {
626
+ var hash = window.location.hash || "";
627
+ hash = hash.replace(/^#\\/?/, "").replace(/\\/+$/, "");
628
+ return hash || DEFAULT_PAGE;
629
+ }
630
+ window.${globalVar} = currentPageId();
631
+ window.addEventListener("hashchange", function () {
632
+ window.${globalVar} = currentPageId();
633
+ window.dispatchEvent(
634
+ new CustomEvent("pagechange", { detail: { id: window.${globalVar} } })
635
+ );
636
+ });
637
+ })();`;
583
638
  }
584
639
  return {
585
640
  name: "vite-plugin-pages-ssg",
@@ -624,6 +679,7 @@ ${ctx.content}
624
679
  export default ${JSON.stringify({
625
680
  id: page.id,
626
681
  type: "markdown",
682
+ title: getTitle(page.id),
627
683
  ...(addRawMarkdown ? { markdown: page.markdown ?? "" } : {}),
628
684
  html: page.html ?? "",
629
685
  })};
@@ -686,6 +742,12 @@ ${ctx.content}
686
742
  // Serve each page's HTML on its own dev URL (e.g. /guide/installation
687
743
  // or /guide/installation.html), mirroring what generateBundle emits
688
744
  // for production, so `vite dev` is multi-page too — not just the build.
745
+ //
746
+ // Note: `singleFile` only changes the *production build* output.
747
+ // The dev server keeps serving one URL per page id as usual — the
748
+ // hash-router bootstrap only exists in the emitted single-file
749
+ // production HTML — since normal multi-URL dev navigation is more
750
+ // convenient while developing.
689
751
  const entry = "/" +
690
752
  (options.entry ?? "src/main.ts")
691
753
  .replace(/\\/g, "/")
@@ -816,9 +878,9 @@ ${ctx.content}
816
878
  res.end(transformed);
817
879
  });
818
880
  },
819
- // for single bundle
881
+ // for single bundle / single file
820
882
  config() {
821
- if (!singleBundle && !removeConsole) {
883
+ if (!singleBundle && !removeConsole && !singleFile) {
822
884
  return {};
823
885
  }
824
886
  const esbuild = {};
@@ -829,7 +891,9 @@ ${ctx.content}
829
891
  esbuild.drop = ["console"];
830
892
  }
831
893
  const build = {};
832
- if (singleBundle) {
894
+ if (singleBundle || singleFile) {
895
+ // `singleFile` needs exactly one JS chunk to inline into the HTML
896
+ // shell, same requirement as `singleBundle`.
833
897
  build.rollupOptions = {
834
898
  output: {
835
899
  inlineDynamicImports: true,
@@ -889,6 +953,97 @@ ${ctx.content}
889
953
  return;
890
954
  }
891
955
  const entryJsChunk = jsChunk;
956
+ /* ------------------------------------------------------------------
957
+ * Single-file export: one `index.html` with a `#` hash router.
958
+ *
959
+ * All "component"/"markdown" pages already flow through the same
960
+ * entry chunk (thanks to `inlineDynamicImports`, forced above), so
961
+ * this just has to inline that chunk's JS and every emitted CSS
962
+ * asset straight into one HTML document instead of writing one HTML
963
+ * file per page, then remove those now-inlined files from the
964
+ * bundle so they aren't also emitted standalone.
965
+ * ------------------------------------------------------------------ */
966
+ if (singleFile) {
967
+ const skippedPages = pages.filter((page) => page.type === "liquid" || page.type === "ejs");
968
+ if (skippedPages.length > 0) {
969
+ this.warn(`[vite-plugin-pages-ssg] singleFile: skipping ${skippedPages.length} Liquid/EJS page(s) (${skippedPages
970
+ .map((page) => page.id)
971
+ .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.`);
972
+ }
973
+ const otherChunkCount = Object.values(bundle).filter((item) => item.type === "chunk" && item !== entryJsChunk).length;
974
+ if (otherChunkCount > 0) {
975
+ 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 emitted as separate files, so the build is not a true single file — check for a custom "manualChunks"/"output" config that overrides "inlineDynamicImports".`);
976
+ }
977
+ const cssParts = [];
978
+ const cssFileNames = [];
979
+ for (const [fileName, item] of Object.entries(bundle)) {
980
+ if (item.type === "asset" &&
981
+ fileName.toLowerCase().endsWith(".css")) {
982
+ const source = item.source;
983
+ cssParts.push(typeof source === "string"
984
+ ? source
985
+ : Buffer.from(source).toString("utf8"));
986
+ cssFileNames.push(fileName);
987
+ }
988
+ }
989
+ const cssContent = cssParts.join("\n");
990
+ const bundledPages = pages.filter((page) => page.type === "component" || page.type === "markdown");
991
+ const rootId = bundledPages.some((page) => page.id === "index")
992
+ ? "index"
993
+ : (bundledPages[0]?.id ?? "index");
994
+ const title = getTitle(rootId);
995
+ const head = getHead(rootId);
996
+ const bootstrapScript = singleFileBootstrapScript(rootId);
997
+ const inlineJs = escapeScriptClose(entryJsChunk.code);
998
+ let html = `<!doctype html>
999
+ <html lang="en">
1000
+ <head>
1001
+ <meta charset="UTF-8" />
1002
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
1003
+ <title>${escapeHtml(title)}</title>
1004
+ ${cssContent ? `<style>\n${cssContent}\n </style>` : ""}
1005
+ ${head}
1006
+ </head>
1007
+ <body>
1008
+ <div id="app"></div>
1009
+
1010
+ <script>
1011
+ ${bootstrapScript}
1012
+ </script>
1013
+
1014
+ <script type="module">
1015
+ ${inlineJs}
1016
+ </script>
1017
+ </body>
1018
+ </html>
1019
+ `;
1020
+ if (options.minify) {
1021
+ html = await minify(html, {
1022
+ collapseWhitespace: true,
1023
+ removeComments: true,
1024
+ removeRedundantAttributes: true,
1025
+ removeEmptyAttributes: true,
1026
+ useShortDoctype: true,
1027
+ minifyCSS: true,
1028
+ minifyJS: true,
1029
+ });
1030
+ }
1031
+ // The entry chunk and every CSS asset are now embedded directly
1032
+ // in index.html — drop them so they don't also ship as loose files.
1033
+ delete bundle[entryJsChunk.fileName];
1034
+ for (const fileName of cssFileNames) {
1035
+ delete bundle[fileName];
1036
+ }
1037
+ this.emitFile({
1038
+ type: "asset",
1039
+ fileName: "index.html",
1040
+ source: html,
1041
+ });
1042
+ if (verbose) {
1043
+ console.log(`[vite-plugin-pages-ssg] Emitted single-file build: index.html (${bundledPages.length} page(s) bundled, ${skippedPages.length} skipped)`);
1044
+ }
1045
+ return;
1046
+ }
892
1047
  for (const page of pages) {
893
1048
  const htmlFileName = outputFileName(page.id);
894
1049
  const htmlDir = path.dirname(htmlFileName);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "twynejs",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
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",