thunderous-server 0.0.3 → 0.0.4
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.cjs +7 -2
- package/dist/index.d.cts +3 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +5 -1
- package/package.json +3 -9
- package/src/cli.ts +22 -39
- package/src/dev.ts +13 -136
- package/src/generate.ts +86 -33
- package/src/index.ts +1 -1
- package/src/vendorize.ts +8 -1
- package/src/vite-plugin.ts +202 -0
package/dist/index.cjs
CHANGED
|
@@ -22,7 +22,8 @@ var index_exports = {};
|
|
|
22
22
|
__export(index_exports, {
|
|
23
23
|
escapeHtml: () => escapeHtml,
|
|
24
24
|
getMeta: () => getMeta,
|
|
25
|
-
raw: () => raw
|
|
25
|
+
raw: () => raw,
|
|
26
|
+
setMeta: () => setMeta
|
|
26
27
|
});
|
|
27
28
|
module.exports = __toCommonJS(index_exports);
|
|
28
29
|
|
|
@@ -40,6 +41,9 @@ var metaState = {
|
|
|
40
41
|
name: "",
|
|
41
42
|
filename: ""
|
|
42
43
|
};
|
|
44
|
+
var setMeta = (meta) => {
|
|
45
|
+
Object.assign(metaState, meta);
|
|
46
|
+
};
|
|
43
47
|
var getMeta = () => {
|
|
44
48
|
return Object.freeze({
|
|
45
49
|
...metaState,
|
|
@@ -66,5 +70,6 @@ var raw = (str) => new RawHtml(str);
|
|
|
66
70
|
0 && (module.exports = {
|
|
67
71
|
escapeHtml,
|
|
68
72
|
getMeta,
|
|
69
|
-
raw
|
|
73
|
+
raw,
|
|
74
|
+
setMeta
|
|
70
75
|
});
|
package/dist/index.d.cts
CHANGED
|
@@ -69,6 +69,8 @@ type Meta = {
|
|
|
69
69
|
*/
|
|
70
70
|
filename: string;
|
|
71
71
|
};
|
|
72
|
+
/** Set metadata context for the current page being rendered. */
|
|
73
|
+
declare const setMeta: (meta: Partial<Meta>) => void;
|
|
72
74
|
/** Get metadata about the current page being rendered. */
|
|
73
75
|
declare const getMeta: () => Meta;
|
|
74
76
|
|
|
@@ -86,4 +88,4 @@ declare class RawHtml {
|
|
|
86
88
|
*/
|
|
87
89
|
declare const raw: (str: string) => RawHtml;
|
|
88
90
|
|
|
89
|
-
export { escapeHtml, getMeta, raw };
|
|
91
|
+
export { escapeHtml, getMeta, raw, setMeta };
|
package/dist/index.d.ts
CHANGED
|
@@ -69,6 +69,8 @@ type Meta = {
|
|
|
69
69
|
*/
|
|
70
70
|
filename: string;
|
|
71
71
|
};
|
|
72
|
+
/** Set metadata context for the current page being rendered. */
|
|
73
|
+
declare const setMeta: (meta: Partial<Meta>) => void;
|
|
72
74
|
/** Get metadata about the current page being rendered. */
|
|
73
75
|
declare const getMeta: () => Meta;
|
|
74
76
|
|
|
@@ -86,4 +88,4 @@ declare class RawHtml {
|
|
|
86
88
|
*/
|
|
87
89
|
declare const raw: (str: string) => RawHtml;
|
|
88
90
|
|
|
89
|
-
export { escapeHtml, getMeta, raw };
|
|
91
|
+
export { escapeHtml, getMeta, raw, setMeta };
|
package/dist/index.js
CHANGED
|
@@ -12,6 +12,9 @@ var metaState = {
|
|
|
12
12
|
name: "",
|
|
13
13
|
filename: ""
|
|
14
14
|
};
|
|
15
|
+
var setMeta = (meta) => {
|
|
16
|
+
Object.assign(metaState, meta);
|
|
17
|
+
};
|
|
15
18
|
var getMeta = () => {
|
|
16
19
|
return Object.freeze({
|
|
17
20
|
...metaState,
|
|
@@ -37,5 +40,6 @@ var raw = (str) => new RawHtml(str);
|
|
|
37
40
|
export {
|
|
38
41
|
escapeHtml,
|
|
39
42
|
getMeta,
|
|
40
|
-
raw
|
|
43
|
+
raw,
|
|
44
|
+
setMeta
|
|
41
45
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thunderous-server",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.4",
|
|
4
4
|
"description": "A simple server to enhance Thunderous components with SSR.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Jonathan DeWitt <jon.dewitt@thunder.solutions>",
|
|
@@ -24,9 +24,6 @@
|
|
|
24
24
|
"@eslint/js": "^9.38.0",
|
|
25
25
|
"@eslint/json": "^0.13.2",
|
|
26
26
|
"@total-typescript/ts-reset": "^0.6.1",
|
|
27
|
-
"@types/connect-livereload": "^0.6.3",
|
|
28
|
-
"@types/express": "^5.0.3",
|
|
29
|
-
"@types/livereload": "^0.9.5",
|
|
30
27
|
"@types/node": "^24.9.1",
|
|
31
28
|
"@types/resolve": "^1.20.6",
|
|
32
29
|
"@typescript-eslint/eslint-plugin": "^8.46.2",
|
|
@@ -40,16 +37,13 @@
|
|
|
40
37
|
},
|
|
41
38
|
"dependencies": {
|
|
42
39
|
"chalk": "^5.6.2",
|
|
43
|
-
"connect-livereload": "^0.6.1",
|
|
44
40
|
"emoji-space-shim": "^0.1.7",
|
|
45
41
|
"es-module-lexer": "^1.7.0",
|
|
46
|
-
"express": "^5.1.0",
|
|
47
|
-
"livereload": "^0.10.3",
|
|
48
42
|
"node-emoji": "^2.2.0",
|
|
49
|
-
"nodemon": "^3.1.14",
|
|
50
43
|
"resolve": "^1.22.11",
|
|
51
44
|
"resolve.exports": "^2.0.3",
|
|
52
|
-
"string-width": "^8.2.0"
|
|
45
|
+
"string-width": "^8.2.0",
|
|
46
|
+
"vite": "^6.3.5"
|
|
53
47
|
},
|
|
54
48
|
"peerDependencies": {
|
|
55
49
|
"thunderous": ">=2.4.2"
|
package/src/cli.ts
CHANGED
|
@@ -1,48 +1,31 @@
|
|
|
1
1
|
import { build } from './build';
|
|
2
|
-
import nodemon from 'nodemon';
|
|
3
|
-
import { existsSync, readFileSync } from 'fs';
|
|
4
|
-
import { config } from './config';
|
|
5
|
-
import { relative, resolve } from 'path';
|
|
6
|
-
import chalk from 'chalk';
|
|
7
2
|
|
|
8
3
|
const args = process.argv.slice(2);
|
|
9
4
|
|
|
10
5
|
if (args[0] === 'dev') {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
exec: 'tsx',
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
nodemon
|
|
28
|
-
.on('start', () => {
|
|
29
|
-
// console.log('App has started');
|
|
30
|
-
})
|
|
31
|
-
.on('quit', () => {
|
|
32
|
-
console.log(chalk.green('\nServer shut down successfully.\n'));
|
|
33
|
-
process.exit();
|
|
34
|
-
})
|
|
35
|
-
.on('restart', (files) => {
|
|
36
|
-
const filesList = (files ?? ['(none)']).map(
|
|
37
|
-
(file) => ` • ${chalk.cyan.underline(relative(config.configDir ?? process.cwd(), file))}\n`,
|
|
38
|
-
);
|
|
39
|
-
console.log('\nUpdates detected:\n', filesList.join(''));
|
|
40
|
-
});
|
|
6
|
+
// Parse port from command line arguments
|
|
7
|
+
process.env.PORT = process.env.PORT ?? '3000';
|
|
8
|
+
args.forEach((arg, i) => {
|
|
9
|
+
if (arg.startsWith('--port')) {
|
|
10
|
+
let port: string | undefined;
|
|
11
|
+
if (arg.includes('=')) {
|
|
12
|
+
port = arg.split('=')[1];
|
|
13
|
+
} else {
|
|
14
|
+
port = args[i + 1];
|
|
15
|
+
}
|
|
16
|
+
if (port !== '' && port !== undefined) {
|
|
17
|
+
process.env.PORT = port;
|
|
18
|
+
}
|
|
41
19
|
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Vite handles HMR, port finding, and file watching
|
|
23
|
+
import('./dev')
|
|
24
|
+
.then(({ dev }) => dev())
|
|
25
|
+
.catch((error) => {
|
|
26
|
+
console.error('\x1b[31mFailed to start server:\x1b[0m', error);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
});
|
|
46
29
|
} else if (args[0] === 'build') {
|
|
47
30
|
try {
|
|
48
31
|
build();
|
package/src/dev.ts
CHANGED
|
@@ -1,140 +1,17 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
import { join, relative, resolve } from 'path';
|
|
4
|
-
import { tmpdir } from 'os';
|
|
5
|
-
import { ModuleKind, ScriptTarget, transpileModule, ImportsNotUsedAsValues } from 'typescript';
|
|
6
|
-
import { bootstrapThunderous, generateStaticTemplate, generateImportMap, injectImportMap } from './generate';
|
|
1
|
+
import { createServer } from 'vite';
|
|
2
|
+
import { resolve } from 'path';
|
|
7
3
|
import { config } from './config';
|
|
8
|
-
import
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const dirPath = resolve(dir);
|
|
18
|
-
const files = readdirSync(dirPath);
|
|
19
|
-
|
|
20
|
-
for (const file of files) {
|
|
21
|
-
const filePath = join(dirPath, file);
|
|
22
|
-
if (file.endsWith('.html') && !file.startsWith('_')) {
|
|
23
|
-
const basePath = relative(config.baseDir, dirPath);
|
|
24
|
-
const path = `/${basePath}${file === 'index.html' ? '' : `/${file.replace('.html', '')}`}`;
|
|
25
|
-
console.log(`\x1b[90mFound path: ${path}\x1b[0m`);
|
|
26
|
-
|
|
27
|
-
app.get(path, (_, res) => {
|
|
28
|
-
console.log(`\x1b[90mServing file: ${basePath}/${file}\x1b[0m`);
|
|
29
|
-
|
|
30
|
-
// Process the HTML file and extract client entry files
|
|
31
|
-
const result = generateStaticTemplate(filePath);
|
|
32
|
-
let markup = result.markup;
|
|
33
|
-
|
|
34
|
-
// Generate import map if there are client entry files
|
|
35
|
-
if (result.clientEntryFiles.length > 0) {
|
|
36
|
-
const importMapJson = generateImportMap(result.clientEntryFiles, vendorDir);
|
|
37
|
-
markup = injectImportMap(markup, importMapJson);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// Clean up temporary files
|
|
41
|
-
result.cleanup();
|
|
42
|
-
|
|
43
|
-
res.send(markup);
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
|
-
if (statSync(filePath).isDirectory()) {
|
|
47
|
-
bootstrapRoutes(filePath, app, vendorDir);
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
export const dev = () => {
|
|
53
|
-
console.log('\n\x1b[36m\x1b[1m⚡⚡ Starting development server... ⚡⚡\x1b[0m\x1b[0m\n');
|
|
54
|
-
|
|
55
|
-
const PORT = process.env.PORT ?? 3000;
|
|
56
|
-
|
|
57
|
-
// Set up simple express server
|
|
58
|
-
const app = express();
|
|
59
|
-
|
|
60
|
-
// Set up live reload to watch for changes
|
|
61
|
-
const liveReloadServer = livereload.createServer();
|
|
62
|
-
liveReloadServer.watch(`${process.cwd()}/${config.baseDir}`);
|
|
63
|
-
app.use(connectLiveReload());
|
|
64
|
-
app.use((_, res, next) => {
|
|
65
|
-
const originalSend = res.send;
|
|
66
|
-
|
|
67
|
-
// Inject live reload script into HTML responses
|
|
68
|
-
res.send = function (body) {
|
|
69
|
-
if (typeof body === 'string' && body.includes('</body>')) {
|
|
70
|
-
const liveReloadScript = '<script src="http://localhost:35729/livereload.js"></script>';
|
|
71
|
-
body = body.replace('</body>', `${liveReloadScript}</body>`);
|
|
72
|
-
}
|
|
73
|
-
return originalSend.call(this, body);
|
|
74
|
-
};
|
|
75
|
-
next();
|
|
4
|
+
import { thunderousPlugin } from './vite-plugin';
|
|
5
|
+
|
|
6
|
+
export const dev = async () => {
|
|
7
|
+
const server = await createServer({
|
|
8
|
+
root: resolve(config.baseDir),
|
|
9
|
+
plugins: [thunderousPlugin()],
|
|
10
|
+
server: {
|
|
11
|
+
port: Number(process.env.PORT ?? '3000'),
|
|
12
|
+
},
|
|
76
13
|
});
|
|
77
14
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
console.log(`\x1b[90mUsing temporary vendor directory: ${vendorDir}\x1b[0m`);
|
|
81
|
-
|
|
82
|
-
bootstrapThunderous();
|
|
83
|
-
bootstrapRoutes(`./${config.baseDir}`, app, vendorDir);
|
|
84
|
-
|
|
85
|
-
// Serve .js requests by transpiling the corresponding .ts source on-the-fly
|
|
86
|
-
app.use((req, res, next) => {
|
|
87
|
-
if (!req.path.endsWith('.js')) return next();
|
|
88
|
-
const tsPath = join(config.baseDir, req.path.replace(/\.js$/, '.ts'));
|
|
89
|
-
const tsxPath = join(config.baseDir, req.path.replace(/\.js$/, '.tsx'));
|
|
90
|
-
const srcPath = existsSync(tsPath) ? tsPath : existsSync(tsxPath) ? tsxPath : null;
|
|
91
|
-
if (srcPath === null) return next();
|
|
92
|
-
const source = readFileSync(srcPath, 'utf-8');
|
|
93
|
-
const { outputText } = transpileModule(source, {
|
|
94
|
-
compilerOptions: {
|
|
95
|
-
module: ModuleKind.ESNext,
|
|
96
|
-
target: ScriptTarget.ES2020,
|
|
97
|
-
sourceMap: false,
|
|
98
|
-
importsNotUsedAsValues: ImportsNotUsedAsValues.Remove,
|
|
99
|
-
verbatimModuleSyntax: true,
|
|
100
|
-
isolatedModules: true,
|
|
101
|
-
},
|
|
102
|
-
fileName: srcPath,
|
|
103
|
-
reportDiagnostics: false,
|
|
104
|
-
});
|
|
105
|
-
res.type('application/javascript').send(outputText);
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
// Serve static assets from the base directory
|
|
109
|
-
app.use(express.static(config.baseDir));
|
|
110
|
-
|
|
111
|
-
// Serve vendorized modules
|
|
112
|
-
app.use('/vendor', express.static(join(vendorDir, 'vendor')));
|
|
113
|
-
|
|
114
|
-
const server = app.listen(PORT, () => {
|
|
115
|
-
console.log(`\n\x1b[38;2;100;149;237mServer is running on http://localhost:${PORT}\x1b[0m\n`);
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
// Close everything when server is closed
|
|
119
|
-
server.once('close', () => {
|
|
120
|
-
liveReloadServer.close();
|
|
121
|
-
server.closeAllConnections?.();
|
|
122
|
-
console.log('\x1b[32m\nAll connections closed successfully.\x1b[0m\n\n');
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
// Handle graceful shutdown when user cancels the process
|
|
126
|
-
process.once('SIGINT', () => {
|
|
127
|
-
console.log('\n\x1b[90mShutting down gracefully...\x1b[0m');
|
|
128
|
-
server.close((error) => {
|
|
129
|
-
if (error === undefined) {
|
|
130
|
-
process.exitCode = 0;
|
|
131
|
-
} else {
|
|
132
|
-
console.error('Error closing server:', error);
|
|
133
|
-
process.exitCode = 1;
|
|
134
|
-
}
|
|
135
|
-
});
|
|
136
|
-
});
|
|
15
|
+
await server.listen();
|
|
16
|
+
server.printUrls();
|
|
137
17
|
};
|
|
138
|
-
|
|
139
|
-
// start directly, since this file is targeted directly by nodemon
|
|
140
|
-
dev();
|
package/src/generate.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, wri
|
|
|
2
2
|
import { join, relative, resolve } from 'path';
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
4
|
import { html } from 'thunderous';
|
|
5
|
-
import {
|
|
5
|
+
import { type Breadcrumb } from './meta';
|
|
6
6
|
import { basename, dirname, extname } from 'node:path';
|
|
7
7
|
import { config } from './config';
|
|
8
8
|
import { ModuleKind, ScriptTarget, transpileModule, ImportsNotUsedAsValues } from 'typescript';
|
|
@@ -51,7 +51,7 @@ export const bootstrapThunderous = () => {
|
|
|
51
51
|
innerHTML.replace(/\s+/gm, ' ').replace(/ >/g, '>'),
|
|
52
52
|
renderState.markup,
|
|
53
53
|
);
|
|
54
|
-
console.log(`\x1b[
|
|
54
|
+
console.log(`\x1b[90m |--- Inserted SSR template for <${tagName}> into markup.\x1b[0m`);
|
|
55
55
|
});
|
|
56
56
|
};
|
|
57
57
|
|
|
@@ -90,7 +90,7 @@ export const processFiles = (args: ProcessFilesArgs) => {
|
|
|
90
90
|
};
|
|
91
91
|
|
|
92
92
|
type ScriptKind = 'expr' | 'server' | 'isomorphic' | 'module';
|
|
93
|
-
type ParsedScript = { kind: ScriptKind; content: string;
|
|
93
|
+
type ParsedScript = { kind: ScriptKind; content: string; src?: string | undefined; start: number; end: number };
|
|
94
94
|
type ParsedLayout = { href: string; start: number; end: number };
|
|
95
95
|
|
|
96
96
|
// ── Cached resolved paths (computed once at module load) ──
|
|
@@ -150,9 +150,9 @@ const extractTags = (markup: string) => {
|
|
|
150
150
|
else if (/\bisomorphic\b/.test(attrs)) kind = 'isomorphic';
|
|
151
151
|
else if (/\btype\s*=\s*"module"/.test(attrs)) kind = 'module';
|
|
152
152
|
|
|
153
|
-
let
|
|
154
|
-
const
|
|
155
|
-
if (
|
|
153
|
+
let src: string | undefined;
|
|
154
|
+
const srcMatch = /\bsrc\s*=\s*"([^"]*)"/.exec(attrs);
|
|
155
|
+
if (srcMatch) src = srcMatch[1];
|
|
156
156
|
|
|
157
157
|
let endPos = -1;
|
|
158
158
|
let j = tagClose + 1;
|
|
@@ -172,7 +172,7 @@ const extractTags = (markup: string) => {
|
|
|
172
172
|
|
|
173
173
|
if (kind !== null) {
|
|
174
174
|
const content = markup.slice(tagClose + 1, markup.lastIndexOf('</', endPos - 1)).trim();
|
|
175
|
-
scripts.push({ kind, content,
|
|
175
|
+
scripts.push({ kind, content, src, start: i, end: endPos });
|
|
176
176
|
}
|
|
177
177
|
i = endPos;
|
|
178
178
|
continue;
|
|
@@ -212,38 +212,44 @@ const extractTags = (markup: string) => {
|
|
|
212
212
|
* ```
|
|
213
213
|
*/
|
|
214
214
|
export const generateStaticTemplate = (filePath: string) => {
|
|
215
|
+
const configDir = config.configDir ?? process.cwd();
|
|
216
|
+
console.log(`Building: ${relative(configDir, filePath)}`);
|
|
215
217
|
renderState.markup = readFileSync(filePath, 'utf-8');
|
|
216
218
|
renderState.insertedTags.clear();
|
|
217
219
|
|
|
218
220
|
const name = basename(filePath, extname(filePath));
|
|
219
221
|
|
|
222
|
+
// quick local utility to convert kebab/camel/snake case to title case
|
|
223
|
+
const toTitleFormat = (str: string) =>
|
|
224
|
+
str
|
|
225
|
+
.replace(/[-_]|(?<=[a-z])(?=[A-Z])/g, ' ')
|
|
226
|
+
.replace(/(?:\b|^)\w/g, (m) => m.toUpperCase())
|
|
227
|
+
.trim();
|
|
228
|
+
|
|
220
229
|
// Set metadata context for the current page before server scripts run
|
|
221
230
|
const relativePath = relative(resolvedBaseDir, filePath);
|
|
222
231
|
const parentDir = dirname(relativePath).replace(/^\./, '');
|
|
223
232
|
const pathname = `/${parentDir}${name === 'index' ? '' : `/${name}`}`;
|
|
224
233
|
const titleWord = name === 'index' ? (pathname.split('/').pop() ?? '') : name;
|
|
225
|
-
const title = titleWord
|
|
226
|
-
.split(/[-_]/)
|
|
227
|
-
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
228
|
-
.join(' ');
|
|
234
|
+
const title = toTitleFormat(titleWord);
|
|
229
235
|
const path = pathname.split('/').filter((segment) => segment !== '');
|
|
230
|
-
let crumbPathname = '/';
|
|
231
236
|
const breadcrumbs: Breadcrumb[] = [
|
|
232
237
|
{
|
|
233
238
|
// always add the home page
|
|
234
|
-
name: config.name,
|
|
235
|
-
pathname:
|
|
239
|
+
name: toTitleFormat(config.name),
|
|
240
|
+
pathname: '/',
|
|
236
241
|
},
|
|
237
242
|
];
|
|
238
243
|
// add each segment of the path as a breadcrumb
|
|
244
|
+
let crumbPathname = '';
|
|
239
245
|
for (const segment of path) {
|
|
240
246
|
crumbPathname += `/${segment}`;
|
|
241
247
|
breadcrumbs.push({
|
|
242
|
-
name: segment,
|
|
248
|
+
name: toTitleFormat(segment),
|
|
243
249
|
pathname: crumbPathname,
|
|
244
250
|
});
|
|
245
251
|
}
|
|
246
|
-
setMeta({
|
|
252
|
+
outRequire('thunderous-server').setMeta({
|
|
247
253
|
config,
|
|
248
254
|
pathname,
|
|
249
255
|
title,
|
|
@@ -276,7 +282,9 @@ export const generateStaticTemplate = (filePath: string) => {
|
|
|
276
282
|
}
|
|
277
283
|
renderState.markup =
|
|
278
284
|
layoutContent.slice(0, slotIndex) + renderState.markup + layoutContent.slice(slotIndex + slotTag.length);
|
|
279
|
-
|
|
285
|
+
|
|
286
|
+
const absLayoutPath = resolve(dirname(filePath), layout.href);
|
|
287
|
+
console.log(`\x1b[90m | Applied layout: ${relative(configDir, absLayoutPath)}\x1b[0m`);
|
|
280
288
|
}
|
|
281
289
|
// ── Extract and process scripts ──
|
|
282
290
|
const { scripts } = extractTags(renderState.markup);
|
|
@@ -286,7 +294,7 @@ export const generateStaticTemplate = (filePath: string) => {
|
|
|
286
294
|
const tempFilesToCleanup: string[] = [];
|
|
287
295
|
|
|
288
296
|
// Map from scriptKey → replacement text (built during processing, applied later)
|
|
289
|
-
const scriptKey = (s: ParsedScript) => `${s.kind}|${s.
|
|
297
|
+
const scriptKey = (s: ParsedScript) => `${s.kind}|${s.src ?? ''}|${s.content}`;
|
|
290
298
|
const replacementMap = new Map<string, string>();
|
|
291
299
|
|
|
292
300
|
mkdirSync(resolvedOutDir, { recursive: true });
|
|
@@ -295,17 +303,21 @@ export const generateStaticTemplate = (filePath: string) => {
|
|
|
295
303
|
for (const script of scripts) {
|
|
296
304
|
const key = scriptKey(script);
|
|
297
305
|
|
|
298
|
-
// ── Resolve content: from
|
|
306
|
+
// ── Resolve content: from src file or inline ──
|
|
299
307
|
let content = script.content;
|
|
300
|
-
let
|
|
301
|
-
if (script.
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
308
|
+
let srcAbsPath: string | undefined;
|
|
309
|
+
if (script.src) {
|
|
310
|
+
if (script.src.startsWith('/')) {
|
|
311
|
+
srcAbsPath = resolve(resolvedBaseDir, script.src.slice(1));
|
|
312
|
+
} else {
|
|
313
|
+
srcAbsPath = resolve(fileDir, script.src);
|
|
314
|
+
}
|
|
315
|
+
if (!existsSync(srcAbsPath)) {
|
|
316
|
+
console.warn(`\x1b[33mWarning: Script src file not found: ${srcAbsPath}\x1b[0m`);
|
|
305
317
|
continue;
|
|
306
318
|
}
|
|
307
319
|
if (script.kind === 'expr') {
|
|
308
|
-
content = readFileSync(
|
|
320
|
+
content = readFileSync(srcAbsPath, 'utf-8').trim();
|
|
309
321
|
}
|
|
310
322
|
}
|
|
311
323
|
|
|
@@ -318,10 +330,10 @@ export const generateStaticTemplate = (filePath: string) => {
|
|
|
318
330
|
// ── Server/isomorphic: execute server-side to collect exported values ──
|
|
319
331
|
if (script.kind === 'server' || script.kind === 'isomorphic') {
|
|
320
332
|
let module: Record<string, unknown>;
|
|
321
|
-
if (
|
|
333
|
+
if (srcAbsPath) {
|
|
322
334
|
// href: require the file directly from source
|
|
323
|
-
delete outRequire.cache[outRequire.resolve(
|
|
324
|
-
module = outRequire(
|
|
335
|
+
delete outRequire.cache[outRequire.resolve(srcAbsPath)];
|
|
336
|
+
module = outRequire(srcAbsPath);
|
|
325
337
|
} else {
|
|
326
338
|
// inline: write temp .ts into baseDir so relative imports resolve correctly
|
|
327
339
|
const tsScriptFile = join(resolvedBaseDir, `${name}-${scriptIndex}.tmp.ts`);
|
|
@@ -341,12 +353,12 @@ export const generateStaticTemplate = (filePath: string) => {
|
|
|
341
353
|
if (script.kind === 'server') {
|
|
342
354
|
// Server scripts are fully discarded from client output
|
|
343
355
|
replacementMap.set(key, '');
|
|
344
|
-
} else if (
|
|
356
|
+
} else if (srcAbsPath) {
|
|
345
357
|
// href isomorphic/module: output a <script src> pointing to the .js file
|
|
346
|
-
const jsSrc = script.
|
|
358
|
+
const jsSrc = script.src!.replace(/\.tsx?$/, '.js');
|
|
347
359
|
replacementMap.set(key, `<script type="module" src="${jsSrc}"></script>`);
|
|
348
360
|
// Resolve the outDir .js path for vendorization
|
|
349
|
-
const hrefRelPath = relative(resolvedBaseDir,
|
|
361
|
+
const hrefRelPath = relative(resolvedBaseDir, srcAbsPath);
|
|
350
362
|
const hrefOutJsPath = join(resolvedOutDir, hrefRelPath).replace(/\.tsx?$/, '.js');
|
|
351
363
|
clientEntryFiles.push(resolve(hrefOutJsPath));
|
|
352
364
|
} else {
|
|
@@ -370,6 +382,16 @@ export const generateStaticTemplate = (filePath: string) => {
|
|
|
370
382
|
scriptIndex++;
|
|
371
383
|
}
|
|
372
384
|
|
|
385
|
+
// ── Pick up any new scripts added by template insertion (e.g. expr inside components) ──
|
|
386
|
+
const { scripts: postInsertScripts } = extractTags(renderState.markup);
|
|
387
|
+
for (const script of postInsertScripts) {
|
|
388
|
+
const key = scriptKey(script);
|
|
389
|
+
if (replacementMap.has(key)) continue;
|
|
390
|
+
if (script.kind === 'expr') {
|
|
391
|
+
replacementMap.set(key, '__expr__');
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
373
395
|
// ── Re-parse and apply all replacements in one reverse pass ──
|
|
374
396
|
const { scripts: freshScripts } = extractTags(renderState.markup);
|
|
375
397
|
for (let i = freshScripts.length - 1; i >= 0; i--) {
|
|
@@ -383,8 +405,8 @@ export const generateStaticTemplate = (filePath: string) => {
|
|
|
383
405
|
if (replacement === '__expr__') {
|
|
384
406
|
// Evaluate expr: content may come from href file
|
|
385
407
|
let content = script.content;
|
|
386
|
-
if (script.
|
|
387
|
-
const hrefPath = resolve(fileDir, script.
|
|
408
|
+
if (script.src) {
|
|
409
|
+
const hrefPath = resolve(fileDir, script.src);
|
|
388
410
|
content = readFileSync(hrefPath, 'utf-8').trim();
|
|
389
411
|
}
|
|
390
412
|
const expression = content.replace(/;$/, '');
|
|
@@ -401,7 +423,38 @@ export const generateStaticTemplate = (filePath: string) => {
|
|
|
401
423
|
}
|
|
402
424
|
renderState.markup = renderState.markup.slice(0, script.start) + text + renderState.markup.slice(script.end);
|
|
403
425
|
}
|
|
426
|
+
// ── Evaluate expr: attributes ──
|
|
427
|
+
renderState.markup = renderState.markup.replace(
|
|
428
|
+
/(<[a-zA-Z][\w-]*\b)((?:\s+[^>]*?)?)(\s*\/?>)/g,
|
|
429
|
+
(_match, openTag: string, attrs: string, close: string) => {
|
|
430
|
+
if (!attrs.includes('expr:')) return _match;
|
|
431
|
+
const result = attrs.replace(
|
|
432
|
+
/\s+expr:([a-zA-Z][\w-]*)=(["'])([\s\S]*?)\2/g,
|
|
433
|
+
(_attrMatch: string, attrName: string, _quote: string, expression: string) => {
|
|
434
|
+
try {
|
|
435
|
+
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
|
436
|
+
const value = Function(
|
|
437
|
+
'html',
|
|
438
|
+
'escapeHtml',
|
|
439
|
+
'raw',
|
|
440
|
+
...Object.keys(safeValues),
|
|
441
|
+
`'use strict'; return (${expression});`,
|
|
442
|
+
)(html, escapeHtml, raw, ...Object.values(safeValues));
|
|
443
|
+
if (value == null || value === false || value === '') return '';
|
|
444
|
+
if (value === true) return ` ${attrName}`;
|
|
445
|
+
return ` ${attrName}="${escapeHtml(String(value))}"`;
|
|
446
|
+
} catch (e) {
|
|
447
|
+
console.warn(`\x1b[33mWarning: Failed to evaluate expr:${attrName}="${expression}":\x1b[0m`, e);
|
|
448
|
+
return '';
|
|
449
|
+
}
|
|
450
|
+
},
|
|
451
|
+
);
|
|
452
|
+
return openTag + result + close;
|
|
453
|
+
},
|
|
454
|
+
);
|
|
455
|
+
|
|
404
456
|
renderState.markup = renderState.markup.trim();
|
|
457
|
+
console.log('');
|
|
405
458
|
|
|
406
459
|
// Return the final rendered markup, client entry files, and cleanup function
|
|
407
460
|
return {
|
package/src/index.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { getMeta } from './meta';
|
|
1
|
+
export { getMeta, setMeta } from './meta';
|
|
2
2
|
export { escapeHtml, raw } from './utilities';
|
package/src/vendorize.ts
CHANGED
|
@@ -38,12 +38,19 @@ export function processNodeModules(entryFiles: string[], outputDir?: string) {
|
|
|
38
38
|
} else {
|
|
39
39
|
const tsFile = file.replace(/\.js$/, '.ts');
|
|
40
40
|
const tsxFile = file.replace(/\.js$/, '.tsx');
|
|
41
|
+
const srcFile = file.replace(`/${config.outDir}/`, `/${config.baseDir}/`);
|
|
42
|
+
const srcTsFile = srcFile.replace(/\.js$/, '.ts');
|
|
43
|
+
const srcTsxFile = srcFile.replace(/\.js$/, '.tsx');
|
|
41
44
|
if (existsSync(tsFile)) {
|
|
42
45
|
code = transpileTsFast(readFileSync(tsFile, 'utf8'), tsFile);
|
|
43
46
|
} else if (existsSync(tsxFile)) {
|
|
44
47
|
code = transpileTsFast(readFileSync(tsxFile, 'utf8'), tsxFile);
|
|
48
|
+
} else if (existsSync(srcTsFile)) {
|
|
49
|
+
code = transpileTsFast(readFileSync(srcTsFile, 'utf8'), srcTsFile);
|
|
50
|
+
} else if (existsSync(srcTsxFile)) {
|
|
51
|
+
code = transpileTsFast(readFileSync(srcTsxFile, 'utf8'), srcTsxFile);
|
|
45
52
|
} else {
|
|
46
|
-
throw new Error(`Cannot find module: ${file} (also tried .ts/.tsx)`);
|
|
53
|
+
throw new Error(`Cannot find module: ${file} (also tried .ts/.tsx and ${config.baseDir}/ equivalents)`);
|
|
47
54
|
}
|
|
48
55
|
}
|
|
49
56
|
const [imports] = parse(code);
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import type { Plugin, ViteDevServer, Connect } from 'vite';
|
|
2
|
+
import type { ServerResponse } from 'http';
|
|
3
|
+
import { createRequire } from 'module';
|
|
4
|
+
import { existsSync, readdirSync, statSync } from 'fs';
|
|
5
|
+
import { dirname, join, relative, resolve } from 'path';
|
|
6
|
+
import { bootstrapThunderous, generateStaticTemplate } from './generate';
|
|
7
|
+
import { config } from './config';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolve a URL pathname to an HTML file in the base directory.
|
|
11
|
+
* Maps routes like `/about` → `src/about/index.html`, `/about/contact` → `src/about/contact.html`.
|
|
12
|
+
*/
|
|
13
|
+
const resolveHtmlPath = (url: string, root: string): string | null => {
|
|
14
|
+
const pathname = url.split('?')[0]?.split('#')[0] ?? '/';
|
|
15
|
+
|
|
16
|
+
// Files starting with _ are excluded (layouts, partials, etc.)
|
|
17
|
+
const lastSegment = pathname.split('/').pop() ?? '';
|
|
18
|
+
if (lastSegment.startsWith('_')) return null;
|
|
19
|
+
|
|
20
|
+
// Try: /about → src/about/index.html
|
|
21
|
+
const indexPath = join(root, pathname, 'index.html');
|
|
22
|
+
if (existsSync(indexPath)) return indexPath;
|
|
23
|
+
|
|
24
|
+
// Try: /about/contact → src/about/contact.html
|
|
25
|
+
const directPath = join(root, pathname + '.html');
|
|
26
|
+
if (existsSync(directPath)) return directPath;
|
|
27
|
+
|
|
28
|
+
return null;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Walk baseDir and collect all routable HTML files (excluding _layout, _partials, etc.).
|
|
33
|
+
* Returns an array of { filePath, urlPath } objects.
|
|
34
|
+
*/
|
|
35
|
+
const collectHtmlPages = (root: string): Array<{ filePath: string; urlPath: string }> => {
|
|
36
|
+
const pages: Array<{ filePath: string; urlPath: string }> = [];
|
|
37
|
+
const walk = (dir: string) => {
|
|
38
|
+
for (const entry of readdirSync(dir)) {
|
|
39
|
+
const full = join(dir, entry);
|
|
40
|
+
if (statSync(full).isDirectory()) {
|
|
41
|
+
walk(full);
|
|
42
|
+
} else if (entry.endsWith('.html') && !entry.startsWith('_')) {
|
|
43
|
+
const rel = relative(root, dirname(full));
|
|
44
|
+
const urlPath = `/${rel}${entry === 'index.html' ? '' : `/${entry.replace('.html', '')}`}`;
|
|
45
|
+
pages.push({ filePath: full, urlPath });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
walk(root);
|
|
50
|
+
return pages;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Vite plugin for Thunderous server-side rendering.
|
|
55
|
+
*
|
|
56
|
+
* Handles:
|
|
57
|
+
* - HTML page requests routed through `generateStaticTemplate`
|
|
58
|
+
* - `.js` → `.ts`/`.tsx` resolution for isomorphic script src attributes
|
|
59
|
+
* - Pre-renders all pages on file change BEFORE triggering browser reload
|
|
60
|
+
*/
|
|
61
|
+
export const thunderousPlugin = (): Plugin => {
|
|
62
|
+
const root = resolve(config.baseDir);
|
|
63
|
+
const outRequire = createRequire(resolve(config.baseDir));
|
|
64
|
+
|
|
65
|
+
// Pre-rendered SSR markup cache: urlPath → markup
|
|
66
|
+
const pageCache = new Map<string, string>();
|
|
67
|
+
|
|
68
|
+
const invalidateRequireCache = () => {
|
|
69
|
+
for (const key of Object.keys(outRequire.cache)) {
|
|
70
|
+
if (key.startsWith(root)) {
|
|
71
|
+
delete outRequire.cache[key];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** Pre-render every routable page so SSR markup is ready before reload. */
|
|
77
|
+
const renderAllPages = () => {
|
|
78
|
+
pageCache.clear();
|
|
79
|
+
for (const { filePath, urlPath } of collectHtmlPages(root)) {
|
|
80
|
+
try {
|
|
81
|
+
const result = generateStaticTemplate(filePath);
|
|
82
|
+
pageCache.set(urlPath, result.markup);
|
|
83
|
+
result.cleanup();
|
|
84
|
+
} catch (error) {
|
|
85
|
+
console.error(`\x1b[31mError pre-rendering ${urlPath}:\x1b[0m`, error);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const virtualId = 'virtual:thunderous-hmr-client';
|
|
91
|
+
const resolvedVirtualId = '\0' + virtualId;
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
name: 'thunderous',
|
|
95
|
+
|
|
96
|
+
// resolveId(id) {
|
|
97
|
+
// if (id === virtualId) return resolvedVirtualId;
|
|
98
|
+
// return id;
|
|
99
|
+
// },
|
|
100
|
+
|
|
101
|
+
// load(id) {
|
|
102
|
+
// if (id === resolvedVirtualId) {
|
|
103
|
+
// return `
|
|
104
|
+
// if (import.meta.hot) {
|
|
105
|
+
// import.meta.hot.on('thunderous:reload', () => {
|
|
106
|
+
// location.reload();
|
|
107
|
+
// })
|
|
108
|
+
// }
|
|
109
|
+
// `;
|
|
110
|
+
// }
|
|
111
|
+
// return '';
|
|
112
|
+
// },
|
|
113
|
+
|
|
114
|
+
// transformIndexHtml(html) {
|
|
115
|
+
// return {
|
|
116
|
+
// html,
|
|
117
|
+
// tags: [
|
|
118
|
+
// {
|
|
119
|
+
// tag: 'script',
|
|
120
|
+
// attrs: { type: 'module' },
|
|
121
|
+
// children: `import "virtual:thunderous-hmr-client"`,
|
|
122
|
+
// injectTo: 'head',
|
|
123
|
+
// },
|
|
124
|
+
// ],
|
|
125
|
+
// };
|
|
126
|
+
// },
|
|
127
|
+
|
|
128
|
+
configureServer(server: ViteDevServer) {
|
|
129
|
+
bootstrapThunderous();
|
|
130
|
+
|
|
131
|
+
// Initial pre-render so the first request is served from cache.
|
|
132
|
+
renderAllPages();
|
|
133
|
+
|
|
134
|
+
// On any file change in baseDir: bust cache → re-render all pages → reload.
|
|
135
|
+
// All three steps are synchronous, so the pre-render logs appear before the reload.
|
|
136
|
+
const onFileChange = (file: string) => {
|
|
137
|
+
if (!file.startsWith(root)) return;
|
|
138
|
+
invalidateRequireCache();
|
|
139
|
+
renderAllPages();
|
|
140
|
+
server.ws.send({ type: 'full-reload' });
|
|
141
|
+
};
|
|
142
|
+
server.watcher.on('change', onFileChange);
|
|
143
|
+
server.watcher.on('add', onFileChange);
|
|
144
|
+
|
|
145
|
+
// Rewrite .js requests to .ts/.tsx when the .js file doesn't exist
|
|
146
|
+
// (generateStaticTemplate outputs .js src paths but source files are .ts)
|
|
147
|
+
server.middlewares.use((req: Connect.IncomingMessage, _res: unknown, next: Connect.NextFunction) => {
|
|
148
|
+
if (!req.url?.endsWith('.js')) return next();
|
|
149
|
+
const cleanUrl = req.url.split('?')[0] ?? '';
|
|
150
|
+
const jsPath = join(root, cleanUrl);
|
|
151
|
+
if (existsSync(jsPath)) return next();
|
|
152
|
+
const tsPath = join(root, cleanUrl.replace(/\.js$/, '.ts'));
|
|
153
|
+
const tsxPath = join(root, cleanUrl.replace(/\.js$/, '.tsx'));
|
|
154
|
+
if (existsSync(tsPath)) {
|
|
155
|
+
req.url = req.url.replace(/\.js$/, '.ts');
|
|
156
|
+
} else if (existsSync(tsxPath)) {
|
|
157
|
+
req.url = req.url.replace(/\.js$/, '.tsx');
|
|
158
|
+
}
|
|
159
|
+
next();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Serve pre-rendered HTML pages, applying Vite's transforms at serve time.
|
|
163
|
+
return () => {
|
|
164
|
+
server.middlewares.use(
|
|
165
|
+
async (req: Connect.IncomingMessage, res: ServerResponse, next: Connect.NextFunction) => {
|
|
166
|
+
const url = req.originalUrl ?? req.url;
|
|
167
|
+
if (!url) return next();
|
|
168
|
+
|
|
169
|
+
const urlPath = url.split('?')[0]?.split('#')[0] ?? '/';
|
|
170
|
+
|
|
171
|
+
// Try cache first, fall back to on-demand render
|
|
172
|
+
let markup = pageCache.get(urlPath);
|
|
173
|
+
if (!markup) {
|
|
174
|
+
const htmlPath = resolveHtmlPath(url, root);
|
|
175
|
+
if (!htmlPath) return next();
|
|
176
|
+
try {
|
|
177
|
+
const result = generateStaticTemplate(htmlPath);
|
|
178
|
+
markup = result.markup;
|
|
179
|
+
result.cleanup();
|
|
180
|
+
} catch (error) {
|
|
181
|
+
console.error(`\x1b[31mError processing ${htmlPath}:\x1b[0m`, error);
|
|
182
|
+
return next(error);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
// Let Vite inject its HMR client and process module scripts
|
|
188
|
+
markup = await server.transformIndexHtml(url, markup);
|
|
189
|
+
|
|
190
|
+
res.statusCode = 200;
|
|
191
|
+
res.setHeader('Content-Type', 'text/html');
|
|
192
|
+
res.end(markup);
|
|
193
|
+
} catch (error) {
|
|
194
|
+
console.error(`\x1b[31mError transforming HTML for ${url}:\x1b[0m`, error);
|
|
195
|
+
next(error);
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
);
|
|
199
|
+
};
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
};
|