Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .sonarcloud.properties
Original file line number Diff line number Diff line change
@@ -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/**
16 changes: 10 additions & 6 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
CMD ["npm", "run", "dev"]
58 changes: 28 additions & 30 deletions bench/benchmarks.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
6 changes: 4 additions & 2 deletions bench/referenceSource.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
5 changes: 3 additions & 2 deletions bench/run.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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();
Expand Down
21 changes: 17 additions & 4 deletions playground/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,16 +55,29 @@ async function render() {

const data = await response.json();
if (!response.ok) {
preview.innerHTML = `<pre class="error">${escapeHtml(data.error || "Preview failed")}</pre>`;
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("&", "&amp;")
Expand All @@ -79,7 +92,7 @@ function scheduleRender() {
globalThis.clearTimeout(timer);
timer = globalThis.setTimeout(() => {
render().catch((error) => {
preview.innerHTML = `<pre class="error">${escapeHtml(error.message)}</pre>`;
showError(error.message);
});
}, 80);
}
Expand Down Expand Up @@ -254,5 +267,5 @@ sourceToggleButton.addEventListener("click", () => {
});

render().catch((error) => {
preview.innerHTML = `<pre class="error">${escapeHtml(error.message)}</pre>`;
showError(error.message);
});
43 changes: 20 additions & 23 deletions tests/inline-formatting-regression.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
104 changes: 51 additions & 53 deletions tests/support/loadMarkengine.cjs
Original file line number Diff line number Diff line change
@@ -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 };

Loading