246 lines
7.5 KiB
TypeScript
246 lines
7.5 KiB
TypeScript
// This implements the ability to use TS, TSX, and more plugins
|
|
// in Node.js. It cannot be built on the ES module loader,
|
|
// because there is no exposed way to replace modules when
|
|
// needed (see nodejs#49442).
|
|
//
|
|
// It also allows using a simple compile cache, which is used by
|
|
// the site generator to determine when code changes.
|
|
export const projectRoot = path.resolve(import.meta.dirname, "../");
|
|
export const projectSrc = path.resolve(projectRoot, "src");
|
|
|
|
// Create a project-relative require. For convenience, it is generic-typed.
|
|
export const load = createRequire(
|
|
pathToFileURL(path.join(projectRoot, "run.js")).toString(),
|
|
) as {
|
|
<T = unknown>(id: string): T;
|
|
extensions: NodeJS.Dict<(mod: NodeJS.Module, file: string) => unknown>;
|
|
cache: NodeJS.Dict<NodeJS.Module>;
|
|
resolve: (id: string, o?: { paths: string[] }) => string;
|
|
};
|
|
export const { cache } = load;
|
|
|
|
// Register extensions by overwriting `require.extensions`
|
|
const require = load;
|
|
const exts = require.extensions;
|
|
exts[".ts"] = loadEsbuild;
|
|
exts[".tsx"] = loadEsbuild;
|
|
exts[".jsx"] = loadEsbuild;
|
|
exts[".marko"] = loadMarko;
|
|
exts[".mdx"] = loadMdx;
|
|
exts[".css"] = loadCss;
|
|
|
|
// Intercept all module load calls to track CSS imports + file times.
|
|
export interface FileStat {
|
|
cssImportsRecursive: string[] | null;
|
|
lastModified: number;
|
|
imports: string[];
|
|
/* Used by 'incremental.ts' */
|
|
srcIds: string[];
|
|
}
|
|
let fsGraph = new Map<string, FileStat>();
|
|
export function setFsGraph(g: Map<string, FileStat>) {
|
|
if (fsGraph.size > 0) {
|
|
throw new Error("Cannot restore fsGraph when it has been written into");
|
|
}
|
|
fsGraph = g;
|
|
}
|
|
export function getFsGraph() {
|
|
return fsGraph;
|
|
}
|
|
|
|
function shouldTrackPath(filename: string) {
|
|
return !filename.includes("node_modules") &&
|
|
!filename.includes(import.meta.dirname);
|
|
}
|
|
|
|
const Module = load<typeof import("node:module")>("node:module");
|
|
const ModulePrototypeUnderscoreCompile = Module.prototype._compile;
|
|
Module.prototype._compile = function (
|
|
content: string,
|
|
filename: string,
|
|
format: "module" | "commonjs",
|
|
) {
|
|
fs.writeMkdirSync(
|
|
".clover/debug-transpilation/" +
|
|
path.relative(projectRoot, filename).replaceAll("\\", "/").replaceAll(
|
|
"../",
|
|
"_/",
|
|
).replaceAll("/", "."),
|
|
content,
|
|
);
|
|
const result = ModulePrototypeUnderscoreCompile.call(
|
|
this,
|
|
content,
|
|
filename,
|
|
format,
|
|
);
|
|
const stat = fs.statSync(filename);
|
|
if (shouldTrackPath(filename)) {
|
|
const cssImportsMaybe: string[] = [];
|
|
const imports: string[] = [];
|
|
for (const { filename: file } of this.children) {
|
|
const relative = path.relative(projectRoot, file);
|
|
if (file.endsWith(".css")) cssImportsMaybe.push(relative);
|
|
else {
|
|
const child = fsGraph.get(relative);
|
|
if (!child) continue;
|
|
const { cssImportsRecursive } = child;
|
|
if (cssImportsRecursive) cssImportsMaybe.push(...cssImportsRecursive);
|
|
imports.push(relative);
|
|
}
|
|
}
|
|
const relative = path.relative(projectRoot, filename);
|
|
fsGraph.set(relative, {
|
|
cssImportsRecursive: cssImportsMaybe.length > 0
|
|
? Array.from(new Set(cssImportsMaybe))
|
|
: null,
|
|
imports,
|
|
lastModified: stat.mtimeMs,
|
|
srcIds: [],
|
|
});
|
|
}
|
|
return result;
|
|
};
|
|
|
|
// Implement @/ prefix
|
|
const ModuleUnderscoreResolveFilename = Module._resolveFilename;
|
|
Module._resolveFilename = (...args) => {
|
|
if (args[0].startsWith("@/")) {
|
|
const replacedPath = "." + args[0].slice(1);
|
|
try {
|
|
return require.resolve(replacedPath, { paths: [projectSrc] });
|
|
} catch (err: any) {
|
|
if (err.code === "MODULE_NOT_FOUND" && err.requireStack.length <= 1) {
|
|
err.message.replace(replacedPath, args[0]);
|
|
}
|
|
}
|
|
}
|
|
return ModuleUnderscoreResolveFilename(...args);
|
|
};
|
|
|
|
function loadEsbuild(module: NodeJS.Module, filepath: string) {
|
|
let src = fs.readFileSync(filepath, "utf8");
|
|
return loadEsbuildCode(module, filepath, src);
|
|
}
|
|
|
|
function loadEsbuildCode(module: NodeJS.Module, filepath: string, src: string) {
|
|
if (filepath === import.meta.filename) {
|
|
module.exports = self;
|
|
return;
|
|
}
|
|
|
|
let loader: any = "tsx";
|
|
if (filepath.endsWith(".ts")) loader = "ts";
|
|
else if (filepath.endsWith(".jsx")) loader = "jsx";
|
|
else if (filepath.endsWith(".js")) loader = "js";
|
|
if (src.includes("import.meta")) {
|
|
src = `
|
|
import.meta.url = ${JSON.stringify(pathToFileURL(filepath).toString())};
|
|
import.meta.dirname = ${JSON.stringify(path.dirname(filepath))};
|
|
import.meta.filename = ${JSON.stringify(filepath)};
|
|
` + src;
|
|
}
|
|
src = esbuild.transformSync(src, {
|
|
loader,
|
|
format: "cjs",
|
|
target: "esnext",
|
|
jsx: "automatic",
|
|
jsxImportSource: "#ssr",
|
|
}).code;
|
|
return module._compile(src, filepath, "commonjs");
|
|
}
|
|
|
|
function loadMarko(module: NodeJS.Module, filepath: string) {
|
|
let src = fs.readFileSync(filepath, "utf8");
|
|
// A non-standard thing here is Clover Sitegen implements
|
|
// its own client side scripting stuff, so it overrides
|
|
// bare client import statements to it's own usage.
|
|
if (src.match(/^\s*client\s+import\s+["']/m)) {
|
|
src = src.replace(
|
|
/^\s*client\s+import\s+("[^"]+|'[^']+)[^\n]+/m,
|
|
"<CloverScriptInclude src=$1 />",
|
|
) + '\nimport { Script as CloverScriptInclude } from "#sitegen";';
|
|
}
|
|
|
|
src = marko.compileSync(src, filepath).code;
|
|
src = src.replace("marko/debug/html", "#ssr/marko");
|
|
return loadEsbuildCode(module, filepath, src);
|
|
}
|
|
|
|
function loadMdx(module: NodeJS.Module, filepath: string) {
|
|
const input = fs.readFileSync(filepath);
|
|
const out = mdx.compileSync(input, { jsxImportSource: "#ssr" }).value;
|
|
const src = typeof out === "string" ? out : Buffer.from(out).toString("utf8");
|
|
return loadEsbuildCode(module, filepath, src);
|
|
}
|
|
|
|
function loadCss(module: NodeJS.Module, _filepath: string) {
|
|
module.exports = {};
|
|
}
|
|
|
|
export function reloadRecursive(filepath: string) {
|
|
filepath = path.resolve(filepath);
|
|
const existing = cache[filepath];
|
|
if (existing) deleteRecursive(filepath, existing);
|
|
fsGraph.clear();
|
|
return require(filepath);
|
|
}
|
|
|
|
function deleteRecursive(id: string, module: any) {
|
|
if (id.includes(path.sep + "node_modules" + path.sep)) {
|
|
return;
|
|
}
|
|
delete cache[id];
|
|
for (const child of module.children) {
|
|
if (child.filename.includes("/engine/")) return;
|
|
const existing = cache[child.filename];
|
|
if (existing === child) deleteRecursive(child.filename, existing);
|
|
}
|
|
}
|
|
|
|
export function getCssImports(filepath: string) {
|
|
filepath = path.resolve(filepath);
|
|
if (!require.cache[filepath]) throw new Error(filepath + " was never loaded");
|
|
return fsGraph.get(path.relative(projectRoot, filepath))
|
|
?.cssImportsRecursive ?? [];
|
|
}
|
|
|
|
export function resolveFrom(src: string, dest: string) {
|
|
try {
|
|
return createRequire(src).resolve(dest);
|
|
} catch (err: any) {
|
|
if (err.code === "MODULE_NOT_FOUND" && err.requireStack.length <= 1) {
|
|
err.message = err.message.split("\n")[0] + " from '" + src + "'";
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
declare global {
|
|
namespace NodeJS {
|
|
interface Module {
|
|
_compile(
|
|
this: NodeJS.Module,
|
|
content: string,
|
|
filepath: string,
|
|
format: "module" | "commonjs",
|
|
): unknown;
|
|
}
|
|
}
|
|
}
|
|
declare module "node:module" {
|
|
export function _resolveFilename(
|
|
id: string,
|
|
parent: NodeJS.Module,
|
|
): unknown;
|
|
}
|
|
|
|
import * as fs from "./fs.ts";
|
|
import * as path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import * as esbuild from "esbuild";
|
|
import * as marko from "@marko/compiler";
|
|
import { createRequire } from "node:module";
|
|
import * as mdx from "@mdx-js/mdx";
|
|
import * as self from "./hot.ts";
|
|
import { Buffer } from "node:buffer";
|