1// This implements the ability to use TS, TSX, and more plugins
2// in Node.js. It cannot be built on the ES module loader,
3// because there is no exposed way to replace modules when
4// needed (see nodejs#49442).
5//
6// It also allows using a simple compile cache, which is used by
7// the site generator to determine when code changes.
8export const projectRoot = path.resolve(import.meta.dirname, "../");
9export const projectSrc = path.resolve(projectRoot, "src");
10
11using _ = null;
12
13// Create a project-relative require. For convenience, it is generic-typed.
14export const load = createRequire(
15 pathToFileURL(path.join(projectRoot, "run.js")).toString(),
16) as {
17 <T = unknown>(id: string): T;
18 extensions: NodeJS.Dict<(mod: NodeJS.Module, file: string) => unknown>;
19 cache: NodeJS.Dict<NodeJS.Module>;
20 resolve: (id: string, o?: { paths: string[] }) => string;
21};
22export const { cache } = load;
23
24// Register extensions by overwriting `require.extensions`
25const require = load;
26const exts = require.extensions;
27exts[".ts"] = loadEsbuild;
28exts[".tsx"] = loadEsbuild;
29exts[".jsx"] = loadEsbuild;
30exts[".marko"] = loadMarko;
31exts[".mdo"] = loadMarkodown;
32exts[".css"] = loadCss;
33
34// Intercept all module load calls to track CSS imports + file times.
35export interface FileStat {
36 cssImportsRecursive: string[] | null;
37 lastModified: number;
38 imports: string[];
39}
40const fileStats = new Map<string, FileStat>();
41
42export function getFileStat(filepath: string) {
43 return fileStats.get(path.resolve(filepath));
44}
45
46function shouldTrackPath(filename: string) {
47 return !filename.includes("node_modules");
48}
49
50const Module = load<typeof import("node:module")>("node:module");
51const ModulePrototypeUnderscoreCompile = Module.prototype._compile;
52Module.prototype._compile = function(
53 content: string,
54 filename: string,
55 format: "module" | "commonjs",
56) {
57 const result = ModulePrototypeUnderscoreCompile.call(
58 this,
59 content,
60 filename,
61 format,
62 );
63 if (shouldTrackPath(filename)) {
64 const stat = fs.statSync(filename);
65 const cssImportsMaybe: string[] = [];
66 const imports: string[] = [];
67 for (const childModule of this.children) {
68 const { filename: file, cloverClientRefs } = childModule;
69 if (file.endsWith(".css")) cssImportsMaybe.push(file);
70 else {
71 const child = fileStats.get(file);
72 if (!child) continue;
73 const { cssImportsRecursive } = child;
74 if (cssImportsRecursive) cssImportsMaybe.push(...cssImportsRecursive);
75 imports.push(file);
76 (childModule.cloverImporters ??= []).push(this);
77 if (cloverClientRefs && cloverClientRefs.length > 0) {
78 (this.cloverClientRefs ??= []).push(...cloverClientRefs);
79 }
80 }
81 }
82 fileStats.set(filename, {
83 cssImportsRecursive: cssImportsMaybe.length > 0
84 ? Array.from(new Set(cssImportsMaybe))
85 : null,
86 imports,
87 lastModified: Math.floor(stat.mtimeMs),
88 });
89 }
90 return result;
91};
92
93function loadEsbuild(module: NodeJS.Module, filepath: string) {
94 return loadEsbuildCode(module, filepath, fs.readFileSync(filepath, "utf8"));
95}
96
97interface LoadOptions {
98 scannedClientRefs?: string[];
99}
100export function loadEsbuildCode(
101 module: NodeJS.Module,
102 filepath: string,
103 src: string,
104 opt: LoadOptions = {},
105) {
106 if (filepath === import.meta.filename) {
107 module.exports = self;
108 return;
109 }
110 let loader: any = "tsx";
111 if (filepath.endsWith(".ts")) loader = "ts";
112 else if (filepath.endsWith(".jsx")) loader = "jsx";
113 else if (filepath.endsWith(".js")) loader = "js";
114 if (opt.scannedClientRefs) {
115 module.cloverClientRefs = opt.scannedClientRefs;
116 } else {
117 let { code, refs } = resolveClientRefs(src, filepath);
118 module.cloverClientRefs = refs;
119 src = code;
120 }
121 if (src.includes("import.meta")) {
122 src = `
123 import.meta.url = ${JSON.stringify(pathToFileURL(filepath).toString())};
124 import.meta.dirname = ${JSON.stringify(path.dirname(filepath))};
125 import.meta.filename = ${JSON.stringify(filepath)};
126 import.meta.resolve = (r) => require('url').pathToFileURL(require.resolve(r));
127 `
128 .trim()
129 .replace(/[\n\s]/g, "") + src;
130 }
131 src = esbuild.transformSync(src, {
132 loader,
133 format: "cjs",
134 target: "esnext",
135 jsx: "automatic",
136 jsxImportSource: "#jsx",
137 jsxDev: true,
138 sourcemap: "inline",
139 sourcefile: path.basename(filepath),
140 sourceRoot: path.dirname(filepath),
141 }).code;
142 return module._compile(src, filepath, "commonjs");
143}
144
145export function resolveClientRef(sourcePath: string, ref: string) {
146 const filePath = resolveFrom(sourcePath, ref);
147 if (!filePath.endsWith(".client.ts")) {
148 throw new Error("addScript must take a .client.ts");
149 }
150 return path.relative(projectSrc, filePath);
151}
152
153let lazyMarko: typeof import("./marko.ts") | null = null;
154function loadMarko(module: NodeJS.Module, filepath: string) {
155 lazyMarko ??= require<typeof import("./marko.ts")>("./framework/marko.ts");
156 lazyMarko.loadMarko(module, filepath);
157}
158function loadMarkodown(module: NodeJS.Module, filepath: string) {
159 lazyMarko ??= require<typeof import("./marko.ts")>("./framework/marko.ts");
160 lazyMarko.loadMarkodown(module, filepath);
161}
162
163function loadCss(module: NodeJS.Module, _filepath: string) {
164 module.exports = {};
165}
166
167export function reloadRecursive(filepath: string) {
168 filepath = path.resolve(filepath);
169 const existing = cache[filepath];
170 if (existing) deleteRecursiveInner(filepath, existing);
171 fileStats.clear();
172 return require(filepath);
173}
174
175export function unload(filepath: string) {
176 lazyMarko?.markoCache.delete(filepath);
177 filepath = path.resolve(filepath);
178 const module = cache[filepath];
179 if (!module) return;
180 delete cache[filepath];
181 for (const importer of module.cloverImporters ?? []) {
182 unload(importer.filename);
183 }
184}
185
186function deleteRecursiveInner(id: string, module: any) {
187 if (id.includes(path.sep + "node_modules" + path.sep)) {
188 return;
189 }
190 delete cache[id];
191 for (const child of module.children) {
192 if (child.filename.includes("/engine/")) return;
193 const existing = cache[child.filename];
194 if (existing === child) deleteRecursiveInner(child.filename, existing);
195 }
196}
197
198export function getCssImports(filepath: string) {
199 filepath = path.resolve(filepath);
200 if (!require.cache[filepath]) throw new Error(filepath + " was never loaded");
201 return fileStats.get(filepath)?.cssImportsRecursive ?? [];
202}
203
204export function getClientScriptRefs(filepath: string) {
205 filepath = path.resolve(filepath);
206 const module = require.cache[filepath];
207 if (!module) throw new Error(filepath + " was never loaded");
208 return module.cloverClientRefs ?? [];
209}
210
211export function getSourceCode(filepath: string) {
212 filepath = path.resolve(filepath);
213 const module = require.cache[filepath];
214 if (!module) throw new Error(filepath + " was never loaded");
215 if (!module.cloverSourceCode) {
216 throw new Error(filepath + " did not record source code");
217 }
218 return module.cloverSourceCode;
219}
220
221export function resolveFrom(src: string, dest: string) {
222 try {
223 return createRequire(src).resolve(dest);
224 } catch (err: any) {
225 if (
226 err.code === "MODULE_NOT_FOUND"
227 && err.requireStack
228 && err.requireStack.length <= 1
229 ) {
230 err.message = err.message.split("\n")[0] + " from '" + src + "'";
231 }
232 throw err;
233 }
234}
235
236const importRegExp = /import\s+(\*\sas\s([a-zA-Z0-9$_]+)|{[^}]+})\s+from\s+(?:"#sitegen"|'#sitegen')/s;
237const getSitegenAddScriptRegExp = /addScript(?:\s+as\s+([a-zA-Z0-9$_]+))?/;
238interface ResolvedClientRefs {
239 code: string;
240 refs: string[];
241}
242export function resolveClientRefs(
243 code: string,
244 filepath: string,
245): ResolvedClientRefs {
246 // This match finds a call to 'import ... from "#sitegen"'
247 const importMatch = code.match(importRegExp);
248 if (!importMatch) return { code, refs: [] };
249 const items = UNWRAP(importMatch[1]);
250 let identifier = "";
251 if (items.startsWith("{")) {
252 const clauseMatch = items.match(getSitegenAddScriptRegExp);
253 if (!clauseMatch) return { code, refs: [] }; // did not import
254 identifier = clauseMatch[1] || "addScript";
255 } else if (items.startsWith("*")) {
256 identifier = importMatch[2] + "\\s*\\.\\s*addScript";
257 } else {
258 throw new Error("Impossible");
259 }
260 identifier = identifier.replaceAll("$", "\\$"); // only needed escape
261 const findCallsRegExp = new RegExp(
262 `\\b(${identifier})\\s*\\(("[^"]+"|'[^']+')\\)`,
263 "gs",
264 );
265 const scannedClientRefs = new Set<string>();
266 code = code.replace(findCallsRegExp, (_, call, arg) => {
267 const ref = JSON.parse(`"${arg.slice(1, -1)}"`);
268 const resolved = resolveClientRef(filepath, ref);
269 scannedClientRefs.add(resolved);
270 return `${call}(${JSON.stringify(getScriptId(resolved))})`;
271 });
272 return { code, refs: Array.from(scannedClientRefs) };
273}
274
275export function getScriptId(file: string) {
276 return (
277 path.isAbsolute(file) ? path.relative(projectSrc, file) : file
278 ).replaceAll("\\", "/");
279}
280
281declare global {
282 namespace NodeJS {
283 interface Module {
284 cloverClientRefs?: string[];
285 cloverSourceCode?: string;
286 cloverImporters?: Module[];
287
288 _compile(
289 this: NodeJS.Module,
290 content: string,
291 filepath: string,
292 format: "module" | "commonjs",
293 ): unknown;
294 }
295 }
296}
297declare module "node:module" {
298 export function _resolveFilename(id: string, parent: NodeJS.Module): unknown;
299}
300
301import * as fs from "#sitegen/fs";
302import { UNWRAP } from "@clo/lib/assert";
303import * as esbuild from "esbuild";
304import { Buffer } from "node:buffer";
305import { createRequire } from "node:module";
306import * as path from "node:path";
307import { pathToFileURL } from "node:url";
308import * as self from "./hot.ts";