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.
- package/README.md +75 -0
- package/dist/cli.js +513 -0
- 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
|
+
}
|