thunderous-server 0.0.2 → 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 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.2",
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,45 +1,31 @@
1
1
  import { build } from './build';
2
- import nodemon from 'nodemon';
3
- import { 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 ignoreFile = readFileSync('.gitignore', 'utf-8');
13
- const ignores = ignoreFile.split('\n').filter((line) => line.trim() !== '' && !line.startsWith('#'));
14
-
15
- // Set up nodemon for auto-restart on server changes
16
- if (process.env.NODE_ENV !== 'production') {
17
- nodemon({
18
- script: resolve(`${import.meta.dirname}/dev.ts`),
19
- ignore: ignores,
20
- watch: [config.baseDir],
21
- exec: 'tsx',
22
- });
23
-
24
- nodemon
25
- .on('start', () => {
26
- // console.log('App has started');
27
- })
28
- .on('quit', () => {
29
- console.log(chalk.green('\nServer shut down successfully.\n'));
30
- process.exit();
31
- })
32
- .on('restart', (files) => {
33
- const filesList = (files ?? ['(none)']).map(
34
- (file) => ` • ${chalk.cyan.underline(relative(config.configDir ?? process.cwd(), file))}\n`,
35
- );
36
- console.log('\nUpdates detected:\n', filesList.join(''));
37
- });
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
+ }
38
19
  }
39
- } catch (error) {
40
- console.error('\x1b[31mFailed to start server:\x1b[0m', error);
41
- process.exit(1);
42
- }
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
+ });
43
29
  } else if (args[0] === 'build') {
44
30
  try {
45
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,7 +51,7 @@ 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
 
@@ -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; href?: string | undefined; start: number; end: number };
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 href: string | undefined;
154
- const hrefMatch = /\bhref\s*=\s*"([^"]*)"/.exec(attrs);
155
- if (hrefMatch) href = hrefMatch[1];
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, href, start: i, end: endPos });
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: crumbPathname,
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
- console.log(`\x1b[90mApplied layout: ${layout.href}\x1b[0m`);
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.href ?? ''}|${s.content}`;
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 href file or inline ──
306
+ // ── Resolve content: from src file or inline ──
299
307
  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`);
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(hrefAbsPath, 'utf-8').trim();
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 (hrefAbsPath) {
333
+ if (srcAbsPath) {
322
334
  // href: require the file directly from source
323
- delete outRequire.cache[outRequire.resolve(hrefAbsPath)];
324
- module = outRequire(hrefAbsPath);
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 (hrefAbsPath) {
356
+ } else if (srcAbsPath) {
345
357
  // href isomorphic/module: output a <script src> pointing to the .js file
346
- const jsSrc = script.href!.replace(/\.tsx?$/, '.js');
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, hrefAbsPath);
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.href) {
387
- const hrefPath = resolve(fileDir, script.href);
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
+ };