thunderous-server 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +129 -0
- package/bin/thunderous +10 -0
- package/dist/index.cjs +69 -0
- package/dist/index.d.cts +85 -0
- package/dist/index.d.ts +85 -0
- package/dist/index.js +40 -0
- package/package.json +63 -0
- package/src/build.ts +134 -0
- package/src/cli.ts +24 -0
- package/src/config.ts +61 -0
- package/src/dev.ts +113 -0
- package/src/generate.ts +451 -0
- package/src/index.ts +2 -0
- package/src/meta.ts +80 -0
- package/src/utilities.ts +26 -0
- package/src/vendorize.ts +190 -0
- package/types/global.d.ts +1 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { existsSync } from 'fs';
|
|
2
|
+
import { createRequire } from 'module';
|
|
3
|
+
import { join, resolve } from 'path';
|
|
4
|
+
const require = createRequire(import.meta.url);
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The type for the default export from `thunderous.config.ts`.
|
|
8
|
+
*/
|
|
9
|
+
export type ThunderousConfig = {
|
|
10
|
+
/**
|
|
11
|
+
* The name of the site.
|
|
12
|
+
*/
|
|
13
|
+
name: string;
|
|
14
|
+
/**
|
|
15
|
+
* The base directory where HTML files and static assets are served.
|
|
16
|
+
*/
|
|
17
|
+
baseDir: string;
|
|
18
|
+
/**
|
|
19
|
+
* The output directory for builds.
|
|
20
|
+
*/
|
|
21
|
+
outDir: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const DEFAULT_CONFIG = Object.freeze({
|
|
25
|
+
name: 'Thunderous Project',
|
|
26
|
+
baseDir: 'src',
|
|
27
|
+
outDir: 'dist',
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/** Find and import the `thunderous.config.ts` file. */
|
|
31
|
+
const resolveConfig = (): ThunderousConfig => {
|
|
32
|
+
const cwd = process.cwd();
|
|
33
|
+
let currentDir = cwd;
|
|
34
|
+
let configDir: string | undefined;
|
|
35
|
+
let rootDir: string | undefined;
|
|
36
|
+
while (true) {
|
|
37
|
+
if (existsSync(join(currentDir, 'package.json')) && rootDir === undefined) {
|
|
38
|
+
rootDir = currentDir;
|
|
39
|
+
}
|
|
40
|
+
if (existsSync(join(currentDir, 'thunderous.config.ts')) && configDir === undefined) {
|
|
41
|
+
configDir = currentDir;
|
|
42
|
+
}
|
|
43
|
+
if (rootDir !== undefined && configDir !== undefined) {
|
|
44
|
+
const configPath = join(configDir!, 'thunderous.config.ts');
|
|
45
|
+
const configOverrides: Partial<ThunderousConfig> = require(configPath);
|
|
46
|
+
return {
|
|
47
|
+
name: configOverrides.name ?? DEFAULT_CONFIG.name,
|
|
48
|
+
baseDir: configOverrides.baseDir?.replace(/^\/*/, '') ?? DEFAULT_CONFIG.baseDir,
|
|
49
|
+
outDir: configOverrides.outDir ?? DEFAULT_CONFIG.outDir,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const parentDir = resolve(currentDir, '..');
|
|
53
|
+
if (parentDir === currentDir) {
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
currentDir = parentDir;
|
|
57
|
+
}
|
|
58
|
+
return DEFAULT_CONFIG;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const config = resolveConfig();
|
package/src/dev.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { readdirSync, statSync, mkdtempSync } from 'fs';
|
|
3
|
+
import { join, relative, resolve } from 'path';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import { bootstrapThunderous, generateStaticTemplate, generateImportMap, injectImportMap } from './generate';
|
|
6
|
+
import { config } from './config';
|
|
7
|
+
import livereload from 'livereload';
|
|
8
|
+
import connectLiveReload from 'connect-livereload';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Find all `.html` files in the given directory and set up Express routes to serve them.
|
|
12
|
+
*
|
|
13
|
+
* Composes `processServerScripts` to handle server-side logic and templating in the HTML files.
|
|
14
|
+
*/
|
|
15
|
+
const bootstrapRoutes = (dir: string, app: express.Express, vendorDir: string) => {
|
|
16
|
+
const dirPath = resolve(dir);
|
|
17
|
+
const files = readdirSync(dirPath);
|
|
18
|
+
|
|
19
|
+
for (const file of files) {
|
|
20
|
+
const filePath = join(dirPath, file);
|
|
21
|
+
if (file.endsWith('.html') && !file.startsWith('_')) {
|
|
22
|
+
const basePath = relative(config.baseDir, dirPath);
|
|
23
|
+
const path = `/${basePath}${file === 'index.html' ? '' : `/${file.replace('.html', '')}`}`;
|
|
24
|
+
console.log(`\x1b[90mFound path: ${path}\x1b[0m`);
|
|
25
|
+
|
|
26
|
+
app.get(path, (_, res) => {
|
|
27
|
+
console.log(`\x1b[90mServing file: ${basePath}/${file}\x1b[0m`);
|
|
28
|
+
|
|
29
|
+
// Process the HTML file and extract client entry files
|
|
30
|
+
const result = generateStaticTemplate(filePath);
|
|
31
|
+
let markup = result.markup;
|
|
32
|
+
|
|
33
|
+
// Generate import map if there are client entry files
|
|
34
|
+
if (result.clientEntryFiles.length > 0) {
|
|
35
|
+
const importMapJson = generateImportMap(result.clientEntryFiles);
|
|
36
|
+
markup = injectImportMap(markup, importMapJson);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Clean up temporary files
|
|
40
|
+
result.cleanup();
|
|
41
|
+
|
|
42
|
+
res.send(markup);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
if (statSync(filePath).isDirectory()) {
|
|
46
|
+
bootstrapRoutes(filePath, app, vendorDir);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const dev = () => {
|
|
52
|
+
console.log('\n\x1b[36m\x1b[1m⚡⚡ Starting development server... ⚡⚡\x1b[0m\x1b[0m\n');
|
|
53
|
+
|
|
54
|
+
const PORT = process.env['PORT'] ?? 3000;
|
|
55
|
+
|
|
56
|
+
// Set up simple express server
|
|
57
|
+
const app = express();
|
|
58
|
+
|
|
59
|
+
// Set up live reload to watch for changes
|
|
60
|
+
const liveReloadServer = livereload.createServer();
|
|
61
|
+
liveReloadServer.watch(`${process.cwd()}/${config.baseDir}`);
|
|
62
|
+
app.use(connectLiveReload());
|
|
63
|
+
app.use((_, res, next) => {
|
|
64
|
+
const originalSend = res.send;
|
|
65
|
+
|
|
66
|
+
// Inject live reload script into HTML responses
|
|
67
|
+
res.send = function (body) {
|
|
68
|
+
if (typeof body === 'string' && body.includes('</body>')) {
|
|
69
|
+
const liveReloadScript = '<script src="http://localhost:35729/livereload.js"></script>';
|
|
70
|
+
body = body.replace('</body>', `${liveReloadScript}</body>`);
|
|
71
|
+
}
|
|
72
|
+
return originalSend.call(this, body);
|
|
73
|
+
};
|
|
74
|
+
next();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Create a temporary directory for vendorized modules in dev mode
|
|
78
|
+
const vendorDir = mkdtempSync(join(tmpdir(), 'thunderous-vendor-'));
|
|
79
|
+
console.log(`\x1b[90mUsing temporary vendor directory: ${vendorDir}\x1b[0m`);
|
|
80
|
+
|
|
81
|
+
bootstrapThunderous();
|
|
82
|
+
bootstrapRoutes(`./${config.baseDir}`, app, vendorDir);
|
|
83
|
+
|
|
84
|
+
// Serve static assets from the base directory
|
|
85
|
+
app.use(express.static(config.baseDir));
|
|
86
|
+
|
|
87
|
+
// Serve vendorized modules
|
|
88
|
+
app.use('/vendor', express.static(join(vendorDir, 'vendor')));
|
|
89
|
+
|
|
90
|
+
const server = app.listen(PORT, () => {
|
|
91
|
+
console.log(`\n\x1b[38;2;100;149;237mServer is running on http://localhost:${PORT}\x1b[0m\n`);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Close everything when server is closed
|
|
95
|
+
server.once('close', () => {
|
|
96
|
+
liveReloadServer.close();
|
|
97
|
+
server.closeAllConnections?.();
|
|
98
|
+
console.log('\x1b[32m\nAll connections closed successfully.\x1b[0m\n\n');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Handle graceful shutdown when user cancels the process
|
|
102
|
+
process.once('SIGINT', () => {
|
|
103
|
+
console.log('\n\x1b[90mShutting down gracefully...\x1b[0m');
|
|
104
|
+
server.close((error) => {
|
|
105
|
+
if (error === undefined) {
|
|
106
|
+
process.exitCode = 0;
|
|
107
|
+
} else {
|
|
108
|
+
console.error('Error closing server:', error);
|
|
109
|
+
process.exitCode = 1;
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
};
|
package/src/generate.ts
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'fs';
|
|
2
|
+
import { join, relative, resolve } from 'path';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { html } from 'thunderous';
|
|
5
|
+
import { setMeta as setMeta, type Breadcrumb } from './meta';
|
|
6
|
+
import { basename, dirname, extname } from 'node:path';
|
|
7
|
+
import { config } from './config';
|
|
8
|
+
import { ModuleKind, ScriptTarget, transpileModule, ImportsNotUsedAsValues } from 'typescript';
|
|
9
|
+
import { processNodeModules } from './vendorize';
|
|
10
|
+
import { escapeHtml, raw } from './utilities';
|
|
11
|
+
|
|
12
|
+
// resolves imports relative to the output directory - this is important for persisting
|
|
13
|
+
// the correct references to avoid problems with unique values (like symbols) or other
|
|
14
|
+
// shared state within a given library.
|
|
15
|
+
const outRequire = createRequire(resolve(config.baseDir));
|
|
16
|
+
|
|
17
|
+
// track state outside the function so that we only have to set one `onServerDefine` handler
|
|
18
|
+
const renderState = {
|
|
19
|
+
markup: '',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// ── Shared TypeScript compiler options (created once, reused everywhere) ──
|
|
23
|
+
const tsCompilerOptions = {
|
|
24
|
+
module: ModuleKind.ESNext,
|
|
25
|
+
target: ScriptTarget.ES2020,
|
|
26
|
+
sourceMap: false,
|
|
27
|
+
importsNotUsedAsValues: ImportsNotUsedAsValues.Remove,
|
|
28
|
+
verbatimModuleSyntax: true,
|
|
29
|
+
isolatedModules: true,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Transpile TypeScript source to JavaScript in-process (no child process). */
|
|
33
|
+
const transpileTs = (source: string, fileName = 'inline.ts'): string =>
|
|
34
|
+
transpileModule(source, { compilerOptions: tsCompilerOptions, fileName, reportDiagnostics: false }).outputText;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Import Thunderous (core lib, not server) and set up server-side rendering state.
|
|
38
|
+
* This should be called once at the start of the build process.
|
|
39
|
+
*/
|
|
40
|
+
export const bootstrapThunderous = () => {
|
|
41
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
|
42
|
+
const Thunderous: typeof import('thunderous') = outRequire('thunderous');
|
|
43
|
+
const { insertTemplates, onServerDefine } = Thunderous;
|
|
44
|
+
// Update the markup each time a thunderous element is defined on the server
|
|
45
|
+
onServerDefine((tagName, innerHTML) => {
|
|
46
|
+
renderState.markup = insertTemplates(
|
|
47
|
+
tagName,
|
|
48
|
+
innerHTML.replace(/\s+/gm, ' ').replace(/ >/g, '>'),
|
|
49
|
+
renderState.markup,
|
|
50
|
+
);
|
|
51
|
+
console.log(`\x1b[90mInserted template for <${tagName}> into markup.\x1b[0m`);
|
|
52
|
+
});
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Transpile a .ts file on disk to .js, writing the output next to it. Returns the .js path. */
|
|
56
|
+
export const transpileTsFile = (tsFilePath: string) => {
|
|
57
|
+
const source = readFileSync(tsFilePath, 'utf-8');
|
|
58
|
+
const js = transpileTs(source, basename(tsFilePath));
|
|
59
|
+
const jsPath = tsFilePath.replace(/\.ts$/, '.js');
|
|
60
|
+
writeFileSync(jsPath, js, 'utf-8');
|
|
61
|
+
rmSync(tsFilePath);
|
|
62
|
+
return jsPath;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
type ProcessFilesArgs = {
|
|
66
|
+
dir: string;
|
|
67
|
+
filter: (filePath: string) => boolean;
|
|
68
|
+
callback: (filePath: string) => void | Promise<void>;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const processFiles = (args: ProcessFilesArgs) => {
|
|
72
|
+
const dirPath = resolve(args.dir);
|
|
73
|
+
const files = readdirSync(dirPath);
|
|
74
|
+
|
|
75
|
+
for (const file of files) {
|
|
76
|
+
const filePath = join(dirPath, file);
|
|
77
|
+
const stat = statSync(filePath);
|
|
78
|
+
if (stat.isDirectory()) {
|
|
79
|
+
processFiles({
|
|
80
|
+
...args,
|
|
81
|
+
dir: filePath,
|
|
82
|
+
});
|
|
83
|
+
} else if (args.filter(filePath)) {
|
|
84
|
+
void args.callback(filePath);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
type ScriptKind = 'expr' | 'server' | 'isomorphic' | 'module';
|
|
90
|
+
type ParsedScript = { kind: ScriptKind; content: string; href?: string | undefined; start: number; end: number };
|
|
91
|
+
type ParsedLayout = { href: string; start: number; end: number };
|
|
92
|
+
|
|
93
|
+
// ── Cached resolved paths (computed once at module load) ──
|
|
94
|
+
const resolvedBaseDir = resolve(config.baseDir);
|
|
95
|
+
const resolvedOutDir = resolve(config.outDir);
|
|
96
|
+
|
|
97
|
+
// ── Single-pass tag scanner ──
|
|
98
|
+
const extractTags = (markup: string) => {
|
|
99
|
+
const scripts: ParsedScript[] = [];
|
|
100
|
+
const layouts: ParsedLayout[] = [];
|
|
101
|
+
let i = 0;
|
|
102
|
+
while (i < markup.length) {
|
|
103
|
+
if (markup[i] !== '<') {
|
|
104
|
+
i++;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// <?layout href="…">
|
|
109
|
+
if (markup.startsWith('<?layout', i)) {
|
|
110
|
+
const close = markup.indexOf('>', i);
|
|
111
|
+
if (close === -1) {
|
|
112
|
+
i++;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const tag = markup.slice(i, close + 1);
|
|
116
|
+
const hrefStart = tag.indexOf('href="');
|
|
117
|
+
if (hrefStart !== -1) {
|
|
118
|
+
const valStart = hrefStart + 6;
|
|
119
|
+
const valEnd = tag.indexOf('"', valStart);
|
|
120
|
+
if (valEnd !== -1) {
|
|
121
|
+
layouts.push({ href: tag.slice(valStart, valEnd), start: i, end: close + 1 });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
i = close + 1;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// <script …>
|
|
129
|
+
if (markup.startsWith('<script', i) && /[\s>]/.test(markup[i + 7] ?? '')) {
|
|
130
|
+
const tagClose = markup.indexOf('>', i);
|
|
131
|
+
if (tagClose === -1) {
|
|
132
|
+
i++;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const openTag = markup.slice(i, tagClose + 1);
|
|
136
|
+
const attrs = openTag.slice(openTag.indexOf(' '), openTag.lastIndexOf('>'));
|
|
137
|
+
let kind: ScriptKind | null = null;
|
|
138
|
+
if (/\bexpr\b/.test(attrs)) kind = 'expr';
|
|
139
|
+
else if (/\bserver\b/.test(attrs)) kind = 'server';
|
|
140
|
+
else if (/\bisomorphic\b/.test(attrs)) kind = 'isomorphic';
|
|
141
|
+
else if (/\btype\s*=\s*"module"/.test(attrs)) kind = 'module';
|
|
142
|
+
|
|
143
|
+
let href: string | undefined;
|
|
144
|
+
const hrefMatch = /\bhref\s*=\s*"([^"]*)"/.exec(attrs);
|
|
145
|
+
if (hrefMatch) href = hrefMatch[1];
|
|
146
|
+
|
|
147
|
+
let endPos = -1;
|
|
148
|
+
let j = tagClose + 1;
|
|
149
|
+
while (j < markup.length) {
|
|
150
|
+
const idx = markup.indexOf('</', j);
|
|
151
|
+
if (idx === -1) break;
|
|
152
|
+
if (/^script\s*>/i.test(markup.slice(idx + 2).trimStart())) {
|
|
153
|
+
endPos = markup.indexOf('>', idx + 2) + 1;
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
j = idx + 2;
|
|
157
|
+
}
|
|
158
|
+
if (endPos === -1) {
|
|
159
|
+
i = tagClose + 1;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (kind !== null) {
|
|
164
|
+
const content = markup.slice(tagClose + 1, markup.lastIndexOf('</', endPos - 1)).trim();
|
|
165
|
+
scripts.push({ kind, content, href, start: i, end: endPos });
|
|
166
|
+
}
|
|
167
|
+
i = endPos;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
i++;
|
|
172
|
+
}
|
|
173
|
+
return { scripts, layouts };
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Extract and execute all `<script server>` tags in the given file, and strip
|
|
178
|
+
* them from the markup. Parse the remaining markup with Thunderous' `html` tagged template.
|
|
179
|
+
*
|
|
180
|
+
* @example
|
|
181
|
+
* Example HTML file:
|
|
182
|
+
* ```html
|
|
183
|
+
* <p>
|
|
184
|
+
* <script expr>
|
|
185
|
+
* `${greeting}, world!`
|
|
186
|
+
* </script>
|
|
187
|
+
* </p>
|
|
188
|
+
*
|
|
189
|
+
* <script server>
|
|
190
|
+
* export default {
|
|
191
|
+
* greeting: 'Hello'
|
|
192
|
+
* };
|
|
193
|
+
* </script>
|
|
194
|
+
* ```
|
|
195
|
+
*
|
|
196
|
+
* @example
|
|
197
|
+
* TypeScript usage:
|
|
198
|
+
* ```ts
|
|
199
|
+
* generateStaticTemplate('path/to/file.html').then((renderedMarkup) => {
|
|
200
|
+
* console.log(renderedMarkup); // Outputs roughly: <p>Hello, world!</p>
|
|
201
|
+
* });
|
|
202
|
+
* ```
|
|
203
|
+
*/
|
|
204
|
+
export const generateStaticTemplate = (filePath: string) => {
|
|
205
|
+
renderState.markup = readFileSync(filePath, 'utf-8');
|
|
206
|
+
|
|
207
|
+
const name = basename(filePath, extname(filePath));
|
|
208
|
+
|
|
209
|
+
// Set metadata context for the current page before server scripts run
|
|
210
|
+
const relativePath = relative(resolvedBaseDir, filePath);
|
|
211
|
+
const parentDir = dirname(relativePath).replace(/^\./, '');
|
|
212
|
+
const pathname = `/${parentDir}${name === 'index' ? '' : `/${name}`}`;
|
|
213
|
+
const titleWord = name === 'index' ? (pathname.split('/').pop() ?? '') : name;
|
|
214
|
+
const title = titleWord
|
|
215
|
+
.split(/[-_]/)
|
|
216
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
217
|
+
.join(' ');
|
|
218
|
+
const path = pathname.split('/').filter((segment) => segment !== '');
|
|
219
|
+
let crumbPathname = '/';
|
|
220
|
+
const breadcrumbs: Breadcrumb[] = [
|
|
221
|
+
{
|
|
222
|
+
// always add the home page
|
|
223
|
+
name: config.name,
|
|
224
|
+
pathname: crumbPathname,
|
|
225
|
+
},
|
|
226
|
+
];
|
|
227
|
+
// add each segment of the path as a breadcrumb
|
|
228
|
+
for (const segment of path) {
|
|
229
|
+
crumbPathname += `/${segment}`;
|
|
230
|
+
breadcrumbs.push({
|
|
231
|
+
name: segment,
|
|
232
|
+
pathname: crumbPathname,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
setMeta({
|
|
236
|
+
config,
|
|
237
|
+
pathname,
|
|
238
|
+
title,
|
|
239
|
+
filename: basename(filePath),
|
|
240
|
+
name,
|
|
241
|
+
breadcrumbs,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// ── Extract and apply layouts ──
|
|
245
|
+
const { layouts } = extractTags(renderState.markup);
|
|
246
|
+
for (let i = layouts.length - 1; i >= 0; i--) {
|
|
247
|
+
const l = layouts[i]!;
|
|
248
|
+
renderState.markup = renderState.markup.slice(0, l.start) + renderState.markup.slice(l.end);
|
|
249
|
+
}
|
|
250
|
+
renderState.markup = renderState.markup.trim();
|
|
251
|
+
|
|
252
|
+
for (const layout of layouts) {
|
|
253
|
+
const layoutPath = join(filePath.slice(0, filePath.lastIndexOf('/')), layout.href);
|
|
254
|
+
if (!existsSync(layoutPath)) {
|
|
255
|
+
console.warn(`\x1b[33mWarning: Layout file not found: ${layoutPath}\x1b[0m`);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
const layoutContent = readFileSync(layoutPath, 'utf-8');
|
|
259
|
+
const slotTag = '<slot></slot>';
|
|
260
|
+
const slotIndex = layoutContent.indexOf(slotTag);
|
|
261
|
+
if (slotIndex === -1) {
|
|
262
|
+
console.warn(`\x1b[33mWarning: No <slot></slot> found in ${layout.href}\x1b[0m`);
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
renderState.markup =
|
|
266
|
+
layoutContent.slice(0, slotIndex) + renderState.markup + layoutContent.slice(slotIndex + slotTag.length);
|
|
267
|
+
console.log(`\x1b[90mApplied layout: ${layout.href}\x1b[0m`);
|
|
268
|
+
}
|
|
269
|
+
// ── Extract and process scripts ──
|
|
270
|
+
const { scripts } = extractTags(renderState.markup);
|
|
271
|
+
const fileDir = filePath.slice(0, filePath.lastIndexOf('/'));
|
|
272
|
+
const safeValues: Record<string, unknown> = {};
|
|
273
|
+
const clientEntryFiles: string[] = [];
|
|
274
|
+
const tempFilesToCleanup: string[] = [];
|
|
275
|
+
|
|
276
|
+
// Map from scriptKey → replacement text (built during processing, applied later)
|
|
277
|
+
const scriptKey = (s: ParsedScript) => `${s.kind}|${s.href ?? ''}|${s.content}`;
|
|
278
|
+
const replacementMap = new Map<string, string>();
|
|
279
|
+
|
|
280
|
+
let scriptIndex = 0;
|
|
281
|
+
for (const script of scripts) {
|
|
282
|
+
const key = scriptKey(script);
|
|
283
|
+
|
|
284
|
+
// ── Resolve content: from href file or inline ──
|
|
285
|
+
let content = script.content;
|
|
286
|
+
let hrefAbsPath: string | undefined;
|
|
287
|
+
if (script.href) {
|
|
288
|
+
hrefAbsPath = resolve(fileDir, script.href);
|
|
289
|
+
if (!existsSync(hrefAbsPath)) {
|
|
290
|
+
console.warn(`\x1b[33mWarning: Script href file not found: ${hrefAbsPath}\x1b[0m`);
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if (script.kind === 'expr') {
|
|
294
|
+
content = readFileSync(hrefAbsPath, 'utf-8').trim();
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (script.kind === 'expr') {
|
|
299
|
+
// expr scripts are handled entirely in the replacement pass
|
|
300
|
+
replacementMap.set(key, '__expr__');
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ── Server/isomorphic: execute server-side to collect exported values ──
|
|
305
|
+
if (script.kind === 'server' || script.kind === 'isomorphic') {
|
|
306
|
+
let module: Record<string, unknown>;
|
|
307
|
+
if (hrefAbsPath) {
|
|
308
|
+
// href: require the file directly from source
|
|
309
|
+
delete outRequire.cache[outRequire.resolve(hrefAbsPath)];
|
|
310
|
+
module = outRequire(hrefAbsPath);
|
|
311
|
+
} else {
|
|
312
|
+
// inline: write temp .ts so outRequire can import it
|
|
313
|
+
const tsScriptFile = join(resolvedOutDir, `${name}-${scriptIndex}.tmp.ts`);
|
|
314
|
+
writeFileSync(tsScriptFile, `// @ts-nocheck\n${content}`, 'utf-8');
|
|
315
|
+
tempFilesToCleanup.push(tsScriptFile);
|
|
316
|
+
delete outRequire.cache[outRequire.resolve(tsScriptFile)];
|
|
317
|
+
module = outRequire(tsScriptFile);
|
|
318
|
+
}
|
|
319
|
+
const values = module['default'] ?? {};
|
|
320
|
+
for (const k in values) {
|
|
321
|
+
const val = (values as Record<string, unknown>)[k];
|
|
322
|
+
safeValues[k] = typeof val === 'string' ? escapeHtml(val) : val;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ── Build replacement text and collect client entry files ──
|
|
327
|
+
if (script.kind === 'server') {
|
|
328
|
+
// Server scripts are fully discarded from client output
|
|
329
|
+
replacementMap.set(key, '');
|
|
330
|
+
} else if (hrefAbsPath) {
|
|
331
|
+
// href isomorphic/module: output a <script src> pointing to the .js file
|
|
332
|
+
const jsSrc = script.href!.replace(/\.tsx?$/, '.js');
|
|
333
|
+
replacementMap.set(key, `<script type="module" src="${jsSrc}"></script>`);
|
|
334
|
+
// Resolve the outDir .js path for vendorization
|
|
335
|
+
const hrefRelPath = relative(resolvedBaseDir, hrefAbsPath);
|
|
336
|
+
const hrefOutJsPath = join(resolvedOutDir, hrefRelPath).replace(/\.tsx?$/, '.js');
|
|
337
|
+
clientEntryFiles.push(resolve(hrefOutJsPath));
|
|
338
|
+
} else {
|
|
339
|
+
// inline isomorphic/module: transpile in-process and inline the JS
|
|
340
|
+
const js = transpileTs(`// @ts-nocheck\n${content}`, `${name}-${scriptIndex}.ts`);
|
|
341
|
+
const fixedJs = js
|
|
342
|
+
.replace(/(import\s+.+?\s+from\s+['"](?:\.\.?\/|\/)[^'"]+?)\.tsx?(['"])/gm, '$1.js$2')
|
|
343
|
+
.replace(/(import\s+.+?\s+from\s+['"](?:\.\.?\/|\/)[^'"]+?)(?<!\.m?js)(['"])/gm, '$1.js$2');
|
|
344
|
+
replacementMap.set(key, `<script type="module">\n${fixedJs}\n</script>`);
|
|
345
|
+
|
|
346
|
+
// Write .js to outDir for vendorization
|
|
347
|
+
const jsOutPath = join(resolvedOutDir, `${name}-${scriptIndex}.tmp.js`);
|
|
348
|
+
writeFileSync(jsOutPath, js, 'utf-8');
|
|
349
|
+
tempFilesToCleanup.push(jsOutPath);
|
|
350
|
+
clientEntryFiles.push(resolve(jsOutPath));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
scriptIndex++;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ── Re-parse and apply all replacements in one reverse pass ──
|
|
357
|
+
const { scripts: freshScripts } = extractTags(renderState.markup);
|
|
358
|
+
for (let i = freshScripts.length - 1; i >= 0; i--) {
|
|
359
|
+
const script = freshScripts[i]!;
|
|
360
|
+
const key = scriptKey(script);
|
|
361
|
+
const replacement = replacementMap.get(key);
|
|
362
|
+
if (replacement === undefined) continue;
|
|
363
|
+
|
|
364
|
+
let text: string;
|
|
365
|
+
if (replacement === '__expr__') {
|
|
366
|
+
// Evaluate expr: content may come from href file
|
|
367
|
+
let content = script.content;
|
|
368
|
+
if (script.href) {
|
|
369
|
+
const hrefPath = resolve(fileDir, script.href);
|
|
370
|
+
content = readFileSync(hrefPath, 'utf-8').trim();
|
|
371
|
+
}
|
|
372
|
+
const expression = content.replace(/;$/, '');
|
|
373
|
+
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
|
374
|
+
text = Function(
|
|
375
|
+
'html',
|
|
376
|
+
'escapeHtml',
|
|
377
|
+
'raw',
|
|
378
|
+
...Object.keys(safeValues),
|
|
379
|
+
`'use strict'; return html\`\${${expression}}\`;`,
|
|
380
|
+
)(html, escapeHtml, raw, ...Object.values(safeValues));
|
|
381
|
+
} else {
|
|
382
|
+
text = replacement;
|
|
383
|
+
}
|
|
384
|
+
renderState.markup = renderState.markup.slice(0, script.start) + text + renderState.markup.slice(script.end);
|
|
385
|
+
}
|
|
386
|
+
renderState.markup = renderState.markup.trim();
|
|
387
|
+
|
|
388
|
+
// Return the final rendered markup, client entry files, and cleanup function
|
|
389
|
+
return {
|
|
390
|
+
markup: renderState.markup,
|
|
391
|
+
clientEntryFiles,
|
|
392
|
+
cleanup: () => {
|
|
393
|
+
for (const file of tempFilesToCleanup) {
|
|
394
|
+
if (existsSync(file)) {
|
|
395
|
+
rmSync(file);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Generate import maps from entry files using vendorization.
|
|
404
|
+
* Returns the import map JSON string that can be injected into HTML.
|
|
405
|
+
*/
|
|
406
|
+
export const generateImportMap = (entryFiles: string[]): string => {
|
|
407
|
+
if (entryFiles.length === 0) {
|
|
408
|
+
return JSON.stringify({ imports: {} }, null, 2);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
console.log(`\x1b[90mVendorizing dependencies from ${entryFiles.length} entry file(s)...\x1b[0m`);
|
|
412
|
+
processNodeModules(entryFiles, config.outDir);
|
|
413
|
+
console.log(`\x1b[32m✓ Import map generated\x1b[0m`);
|
|
414
|
+
|
|
415
|
+
const importMapPath = join(config.outDir, 'importmap.json');
|
|
416
|
+
if (existsSync(importMapPath)) {
|
|
417
|
+
return readFileSync(importMapPath, 'utf-8');
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return JSON.stringify({ imports: {} }, null, 2);
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Inject an import map into HTML content.
|
|
425
|
+
* If an import map script already exists, it will be replaced.
|
|
426
|
+
* Otherwise, it will be injected before the first script tag or at the end.
|
|
427
|
+
*/
|
|
428
|
+
export const injectImportMap = (html: string, importMapJson: string): string => {
|
|
429
|
+
const importMapTag = `<script type="importmap">\n${importMapJson}\n</script>`;
|
|
430
|
+
|
|
431
|
+
// Check if there's already an import map and replace it
|
|
432
|
+
const existingImportMapRegex = /<script\s+type="importmap"[^>]*>[\s\S]*?<\/script>/i;
|
|
433
|
+
if (existingImportMapRegex.test(html)) {
|
|
434
|
+
return html.replace(existingImportMapRegex, importMapTag);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Otherwise inject before first script tag
|
|
438
|
+
const firstScriptMatch = /<script/i.exec(html);
|
|
439
|
+
if (firstScriptMatch?.index !== undefined) {
|
|
440
|
+
return html.slice(0, firstScriptMatch.index) + importMapTag + '\n' + html.slice(firstScriptMatch.index);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// No script tag found, inject before </head> if possible
|
|
444
|
+
const headCloseMatch = /<\/head>/i.exec(html);
|
|
445
|
+
if (headCloseMatch?.index !== undefined) {
|
|
446
|
+
return html.slice(0, headCloseMatch.index) + importMapTag + '\n' + html.slice(headCloseMatch.index);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Last resort: add at the end
|
|
450
|
+
return html + '\n' + importMapTag;
|
|
451
|
+
};
|
package/src/index.ts
ADDED