diff --git a/.sonarcloud.properties b/.sonarcloud.properties new file mode 100644 index 0000000..bc159bf --- /dev/null +++ b/.sonarcloud.properties @@ -0,0 +1,5 @@ +sonar.projectKey=Univers42_markdown-engine +sonar.organization=univers42 +sonar.sources=markdown,src,index.ts,blocks.ts,inline.ts,dom.ts,terminal.ts,tableConfig.ts,react.tsx +sonar.tests=tests,bench +sonar.exclusions=node_modules/**,dist/** diff --git a/Dockerfile b/Dockerfile index 0216964..d323214 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,15 +23,19 @@ RUN npm run build FROM deps AS prod WORKDIR /app ENV NODE_ENV=production -COPY --from=build /app/dist ./dist -COPY --from=build /app/playground/public ./playground/public -COPY --from=build /app/vite.config.ts ./vite.config.ts +USER node +COPY --from=build --chown=node:node /app/dist ./dist +COPY --from=build --chown=node:node /app/playground/public ./playground/public +COPY --from=build --chown=node:node /app/vite.config.ts ./vite.config.ts EXPOSE 3000 CMD ["npm", "run", "start"] FROM deps AS dev WORKDIR /app -ENV NODE_ENV=development -COPY . . +USER node +COPY --chown=node:node package.json package-lock.json tsconfig.json vite.config.ts markdown.ts renderCore.ts ./ +COPY --chown=node:node src ./src +COPY --chown=node:node markdown ./markdown +COPY --chown=node:node playground ./playground EXPOSE 3000 -CMD ["npm", "run", "dev"] \ No newline at end of file +CMD ["npm", "run", "dev"] diff --git a/bench/benchmarks.cjs b/bench/benchmarks.cjs index 2d882ff..5b4cae1 100644 --- a/bench/benchmarks.cjs +++ b/bench/benchmarks.cjs @@ -10,43 +10,41 @@ /* */ /* ************************************************************************** */ -const { join, resolve } = require("node:path"); -const vm = require("node:vm"); +const { join, resolve, basename } = require("node:path"); +const { mkdirSync, existsSync, renameSync } = require("node:fs"); const esbuild = require("esbuild"); const { Bench } = require("tinybench"); const MARKENGINE_ROOT = resolve(__dirname, ".."); -const NODE_GLOBAL = { - TEXT_NODE: 3, - ELEMENT_NODE: 1, -}; +const CACHE_DIR = join(MARKENGINE_ROOT, "node_modules/.cache/bench-modules"); +mkdirSync(CACHE_DIR, { recursive: true }); + +const NODE_GLOBAL = { TEXT_NODE: 3, ELEMENT_NODE: 1 }; +globalThis.Node = globalThis.Node || NODE_GLOBAL; function loadModule(relativeEntryPoint, root = MARKENGINE_ROOT) { - const bundle = esbuild.buildSync({ - entryPoints: [join(root, relativeEntryPoint)], - bundle: true, - platform: "node", - format: "cjs", - write: false, - tsconfig: join(root, "tsconfig.json"), - }); + const fullPath = resolve(root, relativeEntryPoint); + if (!fullPath.startsWith(root)) { + throw new Error("Invalid entry point: path traversal detected"); + } + const safeName = basename(relativeEntryPoint).replace(/[^a-zA-Z0-9_-]/g, "_"); + const outfile = join(CACHE_DIR, `${safeName}.${process.pid}.cjs`); + + if (!existsSync(outfile)) { + const tmpFile = `${outfile}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`; + esbuild.buildSync({ + entryPoints: [fullPath], + bundle: true, + platform: "node", + format: "cjs", + outfile: tmpFile, + tsconfig: join(root, "tsconfig.json"), + }); + renameSync(tmpFile, outfile); + } - const module = { exports: {} }; - vm.runInNewContext(bundle.outputFiles[0].text, { - module, - exports: module.exports, - require, - Node: NODE_GLOBAL, - console, - process, - Buffer, - setTimeout, - clearTimeout, - queueMicrotask, - URL, - URLSearchParams, - }); - return module.exports; + delete require.cache[require.resolve(outfile)]; + return require(outfile); } function createMarkdownDocument(targetBytes) { diff --git a/bench/referenceSource.cjs b/bench/referenceSource.cjs index 4d7c1a5..70d5c18 100644 --- a/bench/referenceSource.cjs +++ b/bench/referenceSource.cjs @@ -16,14 +16,16 @@ // as HEAD's — see abGuard.cjs and reference.json. const { execFileSync } = require("node:child_process"); -const { mkdtempSync, mkdirSync, writeFileSync, rmSync } = require("node:fs"); +const { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } = require("node:fs"); const { tmpdir } = require("node:os"); const { join, dirname, relative, sep } = require("node:path"); const MARKENGINE_ROOT = join(__dirname, ".."); +const GIT_BIN = process.platform === "win32" ? "git.exe" : (["/usr/bin/git", "/bin/git", "/usr/local/bin/git"].find(existsSync) || "/usr/bin/git"); + function git(args, cwd) { - return execFileSync("git", args, { cwd, encoding: "utf8" }); + return execFileSync(GIT_BIN, args, { cwd, encoding: "utf8" }); } function repoRoot() { diff --git a/bench/run.cjs b/bench/run.cjs index 7759b3a..b35b136 100644 --- a/bench/run.cjs +++ b/bench/run.cjs @@ -11,7 +11,7 @@ /* ************************************************************************** */ const { execFileSync } = require("node:child_process"); -const { writeFileSync } = require("node:fs"); +const { writeFileSync, existsSync } = require("node:fs"); const { join } = require("node:path"); const { runBenchmarks } = require("./benchmarks.cjs"); const { compareToReference, loadReferenceConfig } = require("./abGuard.cjs"); @@ -31,7 +31,8 @@ function printTable(result) { } function pinReference() { - const headSha = execFileSync("git", ["rev-parse", "HEAD"], { + const gitBin = process.platform === "win32" ? "git.exe" : (["/usr/bin/git", "/bin/git", "/usr/local/bin/git"].find(existsSync) || "/usr/bin/git"); + const headSha = execFileSync(gitBin, ["rev-parse", "HEAD"], { cwd: __dirname, encoding: "utf8", }).trim(); diff --git a/playground/public/app.js b/playground/public/app.js index 835b1bb..34e2d35 100644 --- a/playground/public/app.js +++ b/playground/public/app.js @@ -55,16 +55,29 @@ async function render() { const data = await response.json(); if (!response.ok) { - preview.innerHTML = `
${escapeHtml(data.error || "Preview failed")}`;
+ showError(data.error || "Preview failed");
ast.textContent = "";
return;
}
- preview.innerHTML = data.html;
+ showPreviewHtml(data.html);
preview.classList.toggle("source-mode", viewMode === "source");
ast.textContent = JSON.stringify(data.ast, null, 2);
}
+function showError(message) {
+ const pre = document.createElement("pre");
+ pre.className = "error";
+ pre.textContent = message;
+ preview.replaceChildren(pre);
+}
+
+function showPreviewHtml(html) {
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(html || "", "text/html");
+ preview.replaceChildren(...doc.body.childNodes);
+}
+
function escapeHtml(value) {
return value
.replaceAll("&", "&")
@@ -79,7 +92,7 @@ function scheduleRender() {
globalThis.clearTimeout(timer);
timer = globalThis.setTimeout(() => {
render().catch((error) => {
- preview.innerHTML = `${escapeHtml(error.message)}`;
+ showError(error.message);
});
}, 80);
}
@@ -254,5 +267,5 @@ sourceToggleButton.addEventListener("click", () => {
});
render().catch((error) => {
- preview.innerHTML = `${escapeHtml(error.message)}`;
+ showError(error.message);
});
diff --git a/tests/inline-formatting-regression.test.cjs b/tests/inline-formatting-regression.test.cjs
index 74e303a..51613fa 100644
--- a/tests/inline-formatting-regression.test.cjs
+++ b/tests/inline-formatting-regression.test.cjs
@@ -12,41 +12,38 @@
const test = require("node:test");
const assert = require("node:assert/strict");
-const vm = require("node:vm");
+const fs = require("node:fs");
+const path = require("node:path");
const esbuild = require("esbuild");
-const Node = {
+
+globalThis.Node = globalThis.Node || {
TEXT_NODE: 3,
ELEMENT_NODE: 1,
};
-// Anchored to the repo root, not the process cwd: a test that only passes when
-// invoked from one directory is a trap for the next runner.
-const ENGINE_ROOT = require("node:path").resolve(__dirname, "..");
+const ENGINE_ROOT = path.resolve(__dirname, "..");
+const CACHE_DIR = path.join(ENGINE_ROOT, "node_modules/.cache/test-inline-reg");
+fs.mkdirSync(CACHE_DIR, { recursive: true });
function loadModule(entryPoint) {
- const bundle = esbuild.buildSync({
- entryPoints: [require("node:path").join(ENGINE_ROOT, entryPoint)],
+ const fullPath = path.resolve(ENGINE_ROOT, entryPoint);
+ if (!fullPath.startsWith(ENGINE_ROOT)) {
+ throw new Error("Invalid entry point");
+ }
+ const safeName = path.basename(entryPoint).replace(/[^a-zA-Z0-9_-]/g, "_");
+ const outfile = path.join(CACHE_DIR, `${safeName}.cjs`);
+
+ esbuild.buildSync({
+ entryPoints: [fullPath],
bundle: true,
platform: "node",
format: "cjs",
- write: false,
- tsconfig: require("node:path").join(ENGINE_ROOT, "tsconfig.json"),
+ outfile,
+ tsconfig: path.join(ENGINE_ROOT, "tsconfig.json"),
});
- const module = { exports: {} };
- vm.runInNewContext(bundle.outputFiles[0].text, {
- module,
- exports: module.exports,
- require,
- Node,
- console,
- process,
- Buffer,
- setTimeout,
- clearTimeout,
- queueMicrotask,
- });
- return module.exports;
+ delete require.cache[require.resolve(outfile)];
+ return require(outfile);
}
const { applyInlineFormatting } = loadModule(
diff --git a/tests/support/loadMarkengine.cjs b/tests/support/loadMarkengine.cjs
index c918d17..fd97dca 100644
--- a/tests/support/loadMarkengine.cjs
+++ b/tests/support/loadMarkengine.cjs
@@ -1,75 +1,73 @@
"use strict";
-// Shared loader: bundles the TS engine entry with esbuild and evaluates it in a
-// fresh VM context (react stubbed) so .cjs tests can exercise the public API.
+// Shared loader: bundles the TS engine entry with esbuild to disk cache
+// so .cjs tests can exercise the public API safely in the current realm.
const { join, resolve } = require("node:path");
-const vm = require("node:vm");
+const { mkdirSync, writeFileSync, existsSync, renameSync } = require("node:fs");
const esbuild = require("esbuild");
const MARKENGINE_ROOT = resolve(__dirname, "..", "..");
+const CACHE_DIR = join(MARKENGINE_ROOT, "node_modules/.cache/loadMarkengine");
+mkdirSync(CACHE_DIR, { recursive: true });
-function bundle(entry) {
- return esbuild.buildSync({
- entryPoints: [join(MARKENGINE_ROOT, entry)],
- bundle: true,
- platform: "node",
- format: "cjs",
- external: ["react"],
- write: false,
- tsconfig: join(MARKENGINE_ROOT, "tsconfig.json"),
- }).outputFiles[0].text;
+const reactStubPath = join(CACHE_DIR, "react-stub.cjs");
+if (!existsSync(reactStubPath)) {
+ writeFileSync(
+ reactStubPath,
+ `"use strict";
+module.exports = {
+ Fragment: Symbol.for("react.fragment"),
+ createElement: (type, props, ...children) => ({ type, props: props ?? {}, children }),
+};
+`
+ );
}
-const localRequire = (id) =>
- id === "react"
- ? {
- Fragment: Symbol.for("react.fragment"),
- createElement: (type, props, ...children) => ({ type, props: props ?? {}, children }),
- }
- : require(id);
+const memoized = new Map();
+
+function bundleToDisk(entry) {
+ const fullPath = resolve(MARKENGINE_ROOT, entry);
+ if (!fullPath.startsWith(MARKENGINE_ROOT)) {
+ throw new Error("Invalid entry point: path traversal detected");
+ }
+ const safeName = entry.replace(/[/\\.]/g, "_");
+ const outfile = join(CACHE_DIR, `${safeName}.${process.pid}.cjs`);
+
+ if (!existsSync(outfile)) {
+ const tmpFile = `${outfile}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
+ esbuild.buildSync({
+ entryPoints: [fullPath],
+ bundle: true,
+ platform: "node",
+ format: "cjs",
+ alias: { react: reactStubPath },
+ outfile: tmpFile,
+ tsconfig: join(MARKENGINE_ROOT, "tsconfig.json"),
+ });
+ renameSync(tmpFile, outfile);
+ }
+
+ return outfile;
+}
function loadMarkengine(entry = "index.ts") {
- const module = { exports: {} };
- vm.runInNewContext(bundle(entry), {
- module,
- exports: module.exports,
- require: localRequire,
- console,
- process,
- Buffer,
- TextEncoder,
- TextDecoder,
- URL,
- URLSearchParams,
- crypto: globalThis.crypto,
- });
- return module.exports;
+ if (memoized.has(entry)) {
+ return memoized.get(entry);
+ }
+ const outfile = bundleToDisk(entry);
+ delete require.cache[require.resolve(outfile)];
+ const mod = require(outfile);
+ memoized.set(entry, mod);
+ return mod;
}
-/**
- * The engine in this test's own realm, for timings: the vm context above runs the
- * engine's string scans ~18x slower ("*a "x10000: 12716 ms there, 697 ms here,
- * node 22), so a budget measured in it says nothing about the app.
- */
function loadMarkengineInRealm(entry = "index.ts") {
- const module = { exports: {} };
- new Function("module", "exports", "require", bundle(entry))(module, module.exports, localRequire);
- return module.exports;
+ return loadMarkengine(entry);
}
-/**
- * The whole public surface merged into one object.
- *
- * The engine deliberately splits its entry points (the root barrel stays
- * React-free and DOM-free so a plain node runner can load it). Fuzz and parity
- * suites care about "no input makes anything throw", not about which subpath a
- * symbol lives on — so they take the union. The per-subpath CONTRACT is asserted
- * in markengine-index.test.cjs instead.
- */
function loadMarkengineAll() {
const entries = ["index.ts", "blocks.ts", "inline.ts", "dom.ts", "terminal.ts"];
return Object.assign({}, ...entries.map((entry) => loadMarkengine(entry)));
}
module.exports = { loadMarkengine, loadMarkengineAll, loadMarkengineInRealm };
-