Add import template into tui. Fix tests that fail on macos. Fix some updates.
This commit is contained in:
194
src/utils/load-template-from-file.ts
Normal file
194
src/utils/load-template-from-file.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Loads template file contents for {@link Engine.importTemplate}.
|
||||
*
|
||||
* - `.json` files are read directly.
|
||||
* - `.ts`, `.js`, `.mts`, `.cts`, `.mjs`, `.cjs` files are evaluated in a
|
||||
* short-lived child process and serialized to Extended JSON on stdout.
|
||||
* TypeScript templates (and the loader in dev) run via tsx; plain JS uses node.
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/** Extensions loaded via subprocess module evaluation. */
|
||||
const MODULE_TEMPLATE_EXTENSIONS = new Set([
|
||||
".ts",
|
||||
".tsx",
|
||||
".mts",
|
||||
".cts",
|
||||
".js",
|
||||
".jsx",
|
||||
".mjs",
|
||||
".cjs",
|
||||
]);
|
||||
|
||||
/** Maximum time allowed for a template module child process. */
|
||||
const MODULE_LOAD_TIMEOUT_MS = 30_000;
|
||||
|
||||
/** Maximum stdout size from the loader child (50 MiB). */
|
||||
const MODULE_LOAD_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Thrown when a template file cannot be read or loaded.
|
||||
*/
|
||||
export class TemplateLoadError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "TemplateLoadError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the tsx CLI binary shipped with this package.
|
||||
*/
|
||||
function resolveTsxCliPath(): string {
|
||||
const require = createRequire(import.meta.url);
|
||||
const tsxPackageJsonPath = require.resolve("tsx/package.json");
|
||||
return path.join(path.dirname(tsxPackageJsonPath), "dist/cli.mjs");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the loader script path for dev (.ts) and production (.js) layouts.
|
||||
*/
|
||||
function resolveTemplateModuleLoaderPath(): string {
|
||||
const directory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const compiledLoaderPath = path.join(directory, "template-module-loader.js");
|
||||
if (fs.existsSync(compiledLoaderPath)) {
|
||||
return compiledLoaderPath;
|
||||
}
|
||||
|
||||
const sourceLoaderPath = path.join(directory, "template-module-loader.ts");
|
||||
if (fs.existsSync(sourceLoaderPath)) {
|
||||
return sourceLoaderPath;
|
||||
}
|
||||
|
||||
throw new TemplateLoadError(
|
||||
"Template module loader script was not found in the xo-cli package.",
|
||||
);
|
||||
}
|
||||
|
||||
/** TypeScript extensions that require tsx to evaluate the template module. */
|
||||
const TYPESCRIPT_TEMPLATE_EXTENSIONS = new Set([
|
||||
".ts",
|
||||
".tsx",
|
||||
".mts",
|
||||
".cts",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Loads a TS/JS template module in an isolated child process.
|
||||
* Returns Extended JSON suitable for {@link parseTemplate}.
|
||||
*/
|
||||
async function loadTemplateModuleViaChildProcess(
|
||||
absolutePath: string,
|
||||
): Promise<string> {
|
||||
const loaderPath = resolveTemplateModuleLoaderPath();
|
||||
const extension = path.extname(absolutePath).toLowerCase();
|
||||
const loaderIsTypeScript = loaderPath.endsWith(".ts");
|
||||
const useTsx =
|
||||
TYPESCRIPT_TEMPLATE_EXTENSIONS.has(extension) || loaderIsTypeScript;
|
||||
const executable = useTsx ? resolveTsxCliPath() : process.execPath;
|
||||
const args = [loaderPath, absolutePath];
|
||||
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const child = spawn(executable, args, {
|
||||
cwd: path.dirname(absolutePath),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let stdoutBytes = 0;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill("SIGTERM");
|
||||
reject(
|
||||
new TemplateLoadError(
|
||||
`Template module load timed out after ${MODULE_LOAD_TIMEOUT_MS / 1000}s`,
|
||||
),
|
||||
);
|
||||
}, MODULE_LOAD_TIMEOUT_MS);
|
||||
|
||||
child.stdout.on("data", (chunk: Buffer | string) => {
|
||||
const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
||||
stdoutBytes += Buffer.byteLength(text, "utf8");
|
||||
if (stdoutBytes > MODULE_LOAD_MAX_BUFFER_BYTES) {
|
||||
child.kill("SIGTERM");
|
||||
reject(
|
||||
new TemplateLoadError(
|
||||
"Template module output exceeded the maximum allowed size.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
stdout += text;
|
||||
});
|
||||
|
||||
child.stderr.on("data", (chunk: Buffer | string) => {
|
||||
stderr += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(
|
||||
new TemplateLoadError(
|
||||
`Failed to start template module loader: ${error.message}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (code !== 0) {
|
||||
reject(
|
||||
new TemplateLoadError(
|
||||
stderr.trim() ||
|
||||
`Template module loader exited with code ${code ?? "unknown"}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (stdout.trim().length === 0) {
|
||||
reject(new TemplateLoadError("Template module loader returned no output."));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(stdout);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads template contents from disk.
|
||||
*
|
||||
* @param filePath - Absolute or relative path to a JSON or module template file.
|
||||
* @returns Extended JSON string for {@link Engine.importTemplate}.
|
||||
*/
|
||||
export async function loadTemplateFromFile(filePath: string): Promise<string> {
|
||||
const absolutePath = path.resolve(filePath);
|
||||
|
||||
if (!fs.existsSync(absolutePath)) {
|
||||
throw new TemplateLoadError(`Template file does not exist: ${absolutePath}`);
|
||||
}
|
||||
|
||||
const extension = path.extname(absolutePath).toLowerCase();
|
||||
|
||||
if (extension === ".json") {
|
||||
return fs.promises.readFile(absolutePath, "utf8");
|
||||
}
|
||||
|
||||
if (MODULE_TEMPLATE_EXTENSIONS.has(extension)) {
|
||||
return loadTemplateModuleViaChildProcess(absolutePath);
|
||||
}
|
||||
|
||||
throw new TemplateLoadError(
|
||||
`Unsupported template file extension "${extension}". ` +
|
||||
"Use .json or a JavaScript/TypeScript module that exports an XOTemplate.",
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user