diff --git a/.claude/settings.json b/.claude/settings.json
index e9ba04395..fa4bc827c 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -8,7 +8,7 @@
"type": "command",
"timeout": 180,
"statusMessage": "Running build + lint + typecheck before commit…",
- "command": "node -e \"\nconst chunks = [];\nprocess.stdin.on('data', d => chunks.push(d));\nprocess.stdin.on('end', () => {\n const input = JSON.parse(Buffer.concat(chunks).toString());\n const cmd = input.tool_input?.command || '';\n if (!/git\\\\s+commit\\\\b/.test(cmd)) process.exit(0);\n const { execSync } = require('child_process');\n const cwd = process.env.PWD || process.cwd();\n const steps = [\n ['bun run build', 'Build'],\n ['bun run lint', 'Lint'],\n ['bun run --filter \\'*\\' typecheck 2>&1 | grep -v \\'vitest\\\\|test\\\\.ts\\' || true', 'Typecheck'],\n ];\n const failures = [];\n for (const [script, label] of steps) {\n try { execSync(script, { cwd, stdio: 'pipe' }); }\n catch (e) {\n failures.push(label + ':\\\\n' + (e.stdout?.toString() || e.message).slice(0, 400));\n }\n }\n if (failures.length > 0) {\n process.stdout.write(JSON.stringify({\n continue: false,\n stopReason: '\\u274c Pre-commit checks failed:\\\\n\\\\n' + failures.join('\\\\n\\\\n') + '\\\\n\\\\nFix the issues above before committing.',\n }));\n }\n});\""
+ "command": "node -e \"\nconst chunks = [];\nprocess.stdin.on('data', d => chunks.push(d));\nprocess.stdin.on('end', () => {\n const input = JSON.parse(Buffer.concat(chunks).toString());\n const cmd = input.tool_input?.command || '';\n if (!/git\\\\s+commit\\\\b/.test(cmd)) process.exit(0);\n const { execSync } = require('child_process');\n const cwd = execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim();\n const steps = [\n ['bun run build', 'Build'],\n ['bun run lint', 'Lint'],\n ['bun run --filter \\'*\\' typecheck 2>&1 | grep -v \\'vitest\\\\|test\\\\.ts\\' || true', 'Typecheck'],\n ];\n const failures = [];\n for (const [script, label] of steps) {\n try { execSync(script, { cwd, stdio: 'pipe' }); }\n catch (e) {\n failures.push(label + ':\\\\n' + (e.stdout?.toString() || e.message).slice(0, 400));\n }\n }\n if (failures.length > 0) {\n process.stdout.write(JSON.stringify({\n continue: false,\n stopReason: '\\u274c Pre-commit checks failed:\\\\n\\\\n' + failures.join('\\\\n\\\\n') + '\\\\n\\\\nFix the issues above before committing.',\n }));\n }\n});\""
}
]
}
diff --git a/bun.lock b/bun.lock
index 2ae53f19f..668387d0f 100644
--- a/bun.lock
+++ b/bun.lock
@@ -27,7 +27,6 @@
"@hono/node-server": "^1.8.0",
"@puppeteer/browsers": "^2.13.0",
"adm-zip": "^0.5.16",
- "cheerio": "^1.2.0",
"citty": "^0.2.1",
"compare-versions": "^6.1.1",
"esbuild": "^0.25.0",
@@ -48,7 +47,6 @@
"@types/mime-types": "^3.0.1",
"@types/node": "^22.0.0",
"adm-zip": "^0.5.16",
- "cheerio": "^1.2.0",
"hono": "^4.0.0",
"linkedom": "^0.18.12",
"mime-types": "^3.0.2",
@@ -75,8 +73,8 @@
"vitest": "^3.2.4",
},
"optionalDependencies": {
- "cheerio": "^1.2.0",
"esbuild": "^0.25.12",
+ "linkedom": "^0.18.12",
},
"peerDependencies": {
"hono": "^4.0.0",
@@ -808,10 +806,6 @@
"check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="],
- "cheerio": ["cheerio@1.2.0", "", { "dependencies": { "cheerio-select": "2.1.0", "dom-serializer": "2.0.0", "domhandler": "5.0.3", "domutils": "3.2.2", "encoding-sniffer": "0.2.1", "htmlparser2": "10.1.0", "parse5": "7.3.0", "parse5-htmlparser2-tree-adapter": "7.1.0", "parse5-parser-stream": "7.1.2", "undici": "7.24.5", "whatwg-mimetype": "4.0.0" } }, "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg=="],
-
- "cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "1.0.0", "css-select": "5.2.2", "css-what": "6.2.2", "domelementtype": "2.3.0", "domhandler": "5.0.3", "domutils": "3.2.2" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="],
-
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "4.1.2" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"chromium-bidi": ["chromium-bidi@14.0.0", "", { "dependencies": { "mitt": "3.0.1", "zod": "3.25.76" }, "peerDependencies": { "devtools-protocol": "0.0.1581282" } }, "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw=="],
@@ -908,11 +902,9 @@
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
- "encoding-sniffer": ["encoding-sniffer@0.2.1", "", { "dependencies": { "iconv-lite": "0.6.3", "whatwg-encoding": "3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="],
-
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
- "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
+ "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
@@ -1008,8 +1000,6 @@
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "7.1.4", "debug": "4.4.3" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
- "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": "2.1.2" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
-
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "1.0.1", "resolve-from": "4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="],
@@ -1202,10 +1192,6 @@
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "6.0.1" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
- "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "5.0.3", "parse5": "7.3.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="],
-
- "parse5-parser-stream": ["parse5-parser-stream@7.1.2", "", { "dependencies": { "parse5": "7.3.0" } }, "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow=="],
-
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
@@ -1288,8 +1274,6 @@
"rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
- "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
-
"saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
"scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "1.4.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
@@ -1432,9 +1416,7 @@
"webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="],
- "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
-
- "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
+ "whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
"whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "1.15.0", "tr46": "6.0.0", "webidl-conversions": "8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="],
@@ -1512,7 +1494,7 @@
"chromium-bidi/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
- "data-urls/whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
+ "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"escodegen/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
@@ -1520,8 +1502,6 @@
"glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
- "htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
-
"import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"istanbul-lib-report/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
@@ -1530,8 +1510,6 @@
"jsdom/parse5": ["parse5@8.0.0", "", { "dependencies": { "entities": "6.0.1" } }, "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA=="],
- "jsdom/whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
-
"loose-envify/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 050b04017..3ee9a0c07 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -28,7 +28,6 @@
"@hono/node-server": "^1.8.0",
"@puppeteer/browsers": "^2.13.0",
"adm-zip": "^0.5.16",
- "cheerio": "^1.2.0",
"citty": "^0.2.1",
"compare-versions": "^6.1.1",
"esbuild": "^0.25.0",
@@ -49,7 +48,6 @@
"@types/mime-types": "^3.0.1",
"@types/node": "^22.0.0",
"adm-zip": "^0.5.16",
- "cheerio": "^1.2.0",
"hono": "^4.0.0",
"linkedom": "^0.18.12",
"mime-types": "^3.0.2",
diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts
index 747ae7136..3eab4ccba 100644
--- a/packages/cli/tsup.config.ts
+++ b/packages/cli/tsup.config.ts
@@ -32,7 +32,6 @@ var __dirname = __hf_dirname(__filename);`,
"hono",
"hono/*",
"@hono/node-server",
- "cheerio",
"mime-types",
"adm-zip",
"esbuild",
diff --git a/packages/core/package.json b/packages/core/package.json
index 1fa3bb6d3..72dc1cf2e 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -109,7 +109,7 @@
}
},
"optionalDependencies": {
- "cheerio": "^1.2.0",
- "esbuild": "^0.25.12"
+ "esbuild": "^0.25.12",
+ "linkedom": "^0.18.12"
}
}
diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts
index e2fb81c6d..7933350cb 100644
--- a/packages/core/src/compiler/htmlBundler.ts
+++ b/packages/core/src/compiler/htmlBundler.ts
@@ -1,11 +1,24 @@
import { readFileSync, existsSync } from "fs";
import { join, resolve, isAbsolute, sep } from "path";
-import * as cheerio from "cheerio";
+import { parseHTML } from "linkedom";
import { transformSync } from "esbuild";
import { compileHtml, type MediaDurationProber } from "./htmlCompiler";
import { rewriteAssetPaths, rewriteCssAssetUrls } from "./rewriteSubCompPaths";
import { validateHyperframeHtmlContract } from "./staticGuard";
+/**
+ * Parse an HTML string into a document. Fragments (without a full document
+ * structure) are wrapped in `
…`
+ * so that linkedom places the content inside `document.body`.
+ */
+function parseHTMLContent(html: string): Document {
+ const trimmed = html.trimStart().toLowerCase();
+ if (trimmed.startsWith("${html}`).document;
+}
+
/** Resolve a relative path within projectDir, rejecting traversal outside it. */
function safePath(projectDir: string, relativePath: string): string | null {
const resolved = resolve(projectDir, relativePath);
@@ -179,21 +192,23 @@ function rewriteCssUrlsWithInlinedAssets(cssText: string, projectDir: string): s
);
}
-function enforceCompositionPixelSizing($: cheerio.CheerioAPI): void {
- const compositionEls = $("[data-composition-id][data-width][data-height]").toArray();
+function enforceCompositionPixelSizing(document: Document): void {
+ const compositionEls = [
+ ...document.querySelectorAll("[data-composition-id][data-width][data-height]"),
+ ];
if (compositionEls.length === 0) return;
const sizeMap = new Map();
for (const el of compositionEls) {
- const compId = $(el).attr("data-composition-id");
- const w = Number($(el).attr("data-width"));
- const h = Number($(el).attr("data-height"));
+ const compId = el.getAttribute("data-composition-id");
+ const w = Number(el.getAttribute("data-width"));
+ const h = Number(el.getAttribute("data-height"));
if (compId && Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) {
sizeMap.set(compId, { w, h });
}
}
if (sizeMap.size === 0) return;
- $("style").each((_, styleEl) => {
- let css = $(styleEl).html() || "";
+ for (const styleEl of document.querySelectorAll("style")) {
+ let css = styleEl.textContent || "";
let modified = false;
for (const [compId, { w, h }] of sizeMap) {
const escaped = compId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -209,52 +224,52 @@ function enforceCompositionPixelSizing($: cheerio.CheerioAPI): void {
return open + newBody + close;
});
}
- if (modified) $(styleEl).text(css);
- });
+ if (modified) styleEl.textContent = css;
+ }
}
-function autoHealMissingCompositionIds($: cheerio.CheerioAPI): void {
+function autoHealMissingCompositionIds(document: Document): void {
const compositionIdRe = /data-composition-id=["']([^"']+)["']/gi;
const referencedIds = new Set();
- $("style, script").each((_, el) => {
- const text = ($(el).html() || "").trim();
- if (!text) return;
+ for (const el of document.querySelectorAll("style, script")) {
+ const text = (el.textContent || "").trim();
+ if (!text) continue;
let match: RegExpExecArray | null;
while ((match = compositionIdRe.exec(text)) !== null) {
const compId = (match[1] || "").trim();
if (compId) referencedIds.add(compId);
}
- });
+ }
if (referencedIds.size === 0) return;
const existingIds = new Set();
- $("[data-composition-id]").each((_, el) => {
- const id = ($(el).attr("data-composition-id") || "").trim();
+ for (const el of document.querySelectorAll("[data-composition-id]")) {
+ const id = (el.getAttribute("data-composition-id") || "").trim();
if (id) existingIds.add(id);
- });
+ }
for (const compId of referencedIds) {
if (compId === "root" || existingIds.has(compId)) continue;
const candidates = [`${compId}-layer`, `${compId}-comp`, compId];
for (const targetId of candidates) {
- const match = $(`#${targetId}`).first();
- if (match.length > 0 && !match.attr("data-composition-id")) {
- match.attr("data-composition-id", compId);
+ const found = document.getElementById(targetId);
+ if (found && !found.getAttribute("data-composition-id")) {
+ found.setAttribute("data-composition-id", compId);
break;
}
}
}
}
-function coalesceHeadStylesAndBodyScripts($: cheerio.CheerioAPI): void {
- const headStyleEls = $("head style").toArray();
+function coalesceHeadStylesAndBodyScripts(document: Document): void {
+ const headStyleEls = [...document.querySelectorAll("head style")];
if (headStyleEls.length > 1) {
const importRe = /@import\s+url\([^)]*\)\s*;|@import\s+["'][^"']+["']\s*;/gi;
const imports: string[] = [];
const cssParts: string[] = [];
const seenImports = new Set();
for (const el of headStyleEls) {
- const raw = ($(el).html() || "").trim();
+ const raw = (el.textContent || "").trim();
if (!raw) continue;
const nonImportCss = raw.replace(importRe, (match) => {
const cleaned = match.trim();
@@ -269,29 +284,29 @@ function coalesceHeadStylesAndBodyScripts($: cheerio.CheerioAPI): void {
}
const merged = [...imports, ...cssParts].join("\n\n").trim();
if (merged) {
- $(headStyleEls[0]).text(merged);
- for (let i = 1; i < headStyleEls.length; i++) $(headStyleEls[i]).remove();
+ headStyleEls[0]!.textContent = merged;
+ for (let i = 1; i < headStyleEls.length; i++) headStyleEls[i]!.remove();
}
}
- const bodyInlineScripts = $("body script")
- .toArray()
- .filter((el) => {
- const src = ($(el).attr("src") || "").trim();
- if (src) return false;
- const type = ($(el).attr("type") || "").trim().toLowerCase();
- return !type || type === "text/javascript" || type === "application/javascript";
- });
+ const bodyInlineScripts = [...document.querySelectorAll("body script")].filter((el) => {
+ const src = (el.getAttribute("src") || "").trim();
+ if (src) return false;
+ const type = (el.getAttribute("type") || "").trim().toLowerCase();
+ return !type || type === "text/javascript" || type === "application/javascript";
+ });
if (bodyInlineScripts.length > 0) {
const mergedJs = bodyInlineScripts
- .map((el) => ($(el).html() || "").trim())
+ .map((el) => (el.textContent || "").trim())
.filter(Boolean)
.join("\n;\n")
.trim();
- for (const el of bodyInlineScripts) $(el).remove();
+ for (const el of bodyInlineScripts) el.remove();
if (mergedJs) {
const stripped = stripJsCommentsParserSafe(mergedJs);
- $("body").append(``);
+ const inlineScript = document.createElement("script");
+ inlineScript.textContent = stripped;
+ document.body.appendChild(inlineScript);
}
}
}
@@ -338,87 +353,99 @@ export async function bundleToSingleHtml(
}
const withInterceptor = injectInterceptor(compiled);
- const $ = cheerio.load(withInterceptor);
+ const { document } = parseHTML(withInterceptor);
// Inline local CSS
const localCssChunks: string[] = [];
let cssAnchorPlaced = false;
- $('link[rel="stylesheet"]').each((_, el) => {
- const href = $(el).attr("href");
- if (!href || !isRelativeUrl(href)) return;
+ for (const el of [...document.querySelectorAll('link[rel="stylesheet"]')]) {
+ const href = el.getAttribute("href");
+ if (!href || !isRelativeUrl(href)) continue;
const cssPath = safePath(projectDir, href);
const css = cssPath ? safeReadFile(cssPath) : null;
- if (css == null) return;
+ if (css == null) continue;
localCssChunks.push(css);
if (!cssAnchorPlaced) {
- $(el).replaceWith('');
+ const anchor = document.createElement("style");
+ anchor.setAttribute("data-hf-bundled-local-css", "1");
+ el.replaceWith(anchor);
cssAnchorPlaced = true;
} else {
- $(el).remove();
+ el.remove();
}
- });
+ }
if (localCssChunks.length > 0) {
- const $anchor = $('style[data-hf-bundled-local-css="1"]').first();
- if ($anchor.length)
- $anchor.removeAttr("data-hf-bundled-local-css").text(localCssChunks.join("\n\n"));
- else $("head").append(``);
+ const anchor = document.querySelector('style[data-hf-bundled-local-css="1"]');
+ if (anchor) {
+ anchor.removeAttribute("data-hf-bundled-local-css");
+ anchor.textContent = localCssChunks.join("\n\n");
+ } else {
+ const style = document.createElement("style");
+ style.textContent = localCssChunks.join("\n\n");
+ document.head.appendChild(style);
+ }
}
// Inline local JS
const localJsChunks: string[] = [];
let jsAnchorPlaced = false;
- $("script[src]").each((_, el) => {
- const src = $(el).attr("src");
- if (!src || !isRelativeUrl(src)) return;
+ for (const el of [...document.querySelectorAll("script[src]")]) {
+ const src = el.getAttribute("src");
+ if (!src || !isRelativeUrl(src)) continue;
const jsPath = safePath(projectDir, src);
const js = jsPath ? safeReadFile(jsPath) : null;
- if (js == null) return;
+ if (js == null) continue;
localJsChunks.push(js);
if (!jsAnchorPlaced) {
- $(el).replaceWith('');
+ const anchor = document.createElement("script");
+ anchor.setAttribute("data-hf-bundled-local-js", "1");
+ el.replaceWith(anchor);
jsAnchorPlaced = true;
} else {
- $(el).remove();
+ el.remove();
}
- });
+ }
if (localJsChunks.length > 0) {
- const $anchor = $('script[data-hf-bundled-local-js="1"]').first();
- if ($anchor.length)
- $anchor.removeAttr("data-hf-bundled-local-js").text(localJsChunks.join("\n;\n"));
- else $("body").append(``);
+ const anchor = document.querySelector('script[data-hf-bundled-local-js="1"]');
+ if (anchor) {
+ anchor.removeAttribute("data-hf-bundled-local-js");
+ anchor.textContent = localJsChunks.join("\n;\n");
+ } else {
+ const script = document.createElement("script");
+ script.textContent = localJsChunks.join("\n;\n");
+ document.body.appendChild(script);
+ }
}
// Inline sub-compositions
const compStyleChunks: string[] = [];
const compScriptChunks: string[] = [];
const compExternalScriptSrcs: string[] = [];
- $("[data-composition-src]").each((_, hostEl) => {
- const src = $(hostEl).attr("data-composition-src");
- if (!src || !isRelativeUrl(src)) return;
+ for (const hostEl of [...document.querySelectorAll("[data-composition-src]")]) {
+ const src = hostEl.getAttribute("data-composition-src");
+ if (!src || !isRelativeUrl(src)) continue;
const compPath = safePath(projectDir, src);
const compHtml = compPath ? safeReadFile(compPath) : null;
if (compHtml == null) {
console.warn(`[Bundler] Composition file not found: ${src}`);
- return;
+ continue;
}
- const $comp = cheerio.load(compHtml);
- const compId = $(hostEl).attr("data-composition-id");
- const $contentRoot = $comp("template").first();
- const contentHtml = $contentRoot.length
- ? $contentRoot.html() || ""
- : $comp("body").html() || "";
- const $content = cheerio.load(contentHtml);
- const $innerRoot = compId
- ? $content(`[data-composition-id="${compId}"]`).first()
- : $content("[data-composition-id]").first();
+ const compDoc = parseHTMLContent(compHtml);
+ const compId = hostEl.getAttribute("data-composition-id");
+ const contentRoot = compDoc.querySelector("template");
+ const contentHtml = contentRoot ? contentRoot.innerHTML || "" : compDoc.body.innerHTML || "";
+ const contentDoc = parseHTMLContent(contentHtml);
+ const innerRoot = compId
+ ? contentDoc.querySelector(`[data-composition-id="${compId}"]`)
+ : contentDoc.querySelector("[data-composition-id]");
- $content("style").each((_, s) => {
- compStyleChunks.push(rewriteCssAssetUrls($content(s).html() || "", src));
- $content(s).remove();
- });
- $content("script").each((_, s) => {
- const externalSrc = ($content(s).attr("src") || "").trim();
+ for (const s of [...contentDoc.querySelectorAll("style")]) {
+ compStyleChunks.push(rewriteCssAssetUrls(s.textContent || "", src));
+ s.remove();
+ }
+ for (const s of [...contentDoc.querySelectorAll("script")]) {
+ const externalSrc = (s.getAttribute("src") || "").trim();
if (externalSrc) {
// External CDN/remote script — collect for deduped injection into the document.
// Do NOT try to inline the content (external scripts have no innerHTML).
@@ -427,155 +454,167 @@ export async function bundleToSingleHtml(
}
} else {
compScriptChunks.push(
- `(function(){ try { ${$content(s).html() || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
+ `(function(){ try { ${s.textContent || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
);
}
- $content(s).remove();
- });
+ s.remove();
+ }
// Rewrite relative asset paths before inlining so ../foo.svg from
// compositions/ resolves correctly when the content moves to root.
- const $assetEls = $innerRoot.length
- ? $innerRoot.find("[src], [href]")
- : $content("[src], [href]");
+ const assetEls = innerRoot
+ ? innerRoot.querySelectorAll("[src], [href]")
+ : contentDoc.querySelectorAll("[src], [href]");
rewriteAssetPaths(
- $assetEls.toArray(),
+ assetEls,
src,
- (el, attr) => $content(el).attr(attr),
- (el, attr, val) => {
- $content(el).attr(attr, val);
+ (el: Element, attr: string) => el.getAttribute(attr),
+ (el: Element, attr: string, val: string) => {
+ el.setAttribute(attr, val);
},
);
- if ($innerRoot.length) {
- const innerCompId = $innerRoot.attr("data-composition-id");
- const innerW = $innerRoot.attr("data-width");
- const innerH = $innerRoot.attr("data-height");
- if (innerCompId && !$(hostEl).attr("data-composition-id"))
- $(hostEl).attr("data-composition-id", innerCompId);
- if (innerW && !$(hostEl).attr("data-width")) $(hostEl).attr("data-width", innerW);
- if (innerH && !$(hostEl).attr("data-height")) $(hostEl).attr("data-height", innerH);
- $innerRoot.find("style, script").remove();
- $(hostEl).html($innerRoot.html() || "");
+ if (innerRoot) {
+ const innerCompId = innerRoot.getAttribute("data-composition-id");
+ const innerW = innerRoot.getAttribute("data-width");
+ const innerH = innerRoot.getAttribute("data-height");
+ if (innerCompId && !hostEl.getAttribute("data-composition-id"))
+ hostEl.setAttribute("data-composition-id", innerCompId);
+ if (innerW && !hostEl.getAttribute("data-width")) hostEl.setAttribute("data-width", innerW);
+ if (innerH && !hostEl.getAttribute("data-height")) hostEl.setAttribute("data-height", innerH);
+ for (const child of [...innerRoot.querySelectorAll("style, script")]) child.remove();
+ hostEl.innerHTML = innerRoot.innerHTML || "";
} else {
- $content("style, script").remove();
- $(hostEl).html($content.html() || "");
+ for (const child of [...contentDoc.querySelectorAll("style, script")]) child.remove();
+ hostEl.innerHTML = contentDoc.body.innerHTML || "";
}
- $(hostEl).removeAttr("data-composition-src");
- });
+ hostEl.removeAttribute("data-composition-src");
+ }
// Inline template compositions: inject content into
// matching empty host elements with data-composition-id="X" (no data-composition-src)
- $("template[id]").each((_, templateEl) => {
- const templateId = $(templateEl).attr("id") || "";
+ for (const templateEl of [...document.querySelectorAll("template[id]")]) {
+ const templateId = templateEl.getAttribute("id") || "";
const match = templateId.match(/^(.+)-template$/);
- if (!match) return;
+ if (!match) continue;
const compId = match[1];
// Find the matching host element (must have data-composition-id, no data-composition-src,
- // and must NOT be inside a element). In cheerio, elements inside
- // have a detached parent chain (parents().length === 0), so we filter those out.
+ // and must NOT be inside a element).
const hostSelector = `[data-composition-id="${compId}"]:not([data-composition-src])`;
- const $candidates = $(hostSelector).filter((__, el) => $(el).parents().length > 0);
- const $host = $candidates.first();
- if ($host.length === 0) return;
- if ($host.children().length > 0) return; // already has content
+ // linkedom follows the DOM spec: querySelectorAll does not reach inside
+ // content, so no isInsideTemplate filter is needed.
+ const host = document.querySelector(hostSelector);
+ if (!host) continue;
+ if (host.children.length > 0) continue; // already has content
// Get template content and inject into host
- const templateHtml = $(templateEl).html() || "";
- const $inner = cheerio.load(templateHtml, { xml: false });
- const $innerRoot = $inner(`[data-composition-id="${compId}"]`).first();
+ const templateHtml = templateEl.innerHTML || "";
+ const innerDoc = parseHTMLContent(templateHtml);
+ const innerRoot = innerDoc.querySelector(`[data-composition-id="${compId}"]`);
- if ($innerRoot.length > 0) {
+ if (innerRoot) {
// Hoist styles into the collected style chunks
- $innerRoot.find("style").each((__, styleEl) => {
- compStyleChunks.push($inner(styleEl).html() || "");
- $inner(styleEl).remove();
- });
+ for (const styleEl of [...innerRoot.querySelectorAll("style")]) {
+ compStyleChunks.push(styleEl.textContent || "");
+ styleEl.remove();
+ }
// Hoist scripts into the collected script chunks
- $innerRoot.find("script").each((__, scriptEl) => {
- const externalSrc = ($inner(scriptEl).attr("src") || "").trim();
+ for (const scriptEl of [...innerRoot.querySelectorAll("script")]) {
+ const externalSrc = (scriptEl.getAttribute("src") || "").trim();
if (externalSrc) {
if (!compExternalScriptSrcs.includes(externalSrc)) {
compExternalScriptSrcs.push(externalSrc);
}
} else {
compScriptChunks.push(
- `(function(){ try { ${$inner(scriptEl).html() || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
+ `(function(){ try { ${scriptEl.textContent || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
);
}
- $inner(scriptEl).remove();
- });
+ scriptEl.remove();
+ }
// Copy dimension attributes from inner root to host if not already set
- const innerW = $innerRoot.attr("data-width");
- const innerH = $innerRoot.attr("data-height");
- if (innerW && !$host.attr("data-width")) $host.attr("data-width", innerW);
- if (innerH && !$host.attr("data-height")) $host.attr("data-height", innerH);
+ const innerW = innerRoot.getAttribute("data-width");
+ const innerH = innerRoot.getAttribute("data-height");
+ if (innerW && !host.getAttribute("data-width")) host.setAttribute("data-width", innerW);
+ if (innerH && !host.getAttribute("data-height")) host.setAttribute("data-height", innerH);
// Set host content from inner root
- $host.html($innerRoot.html() || "");
+ host.innerHTML = innerRoot.innerHTML || "";
} else {
// No matching inner root — inject all template content directly
- $inner("style").each((__, styleEl) => {
- compStyleChunks.push($inner(styleEl).html() || "");
- $inner(styleEl).remove();
- });
- $inner("script").each((__, scriptEl) => {
- const externalSrc = ($inner(scriptEl).attr("src") || "").trim();
+ for (const styleEl of [...innerDoc.querySelectorAll("style")]) {
+ compStyleChunks.push(styleEl.textContent || "");
+ styleEl.remove();
+ }
+ for (const scriptEl of [...innerDoc.querySelectorAll("script")]) {
+ const externalSrc = (scriptEl.getAttribute("src") || "").trim();
if (externalSrc) {
if (!compExternalScriptSrcs.includes(externalSrc)) {
compExternalScriptSrcs.push(externalSrc);
}
} else {
compScriptChunks.push(
- `(function(){ try { ${$inner(scriptEl).html() || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
+ `(function(){ try { ${scriptEl.textContent || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
);
}
- $inner(scriptEl).remove();
- });
- $host.html($inner.html() || "");
+ scriptEl.remove();
+ }
+ host.innerHTML = innerDoc.body.innerHTML || "";
}
// Remove the template element from the document
- $(templateEl).remove();
- });
+ templateEl.remove();
+ }
// Inject external scripts from sub-compositions (e.g., Lottie CDN)
// that aren't already present in the main document.
for (const extSrc of compExternalScriptSrcs) {
- if (!$(`script[src="${extSrc}"]`).length) {
- $("body").append(``);
+ if (!document.querySelector(`script[src="${extSrc}"]`)) {
+ const extScript = document.createElement("script");
+ extScript.setAttribute("src", extSrc);
+ document.body.appendChild(extScript);
}
}
- if (compStyleChunks.length) $("head").append(``);
- if (compScriptChunks.length)
- $("body").append(``);
+ if (compStyleChunks.length) {
+ const style = document.createElement("style");
+ style.textContent = compStyleChunks.join("\n\n");
+ document.head.appendChild(style);
+ }
+ if (compScriptChunks.length) {
+ const compScript = document.createElement("script");
+ compScript.textContent = compScriptChunks.join("\n;\n");
+ document.body.appendChild(compScript);
+ }
- enforceCompositionPixelSizing($);
- autoHealMissingCompositionIds($);
- coalesceHeadStylesAndBodyScripts($);
+ enforceCompositionPixelSizing(document);
+ autoHealMissingCompositionIds(document);
+ coalesceHeadStylesAndBodyScripts(document);
// Inline textual assets
- $("[src], [href], [poster], [xlink\\:href]").each((_, el) => {
+ for (const el of [...document.querySelectorAll("[src], [href], [poster], [xlink\\:href]")]) {
for (const attr of ["src", "href", "poster", "xlink:href"] as const) {
- const value = $(el).attr(attr);
+ const value = el.getAttribute(attr);
if (!value) continue;
const inlined = maybeInlineRelativeAssetUrl(value, projectDir);
- if (inlined) $(el).attr(attr, inlined);
+ if (inlined) el.setAttribute(attr, inlined);
}
- });
- $("[srcset]").each((_, el) => {
- const srcset = $(el).attr("srcset");
- if (srcset) $(el).attr("srcset", rewriteSrcsetWithInlinedAssets(srcset, projectDir));
- });
- $("style").each((_, el) => {
- $(el).text(rewriteCssUrlsWithInlinedAssets($(el).html() || "", projectDir));
- });
- $("[style]").each((_, el) => {
- $(el).attr("style", rewriteCssUrlsWithInlinedAssets($(el).attr("style") || "", projectDir));
- });
+ }
+ for (const el of [...document.querySelectorAll("[srcset]")]) {
+ const srcset = el.getAttribute("srcset");
+ if (srcset) el.setAttribute("srcset", rewriteSrcsetWithInlinedAssets(srcset, projectDir));
+ }
+ for (const styleEl of document.querySelectorAll("style")) {
+ styleEl.textContent = rewriteCssUrlsWithInlinedAssets(styleEl.textContent || "", projectDir);
+ }
+ for (const el of [...document.querySelectorAll("[style]")]) {
+ el.setAttribute(
+ "style",
+ rewriteCssUrlsWithInlinedAssets(el.getAttribute("style") || "", projectDir),
+ );
+ }
- return $.html();
+ return document.toString();
}
diff --git a/packages/core/src/compiler/index.ts b/packages/core/src/compiler/index.ts
index feed7c1b6..c7e1678ea 100644
--- a/packages/core/src/compiler/index.ts
+++ b/packages/core/src/compiler/index.ts
@@ -13,7 +13,7 @@ export {
// HTML compiler (Node.js — requires fs)
export { compileHtml, type MediaDurationProber } from "./htmlCompiler";
-// HTML bundler (Node.js — requires fs, cheerio, esbuild)
+// HTML bundler (Node.js — requires fs, linkedom, esbuild)
export { bundleToSingleHtml, type BundleOptions } from "./htmlBundler";
// Static guard
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index abda0f8f0..ec37011d2 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -97,7 +97,7 @@ export {
generateHyperframesStyles,
} from "./generators/hyperframes";
-// Compiler (timing only — browser-safe, no cheerio/esbuild)
+// Compiler (timing only — browser-safe, no linkedom/esbuild)
export type {
UnresolvedElement,
ResolvedDuration,
diff --git a/packages/core/src/studio-api/helpers/subComposition.ts b/packages/core/src/studio-api/helpers/subComposition.ts
index 569cec201..5fb7cb574 100644
--- a/packages/core/src/studio-api/helpers/subComposition.ts
+++ b/packages/core/src/studio-api/helpers/subComposition.ts
@@ -1,6 +1,6 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
-import * as cheerio from "cheerio";
+import { parseHTML } from "linkedom";
import { rewriteAssetPaths, rewriteCssAssetUrls } from "../../compiler/rewriteSubCompPaths.js";
/**
@@ -24,21 +24,23 @@ export function buildSubCompositionHtml(
// Extract content from wrapper (compositions are always templates)
const templateMatch = rawComp.match(/]*>([\s\S]*)<\/template>/i);
const content = templateMatch?.[1] ?? rawComp;
- const $content = cheerio.load(content, {}, false);
+ const { document: contentDoc } = parseHTML(
+ `${content}`,
+ );
rewriteAssetPaths(
- $content("[src], [href]").toArray(),
+ contentDoc.querySelectorAll("[src], [href]"),
compPath,
- (el, attr) => $content(el).attr(attr),
- (el, attr, value) => {
- $content(el).attr(attr, value);
+ (el: Element, attr: string) => el.getAttribute(attr),
+ (el: Element, attr: string, value: string) => {
+ el.setAttribute(attr, value);
},
);
- $content("style").each((_, styleEl) => {
- $content(styleEl).html(rewriteCssAssetUrls($content(styleEl).html() || "", compPath));
- });
+ for (const styleEl of contentDoc.querySelectorAll("style")) {
+ styleEl.textContent = rewriteCssAssetUrls(styleEl.textContent || "", compPath);
+ }
- const rewrittenContent = $content.root().html() || content;
+ const rewrittenContent = contentDoc.body.innerHTML || content;
// Use the project's index.html to preserve all dependencies
const indexPath = join(projectDir, "index.html");