next-single-file 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.
Files changed (3) hide show
  1. package/README.md +75 -0
  2. package/dist/cli.js +513 -0
  3. package/package.json +26 -0
package/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # Next.js Single HTML CLI ๐Ÿš€
2
+
3
+ A powerful CLI tool that transforms a Next.js static export into a **completely self-contained, single HTML file** with hash-based routing. Regex based, zero deps.
4
+ ## ๐Ÿ“– How it Works
5
+
6
+ The tool crawls your `out/` directory, extracts all routes, and bundles them into a single file. It inlines all assets (JS, CSS, Fonts, Images) as Data URIs (base64) and injects a robust hash-based router.
7
+
8
+ ### ๐Ÿ— Architecture
9
+
10
+ ```mermaid
11
+ graph TD
12
+ A[Next.js App] -->|next build| B[out/ Directory]
13
+ B -->|Parser| C[Asset Map \u0026 Routes]
14
+ C -->|Inliner| D[Data URIs \u0026 CSS/JS Bundles]
15
+ D -->|Bundler| E[Single index.html]
16
+ F[Hash Router Shim] -->|Injected| E
17
+
18
+ subgraph "Single HTML File"
19
+ E
20
+ end
21
+
22
+ E -->|Browser| G[Hash-based Navigation]
23
+ G -->|#/about| H[DOM Swap via ROUTE_MAP]
24
+ ```
25
+
26
+ ## ๐Ÿ›  Features
27
+
28
+ - **Self-Contained**: Zero external dependencies. Fonts, images, and scripts are all inlined.
29
+ - **Hash Routing**: Automatically converts path navigation to `#/hash` navigation.
30
+ - **Next.js Compatibility**: Supports latest Next.js features like Geist fonts and Turbopack.
31
+ - **Robust Escaping**: Uses Base64 encoding for the internal route map to prevent minified JS syntax errors.
32
+ - **Shims**: Automatically shims `document.currentScript` and other browser APIs that Next.js expects.
33
+
34
+ ## ๐Ÿš€ Usage
35
+
36
+ ### 1. Generate Static Export
37
+ Ensure your `next.config.js` has `output: 'export'`:
38
+
39
+ ```javascript
40
+ /** @type {import('next').NextConfig} */
41
+ const nextConfig = {
42
+ output: 'export',
43
+ };
44
+ module.exports = nextConfig;
45
+ ```
46
+
47
+ Then build:
48
+ ```bash
49
+ npm run build
50
+ # or
51
+ bun run build
52
+ ```
53
+
54
+ ### 2. Run the Inliner
55
+ ```bash
56
+ # you need bun installed (can be run by npm tho)
57
+ bunx next-single-file --input out --output dist/index.html
58
+ # or npm, needs bun installed
59
+ npx next-single-file --input out --output dist/index.html
60
+
61
+ ```
62
+
63
+ ## ๐ŸŽฏ Use Cases
64
+
65
+ - **Portable Demos**: Send a fully functional web app as a single email attachment.
66
+ - **Offline Documentation**: Create rich, interactive docs that work without an internet connection.
67
+ - **Embedded UIs**: Embed a Next.js interface into desktop applications or hardware dashboards.
68
+ - **Simple Hosting**: Host a multi-page app on GitHub Gists or any basic file server.
69
+
70
+ ## ๐Ÿงช Development
71
+
72
+ To run the tests:
73
+ ```bash
74
+ bun test
75
+ ```
package/dist/cli.js ADDED
@@ -0,0 +1,513 @@
1
+ #!/usr/bin/env bun
2
+ // @bun
3
+
4
+ // src/parser.ts
5
+ import { readdir, readFile } from "node:fs/promises";
6
+ import { join, relative, extname } from "node:path";
7
+ async function walk(dir) {
8
+ const files = [];
9
+ const entries = await readdir(dir, { withFileTypes: true });
10
+ for (const entry of entries) {
11
+ const fullPath = join(dir, entry.name);
12
+ if (entry.isDirectory()) {
13
+ files.push(...await walk(fullPath));
14
+ } else {
15
+ files.push(fullPath);
16
+ }
17
+ }
18
+ return files;
19
+ }
20
+ function htmlPathToRoute(htmlPath, baseDir) {
21
+ const relPath = relative(baseDir, htmlPath);
22
+ const route = relPath.replace(/\.html$/, "").replace(/\\/g, "/").replace(/\/index$/, "").replace(/^index$/, "/");
23
+ return route.startsWith("/") ? route : "/" + route;
24
+ }
25
+ async function findBuildId(outDir) {
26
+ try {
27
+ const staticDir = join(outDir, "_next", "static");
28
+ const entries = await readdir(staticDir, { withFileTypes: true });
29
+ const buildDir = entries.find((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "chunks" && e.name !== "media");
30
+ return buildDir?.name || "unknown";
31
+ } catch {
32
+ return "unknown";
33
+ }
34
+ }
35
+ async function parseNextOutput(outDir) {
36
+ const buildId = await findBuildId(outDir);
37
+ const allFiles = await walk(outDir);
38
+ const routes = [];
39
+ const cssFiles = new Map;
40
+ const jsFiles = new Map;
41
+ const fontFiles = new Map;
42
+ const imageFiles = new Map;
43
+ for (const file of allFiles) {
44
+ const relPath = "/" + relative(outDir, file).replace(/\\/g, "/");
45
+ const ext = extname(file).toLowerCase();
46
+ if (ext === ".html") {
47
+ const htmlContent = await readFile(file, "utf-8");
48
+ routes.push({
49
+ path: htmlPathToRoute(file, outDir),
50
+ htmlFile: file,
51
+ htmlContent
52
+ });
53
+ } else if (ext === ".css") {
54
+ cssFiles.set(relPath, await readFile(file, "utf-8"));
55
+ } else if (ext === ".js") {
56
+ jsFiles.set(relPath, await readFile(file, "utf-8"));
57
+ } else if ([".woff2", ".woff", ".ttf", ".otf"].includes(ext)) {
58
+ const buffer = await readFile(file);
59
+ fontFiles.set(relPath, buffer.buffer);
60
+ } else if ([".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".avif"].includes(ext)) {
61
+ const buffer = await readFile(file);
62
+ imageFiles.set(relPath, buffer.buffer);
63
+ }
64
+ }
65
+ return {
66
+ buildId,
67
+ routes,
68
+ cssFiles,
69
+ jsFiles,
70
+ fontFiles,
71
+ imageFiles
72
+ };
73
+ }
74
+
75
+ // src/inliner.ts
76
+ function extractHeadContent(html) {
77
+ const match = html.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
78
+ return match && match[1] ? match[1] : "";
79
+ }
80
+ function extractBodyContent(html) {
81
+ const match = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
82
+ return match && match[1] ? match[1] : "";
83
+ }
84
+ function toDataUri(data, path) {
85
+ const ext = path.split(".").pop()?.toLowerCase();
86
+ let mime = "application/octet-stream";
87
+ if (ext === "svg")
88
+ mime = "image/svg+xml";
89
+ else if (ext === "png")
90
+ mime = "image/png";
91
+ else if (ext === "jpg" || ext === "jpeg")
92
+ mime = "image/jpeg";
93
+ else if (ext === "webp")
94
+ mime = "image/webp";
95
+ else if (ext === "ico")
96
+ mime = "image/x-icon";
97
+ else if (ext === "woff2")
98
+ mime = "font/woff2";
99
+ else if (ext === "woff")
100
+ mime = "font/woff";
101
+ else if (ext === "ttf")
102
+ mime = "font/ttf";
103
+ else if (ext === "otf")
104
+ mime = "font/otf";
105
+ else if (ext === "js")
106
+ mime = "application/javascript";
107
+ else if (ext === "css")
108
+ mime = "text/css";
109
+ const base64 = typeof data === "string" ? Buffer.from(data, "utf-8").toString("base64") : Buffer.from(data).toString("base64");
110
+ return `data:${mime};base64,${base64}`;
111
+ }
112
+ function extractStyles(html) {
113
+ const styleRegex = /<style[^>]*>([\s\S]*?)<\/style>/gi;
114
+ let styles = "";
115
+ let match;
116
+ while ((match = styleRegex.exec(html)) !== null) {
117
+ styles += match[1] + `
118
+ `;
119
+ }
120
+ return styles;
121
+ }
122
+ async function inlineAssets(parsed) {
123
+ const assetMap = new Map;
124
+ for (const [path, buffer] of parsed.fontFiles) {
125
+ assetMap.set(path, toDataUri(buffer, path));
126
+ }
127
+ for (const [path, buffer] of parsed.imageFiles) {
128
+ assetMap.set(path, toDataUri(buffer, path));
129
+ }
130
+ for (const [path, content] of parsed.cssFiles) {
131
+ assetMap.set(path, toDataUri(content, path));
132
+ }
133
+ for (const [path, content] of parsed.jsFiles) {
134
+ assetMap.set(path, toDataUri(content, path));
135
+ }
136
+ const sortedPaths = Array.from(assetMap.keys()).sort((a, b) => b.length - a.length);
137
+ function inlineEverything(content) {
138
+ let result = content;
139
+ for (const path of sortedPaths) {
140
+ const dataUri = assetMap.get(path);
141
+ const fileName = path.split("/").pop();
142
+ const escapedPath = path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
143
+ const pathPattern = new RegExp(`(?:\\\\?["'\\(\\s,])(?:https?:\\/\\/[^/]+)?${escapedPath}(?:\\?[^\\\\"'\\)\\s,]+)?(?:\\\\?["'\\)\\s,])`, "g");
144
+ result = result.replace(pathPattern, (match) => {
145
+ const prefixMatch = match.match(/^(\\?["'\\(\\s,])/);
146
+ const suffixMatch = match.match(/(\\?["'\\)\\s,])$/);
147
+ const prefix = prefixMatch ? prefixMatch[0] : "";
148
+ const suffix = suffixMatch ? suffixMatch[0] : "";
149
+ return `${prefix}${dataUri}${suffix}`;
150
+ });
151
+ if (path.startsWith("/")) {
152
+ const noSlashPath = path.slice(1).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
153
+ const noSlashPattern = new RegExp(`(?:\\\\?["'\\(\\s,])(?:https?:\\/\\/[^/]+)?${noSlashPath}(?:\\?[^\\\\"'\\)\\s,]+)?(?:\\\\?["'\\)\\s,])`, "g");
154
+ result = result.replace(noSlashPattern, (match) => {
155
+ const prefixMatch = match.match(/^(\\?["'\\(\\s,])/);
156
+ const suffixMatch = match.match(/(\\?["'\\)\\s,])$/);
157
+ const prefix = prefixMatch ? prefixMatch[0] : "";
158
+ const suffix = suffixMatch ? suffixMatch[0] : "";
159
+ return `${prefix}${dataUri}${suffix}`;
160
+ });
161
+ }
162
+ if (fileName) {
163
+ const escapedFileName = fileName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
164
+ const relPattern = new RegExp(`(?:\\\\?["'\\(\\s])(?:\\.\\.?\\/|\\/_next\\/static\\/)?(?:media|chunks|css)\\/${escapedFileName}(?:\\?[^\\\\"'\\)\\s]+)?(?:\\\\?["'\\)\\s])`, "g");
165
+ result = result.replace(relPattern, (match) => {
166
+ const prefixMatch = match.match(/^(\\?["'\\(\\s])/);
167
+ const suffixMatch = match.match(/(\\?["'\\)\\s])$/);
168
+ const prefix = prefixMatch ? prefixMatch[0] : "";
169
+ const suffix = suffixMatch ? suffixMatch[0] : "";
170
+ return `${prefix}${dataUri}${suffix}`;
171
+ });
172
+ }
173
+ }
174
+ return result;
175
+ }
176
+ const processedCssFiles = new Map;
177
+ for (const [path, content] of parsed.cssFiles) {
178
+ processedCssFiles.set(path, inlineEverything(content));
179
+ }
180
+ const processedJsFiles = new Map;
181
+ for (const [path, content] of parsed.jsFiles) {
182
+ processedJsFiles.set(path, inlineEverything(content));
183
+ }
184
+ let inlinedCss = Array.from(processedCssFiles.values()).join(`
185
+ `);
186
+ const inlinedJs = Array.from(processedJsFiles.values()).join(`
187
+ `);
188
+ const routes = parsed.routes.map((route) => {
189
+ let inlinedHtml = inlineEverything(route.htmlContent);
190
+ const routeStyles = extractStyles(inlinedHtml);
191
+ inlinedCss += `
192
+ ` + routeStyles;
193
+ inlinedHtml = inlinedHtml.replace(/<link[^>]+rel=["']preload["'][^>]*>/gi, "");
194
+ return {
195
+ path: route.path,
196
+ headContent: extractHeadContent(inlinedHtml),
197
+ bodyContent: extractBodyContent(inlinedHtml),
198
+ htmlContent: inlinedHtml
199
+ };
200
+ });
201
+ return {
202
+ buildId: parsed.buildId,
203
+ routes,
204
+ inlinedCss,
205
+ inlinedJs,
206
+ allInlinedFiles: assetMap
207
+ };
208
+ }
209
+
210
+ // src/router.ts
211
+ function generateRouterShim(routes) {
212
+ const routeMap = {};
213
+ for (const route of routes) {
214
+ const cleanPath = route.path === "/" ? "/" : route.path.replace(/\/$/, "");
215
+ routeMap[cleanPath] = {
216
+ head: route.headContent,
217
+ body: route.bodyContent
218
+ };
219
+ }
220
+ const routeMapJson = JSON.stringify(routeMap);
221
+ const routeMapBase64 = Buffer.from(routeMapJson).toString("base64");
222
+ return `
223
+ (function() {
224
+ const ROUTE_MAP_BASE64 = "${routeMapBase64}";
225
+ let ROUTE_MAP = {};
226
+
227
+ function b64DecodeUnicode(str) {
228
+ return decodeURIComponent(atob(str).split('').map(function(c) {
229
+ return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
230
+ }).join(''));
231
+ }
232
+
233
+ try {
234
+ ROUTE_MAP = JSON.parse(b64DecodeUnicode(ROUTE_MAP_BASE64));
235
+ } catch (e) {
236
+ console.error("Failed to parse ROUTE_MAP", e);
237
+ try {
238
+ ROUTE_MAP = JSON.parse(window.atob(ROUTE_MAP_BASE64));
239
+ } catch (e2) {
240
+ console.error("Fallback parsing also failed", e2);
241
+ }
242
+ }
243
+
244
+ let currentRoute = '/';
245
+ let isInitialized = false;
246
+
247
+ // Intercept Next.js chunk loading to prevent network requests
248
+ if (typeof window !== 'undefined') {
249
+ window.__NEXT_DATA__ = window.__NEXT_DATA__ || { props: { pageProps: {} }, page: "/", query: {}, buildId: "single-file" };
250
+ // Disable automatic prefetching
251
+ window.__NEXT_P = window.__NEXT_P || [];
252
+ }
253
+
254
+ function getHashPath() {
255
+ let hash = window.location.hash.slice(1);
256
+ if (!hash || hash === '') hash = '/';
257
+ if (!hash.startsWith('/')) hash = '/' + hash;
258
+ return hash;
259
+ }
260
+
261
+ function normalizePath(path) {
262
+ if (!path) return '/';
263
+ if (path === '/') return '/';
264
+ return path.endsWith('/') ? path.slice(0, -1) : path;
265
+ }
266
+
267
+ function renderRoute(path, skipIfSame) {
268
+ path = normalizePath(path);
269
+ if (skipIfSame && path === currentRoute) return;
270
+
271
+ const route = ROUTE_MAP[path];
272
+
273
+ if (!route || !route.body) {
274
+ console.warn('Route not found or empty:', path, '- available routes:', Object.keys(ROUTE_MAP));
275
+ const notFoundRoute = ROUTE_MAP['/404'] || ROUTE_MAP['/_not-found'];
276
+ if (notFoundRoute && notFoundRoute.body) {
277
+ const rootEl = document.getElementById('__next') || document.body;
278
+ rootEl.innerHTML = notFoundRoute.body;
279
+ }
280
+ return;
281
+ }
282
+
283
+ // Update title
284
+ if (route.head) {
285
+ const titleMatch = route.head.match(/<title[^>]*>([\\s\\S]*?)<\\/title>/i);
286
+ if (titleMatch && titleMatch[1]) {
287
+ document.title = titleMatch[1];
288
+ }
289
+ }
290
+
291
+ const rootEl = document.getElementById('__next') || document.body;
292
+ rootEl.innerHTML = route.body;
293
+ currentRoute = path;
294
+
295
+ // Re-execute scripts
296
+ const scripts = rootEl.querySelectorAll('script');
297
+ scripts.forEach(oldScript => {
298
+ const newScript = document.createElement('script');
299
+ Array.from(oldScript.attributes).forEach(attr => newScript.setAttribute(attr.name, attr.value));
300
+ if (oldScript.src) {
301
+ newScript.src = oldScript.src;
302
+ } else {
303
+ newScript.textContent = oldScript.textContent;
304
+ }
305
+ oldScript.parentNode.replaceChild(newScript, oldScript);
306
+ });
307
+
308
+ window.scrollTo(0, 0);
309
+ document.dispatchEvent(new CustomEvent('routeChange', { detail: { path } }));
310
+ }
311
+
312
+ // Intercept pushState/replaceState
313
+ const origPush = history.pushState.bind(history);
314
+ const origReplace = history.replaceState.bind(history);
315
+
316
+ history.pushState = function(state, title, url) {
317
+ if (url && typeof url === 'string' && !url.startsWith('#') && !url.startsWith('http')) {
318
+ window.location.hash = url;
319
+ return;
320
+ }
321
+ return origPush(state, title, url);
322
+ };
323
+
324
+ history.replaceState = function(state, title, url) {
325
+ if (url && typeof url === 'string' && !url.startsWith('#') && !url.startsWith('http')) {
326
+ window.location.hash = url;
327
+ return;
328
+ }
329
+ return origReplace(state, title, url);
330
+ };
331
+
332
+ document.addEventListener('click', (e) => {
333
+ let target = e.target;
334
+ while (target && target !== document) {
335
+ if (target.tagName === 'A') break;
336
+ target = target.parentElement;
337
+ }
338
+
339
+ if (target && target.tagName === 'A') {
340
+ const href = target.getAttribute('href');
341
+ if (href && href.startsWith('/') && !href.startsWith('//')) {
342
+ const isExternal = target.target === '_blank' || target.hasAttribute('download');
343
+ if (!isExternal) {
344
+ e.preventDefault();
345
+ window.location.hash = href;
346
+ }
347
+ }
348
+ }
349
+ }, true);
350
+
351
+ window.addEventListener('hashchange', () => {
352
+ const path = getHashPath();
353
+ renderRoute(path, false);
354
+ });
355
+
356
+ function init() {
357
+ if (isInitialized) return;
358
+ isInitialized = true;
359
+ const initialPath = getHashPath();
360
+ if (initialPath !== '/') {
361
+ renderRoute(initialPath, false);
362
+ }
363
+ }
364
+
365
+ if (document.readyState === 'loading') {
366
+ document.addEventListener('DOMContentLoaded', init);
367
+ } else {
368
+ setTimeout(init, 0);
369
+ }
370
+
371
+ window.__NEXT_SINGLE_FILE_ROUTER__ = {
372
+ navigate: (path) => {
373
+ window.location.hash = path;
374
+ },
375
+ getCurrentRoute: () => currentRoute,
376
+ getRouteMap: () => ROUTE_MAP,
377
+ renderRoute: renderRoute,
378
+ };
379
+ })();
380
+ `;
381
+ }
382
+
383
+ // src/bundler.ts
384
+ function extractHtmlAttrs(html) {
385
+ const htmlMatch = html.match(/<html([^>]*)>/i);
386
+ return htmlMatch && htmlMatch[1] ? htmlMatch[1] : ' lang="en"';
387
+ }
388
+ function extractBodyAttrs(html) {
389
+ const bodyMatch = html.match(/<body([^>]*)>/i);
390
+ return bodyMatch && bodyMatch[1] ? bodyMatch[1] : "";
391
+ }
392
+ function extractTitle(html) {
393
+ const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
394
+ return titleMatch && titleMatch[1] ? titleMatch[1] : "App";
395
+ }
396
+ function extractMeta(html) {
397
+ const headMatch = html.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
398
+ if (!headMatch)
399
+ return "";
400
+ const head = headMatch[1] || "";
401
+ const metaTags = [];
402
+ const metaRegex = /<meta[^>]*>/gi;
403
+ let match;
404
+ while ((match = metaRegex.exec(head)) !== null) {
405
+ if (match[0].includes('name="viewport"'))
406
+ continue;
407
+ metaTags.push(match[0]);
408
+ }
409
+ return metaTags.join(`
410
+ `);
411
+ }
412
+ function escapeScriptTag(js) {
413
+ return js.replace(/<\/script>/gi, "<\\/script>");
414
+ }
415
+ function bundleToSingleHtml(inlined, routerShim) {
416
+ const indexRoute = inlined.routes.find((r) => r.path === "/");
417
+ if (!indexRoute) {
418
+ throw new Error("No index route found");
419
+ }
420
+ const anyRoute = inlined.routes[0];
421
+ if (!anyRoute)
422
+ throw new Error("No routes found");
423
+ const htmlAttrs = extractHtmlAttrs(indexRoute.htmlContent);
424
+ const bodyAttrs = extractBodyAttrs(indexRoute.htmlContent);
425
+ const title = extractTitle(indexRoute.headContent);
426
+ const meta = extractMeta(indexRoute.headContent);
427
+ const runtimeShims = `
428
+ // SHIM: Next.js/Turbopack runtime fix
429
+ if (typeof document !== 'undefined') {
430
+ if (!document.currentScript) {
431
+ const scripts = document.getElementsByTagName('script');
432
+ Object.defineProperty(document, 'currentScript', {
433
+ get: function() { return scripts[scripts.length - 1] || null; },
434
+ configurable: true
435
+ });
436
+ }
437
+
438
+ const origGetAttr = HTMLElement.prototype.getAttribute;
439
+ HTMLElement.prototype.getAttribute = function(name) {
440
+ const val = origGetAttr.apply(this, arguments);
441
+ if (name === 'src' && val === null && this.tagName === 'SCRIPT') {
442
+ return '';
443
+ }
444
+ return val;
445
+ };
446
+ }
447
+ `;
448
+ return `<!DOCTYPE html><!--${inlined.buildId}-->
449
+ <html${htmlAttrs}>
450
+ <head>
451
+ <meta charset="utf-8">
452
+ <script>${runtimeShims}</script>
453
+ <meta name="viewport" content="width=device-width, initial-scale=1">
454
+ ${meta}
455
+ <title>${title}</title>
456
+ <style>
457
+ ${inlined.inlinedCss}
458
+ </style>
459
+ <script>
460
+ /* NEXT_JS_CHUNKS_START */
461
+ ${escapeScriptTag(inlined.inlinedJs)}
462
+ /* NEXT_JS_CHUNKS_END */
463
+ </script>
464
+ </head>
465
+ <body${bodyAttrs}>
466
+ <div id="__next">
467
+ ${indexRoute.bodyContent}
468
+ </div>
469
+ <script>
470
+ /* ROUTER_SHIM_START */
471
+ ${escapeScriptTag(routerShim)}
472
+ /* ROUTER_SHIM_END */
473
+ </script>
474
+ </body>
475
+ </html>`;
476
+ }
477
+
478
+ // index.ts
479
+ var {$ } = globalThis.Bun;
480
+ function parseArgs() {
481
+ const args = process.argv.slice(2);
482
+ let inputDir = "out";
483
+ let outputFile = "dist/index.html";
484
+ for (let i = 0;i < args.length; i++) {
485
+ const arg = args[i];
486
+ if (arg === "--input" || arg === "-i") {
487
+ inputDir = args[++i] || inputDir;
488
+ } else if (arg === "--output" || arg === "-o") {
489
+ outputFile = args[++i] || outputFile;
490
+ } else if (arg && !arg.startsWith("-")) {
491
+ if (inputDir === "out") {
492
+ inputDir = arg;
493
+ } else {
494
+ outputFile = arg;
495
+ }
496
+ }
497
+ }
498
+ return { inputDir, outputFile };
499
+ }
500
+ var { inputDir, outputFile } = parseArgs();
501
+ console.log(`\uD83D\uDCD6 Parsing Next.js output from: ${inputDir}`);
502
+ var parsed = await parseNextOutput(inputDir);
503
+ console.log(`\uD83D\uDCE6 Found ${parsed.routes.length} routes:`, parsed.routes.map((r) => r.path));
504
+ console.log(`\uD83D\uDD27 Inlining assets...`);
505
+ var inlined = await inlineAssets(parsed);
506
+ console.log(`\uD83D\uDD00 Generating hash router...`);
507
+ var routerShim = generateRouterShim(inlined.routes);
508
+ console.log(`\uD83D\uDCDD Bundling to single HTML...`);
509
+ var html = bundleToSingleHtml(inlined, routerShim);
510
+ await $`mkdir -p ${outputFile.split("/").slice(0, -1).join("/") || "."}`.quiet();
511
+ await Bun.write(outputFile, html);
512
+ console.log(`\u2705 Done! Output: ${outputFile}`);
513
+ console.log(` Size: ${(html.length / 1024).toFixed(1)} KB`);
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "next-single-file",
3
+ "version": "1.0.0",
4
+ "description": "Convert Next.js static export to a single HTML file with hash routing",
5
+ "bin": {
6
+ "next-single-file": "./dist/cli.js"
7
+ },
8
+ "files": [
9
+ "dist/cli.js"
10
+ ],
11
+ "scripts": {
12
+ "build": "bun build ./index.ts --target node --outfile ./dist/cli.js && chmod +x ./dist/cli.js",
13
+ "test": "bun test",
14
+ "convert": "bun run ./index.ts"
15
+ },
16
+ "type": "module",
17
+ "workspaces": [
18
+ "test-next-app"
19
+ ],
20
+ "devDependencies": {
21
+ "@types/bun": "latest"
22
+ },
23
+ "peerDependencies": {
24
+ "typescript": "^5"
25
+ }
26
+ }