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 +42 -0
- package/dist/index.js +160 -5
- package/package.json +1 -1
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 `<${inner.replace(/</g, "<")}>`;
|
|
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.
|
|
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",
|