thunderous-server 0.0.3 → 0.0.5

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 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",
3
+ "version": "0.0.5",
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/build.ts CHANGED
@@ -42,7 +42,12 @@ export const build = () => {
42
42
 
43
43
  cpSync(baseDir, outDir, {
44
44
  recursive: true,
45
- filter: (src) => !/\.server\.(ts|js)$/.test(src),
45
+ filter: (src) => {
46
+ const name = src.split('/').pop() ?? '';
47
+ if (name.startsWith('_')) return false;
48
+ if (/\.server\.(ts|mts|cts|tsx|js|mjs|cjs|jsx)$/.test(name)) return false;
49
+ return true;
50
+ },
46
51
  });
47
52
  bootstrapThunderous();
48
53
 
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
- try {
12
- const ignores = existsSync('.gitignore')
13
- ? readFileSync('.gitignore', 'utf-8')
14
- .split('\n')
15
- .filter((line) => line.trim() !== '' && !line.startsWith('#'))
16
- : [];
17
-
18
- // Set up nodemon for auto-restart on server changes
19
- if (process.env.NODE_ENV !== 'production') {
20
- nodemon({
21
- script: resolve(`${import.meta.dirname}/dev.ts`),
22
- ignore: ignores,
23
- watch: [config.baseDir],
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
- } catch (error) {
43
- console.error('\x1b[31mFailed to start server:\x1b[0m', error);
44
- process.exit(1);
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 express from 'express';
2
- import { existsSync, readdirSync, readFileSync, statSync, mkdtempSync } from 'fs';
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 livereload from 'livereload';
9
- import connectLiveReload from 'connect-livereload';
10
-
11
- /**
12
- * Find all `.html` files in the given directory and set up Express routes to serve them.
13
- *
14
- * Composes `processServerScripts` to handle server-side logic and templating in the HTML files.
15
- */
16
- const bootstrapRoutes = (dir: string, app: express.Express, vendorDir: string) => {
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
- // Create a temporary directory for vendorized modules in dev mode
79
- const vendorDir = mkdtempSync(join(tmpdir(), 'thunderous-vendor-'));
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 { setMeta as setMeta, type Breadcrumb } from './meta';
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,15 +51,32 @@ export const bootstrapThunderous = () => {
51
51
  innerHTML.replace(/\s+/gm, ' ').replace(/ >/g, '>'),
52
52
  renderState.markup,
53
53
  );
54
- console.log(`\x1b[90mInserted template for <${tagName}> into markup.\x1b[0m`);
54
+ console.log(`\x1b[90m |--- Inserted SSR template for <${tagName}> into markup.\x1b[0m`);
55
55
  });
56
56
  };
57
57
 
58
+ /** Rewrite relative import specifiers so the browser can resolve them (e.g. '../theme' → '../theme.js'). */
59
+ const rewriteRelativeImports = (code: string, jsFilePath: string): string =>
60
+ code.replace(
61
+ /((?:^|[\s;,])(?:import|export)\s.*?from\s+['"])([^'"]+)(['"])/gm,
62
+ (_match, prefix: string, spec: string, suffix: string) => {
63
+ if (!spec.startsWith('.')) return `${prefix}${spec}${suffix}`;
64
+ if (/\.[a-z]+$/i.test(spec)) return `${prefix}${spec}${suffix}`;
65
+ const dir = dirname(jsFilePath);
66
+ if (existsSync(join(dir, spec + '.js')) || existsSync(join(dir, spec, 'index.js'))) {
67
+ const resolved = existsSync(join(dir, spec + '.js')) ? spec + '.js' : spec + '/index.js';
68
+ return `${prefix}${resolved}${suffix}`;
69
+ }
70
+ return `${prefix}${spec}.js${suffix}`;
71
+ },
72
+ );
73
+
58
74
  /** Transpile a .ts file on disk to .js, writing the output next to it. Returns the .js path. */
59
75
  export const transpileTsFile = (tsFilePath: string) => {
60
76
  const source = readFileSync(tsFilePath, 'utf-8');
61
- const js = transpileTs(source, basename(tsFilePath));
77
+ let js = transpileTs(source, basename(tsFilePath));
62
78
  const jsPath = tsFilePath.replace(/\.ts$/, '.js');
79
+ js = rewriteRelativeImports(js, jsPath);
63
80
  writeFileSync(jsPath, js, 'utf-8');
64
81
  rmSync(tsFilePath);
65
82
  return jsPath;
@@ -90,7 +107,7 @@ export const processFiles = (args: ProcessFilesArgs) => {
90
107
  };
91
108
 
92
109
  type ScriptKind = 'expr' | 'server' | 'isomorphic' | 'module';
93
- type ParsedScript = { kind: ScriptKind; content: string; href?: string | undefined; start: number; end: number };
110
+ type ParsedScript = { kind: ScriptKind; content: string; src?: string | undefined; start: number; end: number };
94
111
  type ParsedLayout = { href: string; start: number; end: number };
95
112
 
96
113
  // ── Cached resolved paths (computed once at module load) ──
@@ -150,9 +167,9 @@ const extractTags = (markup: string) => {
150
167
  else if (/\bisomorphic\b/.test(attrs)) kind = 'isomorphic';
151
168
  else if (/\btype\s*=\s*"module"/.test(attrs)) kind = 'module';
152
169
 
153
- let href: string | undefined;
154
- const hrefMatch = /\bhref\s*=\s*"([^"]*)"/.exec(attrs);
155
- if (hrefMatch) href = hrefMatch[1];
170
+ let src: string | undefined;
171
+ const srcMatch = /\bsrc\s*=\s*"([^"]*)"/.exec(attrs);
172
+ if (srcMatch) src = srcMatch[1];
156
173
 
157
174
  let endPos = -1;
158
175
  let j = tagClose + 1;
@@ -172,7 +189,7 @@ const extractTags = (markup: string) => {
172
189
 
173
190
  if (kind !== null) {
174
191
  const content = markup.slice(tagClose + 1, markup.lastIndexOf('</', endPos - 1)).trim();
175
- scripts.push({ kind, content, href, start: i, end: endPos });
192
+ scripts.push({ kind, content, src, start: i, end: endPos });
176
193
  }
177
194
  i = endPos;
178
195
  continue;
@@ -212,38 +229,44 @@ const extractTags = (markup: string) => {
212
229
  * ```
213
230
  */
214
231
  export const generateStaticTemplate = (filePath: string) => {
232
+ const configDir = config.configDir ?? process.cwd();
233
+ console.log(`Building: ${relative(configDir, filePath)}`);
215
234
  renderState.markup = readFileSync(filePath, 'utf-8');
216
235
  renderState.insertedTags.clear();
217
236
 
218
237
  const name = basename(filePath, extname(filePath));
219
238
 
239
+ // quick local utility to convert kebab/camel/snake case to title case
240
+ const toTitleFormat = (str: string) =>
241
+ str
242
+ .replace(/[-_]|(?<=[a-z])(?=[A-Z])/g, ' ')
243
+ .replace(/(?:\b|^)\w/g, (m) => m.toUpperCase())
244
+ .trim();
245
+
220
246
  // Set metadata context for the current page before server scripts run
221
247
  const relativePath = relative(resolvedBaseDir, filePath);
222
248
  const parentDir = dirname(relativePath).replace(/^\./, '');
223
249
  const pathname = `/${parentDir}${name === 'index' ? '' : `/${name}`}`;
224
250
  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(' ');
251
+ const title = toTitleFormat(titleWord);
229
252
  const path = pathname.split('/').filter((segment) => segment !== '');
230
- let crumbPathname = '/';
231
253
  const breadcrumbs: Breadcrumb[] = [
232
254
  {
233
255
  // always add the home page
234
- name: config.name,
235
- pathname: crumbPathname,
256
+ name: toTitleFormat(config.name),
257
+ pathname: '/',
236
258
  },
237
259
  ];
238
260
  // add each segment of the path as a breadcrumb
261
+ let crumbPathname = '';
239
262
  for (const segment of path) {
240
263
  crumbPathname += `/${segment}`;
241
264
  breadcrumbs.push({
242
- name: segment,
265
+ name: toTitleFormat(segment),
243
266
  pathname: crumbPathname,
244
267
  });
245
268
  }
246
- setMeta({
269
+ outRequire('thunderous-server').setMeta({
247
270
  config,
248
271
  pathname,
249
272
  title,
@@ -276,7 +299,9 @@ export const generateStaticTemplate = (filePath: string) => {
276
299
  }
277
300
  renderState.markup =
278
301
  layoutContent.slice(0, slotIndex) + renderState.markup + layoutContent.slice(slotIndex + slotTag.length);
279
- console.log(`\x1b[90mApplied layout: ${layout.href}\x1b[0m`);
302
+
303
+ const absLayoutPath = resolve(dirname(filePath), layout.href);
304
+ console.log(`\x1b[90m | Applied layout: ${relative(configDir, absLayoutPath)}\x1b[0m`);
280
305
  }
281
306
  // ── Extract and process scripts ──
282
307
  const { scripts } = extractTags(renderState.markup);
@@ -286,7 +311,7 @@ export const generateStaticTemplate = (filePath: string) => {
286
311
  const tempFilesToCleanup: string[] = [];
287
312
 
288
313
  // Map from scriptKey → replacement text (built during processing, applied later)
289
- const scriptKey = (s: ParsedScript) => `${s.kind}|${s.href ?? ''}|${s.content}`;
314
+ const scriptKey = (s: ParsedScript) => `${s.kind}|${s.src ?? ''}|${s.content}`;
290
315
  const replacementMap = new Map<string, string>();
291
316
 
292
317
  mkdirSync(resolvedOutDir, { recursive: true });
@@ -295,17 +320,21 @@ export const generateStaticTemplate = (filePath: string) => {
295
320
  for (const script of scripts) {
296
321
  const key = scriptKey(script);
297
322
 
298
- // ── Resolve content: from href file or inline ──
323
+ // ── Resolve content: from src file or inline ──
299
324
  let content = script.content;
300
- let hrefAbsPath: string | undefined;
301
- if (script.href) {
302
- hrefAbsPath = resolve(fileDir, script.href);
303
- if (!existsSync(hrefAbsPath)) {
304
- console.warn(`\x1b[33mWarning: Script href file not found: ${hrefAbsPath}\x1b[0m`);
325
+ let srcAbsPath: string | undefined;
326
+ if (script.src) {
327
+ if (script.src.startsWith('/')) {
328
+ srcAbsPath = resolve(resolvedBaseDir, script.src.slice(1));
329
+ } else {
330
+ srcAbsPath = resolve(fileDir, script.src);
331
+ }
332
+ if (!existsSync(srcAbsPath)) {
333
+ console.warn(`\x1b[33mWarning: Script src file not found: ${srcAbsPath}\x1b[0m`);
305
334
  continue;
306
335
  }
307
336
  if (script.kind === 'expr') {
308
- content = readFileSync(hrefAbsPath, 'utf-8').trim();
337
+ content = readFileSync(srcAbsPath, 'utf-8').trim();
309
338
  }
310
339
  }
311
340
 
@@ -318,10 +347,10 @@ export const generateStaticTemplate = (filePath: string) => {
318
347
  // ── Server/isomorphic: execute server-side to collect exported values ──
319
348
  if (script.kind === 'server' || script.kind === 'isomorphic') {
320
349
  let module: Record<string, unknown>;
321
- if (hrefAbsPath) {
350
+ if (srcAbsPath) {
322
351
  // href: require the file directly from source
323
- delete outRequire.cache[outRequire.resolve(hrefAbsPath)];
324
- module = outRequire(hrefAbsPath);
352
+ delete outRequire.cache[outRequire.resolve(srcAbsPath)];
353
+ module = outRequire(srcAbsPath);
325
354
  } else {
326
355
  // inline: write temp .ts into baseDir so relative imports resolve correctly
327
356
  const tsScriptFile = join(resolvedBaseDir, `${name}-${scriptIndex}.tmp.ts`);
@@ -341,12 +370,12 @@ export const generateStaticTemplate = (filePath: string) => {
341
370
  if (script.kind === 'server') {
342
371
  // Server scripts are fully discarded from client output
343
372
  replacementMap.set(key, '');
344
- } else if (hrefAbsPath) {
373
+ } else if (srcAbsPath) {
345
374
  // href isomorphic/module: output a <script src> pointing to the .js file
346
- const jsSrc = script.href!.replace(/\.tsx?$/, '.js');
375
+ const jsSrc = script.src!.replace(/\.tsx?$/, '.js');
347
376
  replacementMap.set(key, `<script type="module" src="${jsSrc}"></script>`);
348
377
  // Resolve the outDir .js path for vendorization
349
- const hrefRelPath = relative(resolvedBaseDir, hrefAbsPath);
378
+ const hrefRelPath = relative(resolvedBaseDir, srcAbsPath);
350
379
  const hrefOutJsPath = join(resolvedOutDir, hrefRelPath).replace(/\.tsx?$/, '.js');
351
380
  clientEntryFiles.push(resolve(hrefOutJsPath));
352
381
  } else {
@@ -370,6 +399,16 @@ export const generateStaticTemplate = (filePath: string) => {
370
399
  scriptIndex++;
371
400
  }
372
401
 
402
+ // ── Pick up any new scripts added by template insertion (e.g. expr inside components) ──
403
+ const { scripts: postInsertScripts } = extractTags(renderState.markup);
404
+ for (const script of postInsertScripts) {
405
+ const key = scriptKey(script);
406
+ if (replacementMap.has(key)) continue;
407
+ if (script.kind === 'expr') {
408
+ replacementMap.set(key, '__expr__');
409
+ }
410
+ }
411
+
373
412
  // ── Re-parse and apply all replacements in one reverse pass ──
374
413
  const { scripts: freshScripts } = extractTags(renderState.markup);
375
414
  for (let i = freshScripts.length - 1; i >= 0; i--) {
@@ -383,8 +422,8 @@ export const generateStaticTemplate = (filePath: string) => {
383
422
  if (replacement === '__expr__') {
384
423
  // Evaluate expr: content may come from href file
385
424
  let content = script.content;
386
- if (script.href) {
387
- const hrefPath = resolve(fileDir, script.href);
425
+ if (script.src) {
426
+ const hrefPath = resolve(fileDir, script.src);
388
427
  content = readFileSync(hrefPath, 'utf-8').trim();
389
428
  }
390
429
  const expression = content.replace(/;$/, '');
@@ -401,7 +440,38 @@ export const generateStaticTemplate = (filePath: string) => {
401
440
  }
402
441
  renderState.markup = renderState.markup.slice(0, script.start) + text + renderState.markup.slice(script.end);
403
442
  }
443
+ // ── Evaluate expr: attributes ──
444
+ renderState.markup = renderState.markup.replace(
445
+ /(<[a-zA-Z][\w-]*\b)((?:\s+[^>]*?)?)(\s*\/?>)/g,
446
+ (_match, openTag: string, attrs: string, close: string) => {
447
+ if (!attrs.includes('expr:')) return _match;
448
+ const result = attrs.replace(
449
+ /\s+expr:([a-zA-Z][\w-]*)=(["'])([\s\S]*?)\2/g,
450
+ (_attrMatch: string, attrName: string, _quote: string, expression: string) => {
451
+ try {
452
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval
453
+ const value = Function(
454
+ 'html',
455
+ 'escapeHtml',
456
+ 'raw',
457
+ ...Object.keys(safeValues),
458
+ `'use strict'; return (${expression});`,
459
+ )(html, escapeHtml, raw, ...Object.values(safeValues));
460
+ if (value == null || value === false || value === '') return '';
461
+ if (value === true) return ` ${attrName}`;
462
+ return ` ${attrName}="${escapeHtml(String(value))}"`;
463
+ } catch (e) {
464
+ console.warn(`\x1b[33mWarning: Failed to evaluate expr:${attrName}="${expression}":\x1b[0m`, e);
465
+ return '';
466
+ }
467
+ },
468
+ );
469
+ return openTag + result + close;
470
+ },
471
+ );
472
+
404
473
  renderState.markup = renderState.markup.trim();
474
+ console.log('');
405
475
 
406
476
  // Return the final rendered markup, client entry files, and cleanup function
407
477
  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
+ };