From 3d684c07181c65ebea83de5dcd05ab5202ee8a00 Mon Sep 17 00:00:00 2001 From: oxura Date: Thu, 8 Oct 2026 15:33:34 -0300 Subject: [PATCH 1/5] fix(cli): bundle inline HTML module scripts --- bend2/main.ts | 37 ++++++++++++++++++++++++++++++++++++- guide/GUIDE.md | 2 ++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/bend2/main.ts b/bend2/main.ts index 74aada107..bf83fcac8 100755 --- a/bend2/main.ts +++ b/bend2/main.ts @@ -482,12 +482,47 @@ function cli_base(what?: string): void { } async function cli_bundle(page: string, dir: string): Promise { + // Virtual files live beside each page so its module imports resolve there. + const scripts = new Map(); + let tag: string | undefined; + const inline: BunPlugin = { + name: "bend inline modules", + setup(build) { + build.onResolve({ filter: /\.bend-inline-/ }, (a) => { + const id = path.resolve(a.resolveDir, a.path); + if (scripts.has(id)) return { path: id, namespace: "bend-inline" }; + }); + build.onLoad({ filter: /.*/, namespace: "bend-inline" }, (a) => ({ + contents: scripts.get(a.path)!.join(""), + loader: "js", + })); + build.onLoad({ filter: /\.html$/ }, async (a) => { + let code: string[] | null = null; + const html = await new HTMLRewriter().on("script", { + element(el) { + code = null; + if (el.getAttribute("type")?.toLowerCase() !== "module" + || el.getAttribute("src") !== null) return; + const name = `.${path.basename(a.path)}.bend-inline-${tag ??= crypto.randomUUID()}-${scripts.size}.js`; + code = []; + scripts.set(path.join(path.dirname(a.path), name), code); + el.setAttribute("src", "./" + name); + el.setInnerContent(""); + }, + text(text) { + code?.push(text.text); + }, + }).transform(new Response(Bun.file(a.path))).text(); + return { contents: html, loader: "html" }; + }); + }, + }; const out = await Bun.build({ entrypoints: [page], outdir: dir, target: "browser", minify: true, - plugins: [PLUGIN], + plugins: [inline, PLUGIN], }); for (const a of out.outputs) { cli_say(1, a.path + " (" + (a.size / 1024).toFixed(1) + "kb)\n"); diff --git a/guide/GUIDE.md b/guide/GUIDE.md index 22fb8fa80..2afd1a4f1 100644 --- a/guide/GUIDE.md +++ b/guide/GUIDE.md @@ -556,6 +556,8 @@ bend page.html -o dist # bundle a web page that imports .bend files ./file --gpu 4GB # cap the GPU's heap at 4GB ``` +Page modules, including inline scripts, bundle; failures report diagnostics and exit nonzero. + A `main` that returns `IO` runs compiled; one that returns a value is normalized by the checker (slow for big work) and printed; a file with no `main` just checks. A binary that uses `!` builds its GPU program too, as `file.gpu`, which From 29e26e724856bee3d2f9737700a3a61111b318c2 Mon Sep 17 00:00:00 2001 From: oxura Date: Fri, 9 Oct 2026 10:01:11 +0600 Subject: [PATCH 2/5] fix(cli): stabilize inline page modules and diagnostics --- CHANGELOG.md | 6 +++ bend2/main.ts | 92 ++++++++++++++++++++++++++++--------- guide/GUIDE.md | 2 - tests/io/bundle_inline.bend | 13 ++++++ tests/io/bundle_inline.js | 81 ++++++++++++++++++++++++++++++++ 5 files changed, 170 insertions(+), 24 deletions(-) mode change 100644 => 100755 CHANGELOG.md mode change 100644 => 100755 guide/GUIDE.md create mode 100755 tests/io/bundle_inline.bend create mode 100755 tests/io/bundle_inline.js diff --git a/CHANGELOG.md b/CHANGELOG.md old mode 100644 new mode 100755 index f9ea5ee75..156d338d4 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ Each release names what changed for a user. `bend update` installs the latest one; the GitHub release carries the same notes. +## Unreleased + +- HTML inline modules bundle with stable output names and shared imports. + Spaces around `type="module"` are accepted, and bundle diagnostics point + to the original HTML page's lines. + ## 2.0.36 (2026-10-07) - **HOC's proving agent is now named BendAI**: `bend login` logs in to diff --git a/bend2/main.ts b/bend2/main.ts index bf83fcac8..5b4d97460 100755 --- a/bend2/main.ts +++ b/bend2/main.ts @@ -482,50 +482,98 @@ function cli_base(what?: string): void { } async function cli_bundle(page: string, dir: string): Promise { - // Virtual files live beside each page so its module imports resolve there. - const scripts = new Map(); - let tag: string | undefined; + // Virtual modules resolve imports beside their page, without disk files. + type Script = { page: string; number: number; code: string[] }; + const scripts = new Map(); const inline: BunPlugin = { name: "bend inline modules", setup(build) { build.onResolve({ filter: /\.bend-inline-/ }, (a) => { const id = path.resolve(a.resolveDir, a.path); - if (scripts.has(id)) return { path: id, namespace: "bend-inline" }; + if (scripts.has(id)) { + return { path: id, namespace: "bend-inline" }; + } }); build.onLoad({ filter: /.*/, namespace: "bend-inline" }, (a) => ({ - contents: scripts.get(a.path)!.join(""), + contents: scripts.get(a.path)!.code.join(""), loader: "js", })); build.onLoad({ filter: /\.html$/ }, async (a) => { + const source = await Bun.file(a.path).text(); + let marker = ""; + while (source.includes(marker)) { + marker += "."; + } + const names: string[] = []; let code: string[] | null = null; - const html = await new HTMLRewriter().on("script", { + // Insert markers without changing the original markup: their offsets, + // less earlier markers, locate script bodies even in multiline tags. + const marked = await new HTMLRewriter().on("script", { element(el) { code = null; - if (el.getAttribute("type")?.toLowerCase() !== "module" - || el.getAttribute("src") !== null) return; - const name = `.${path.basename(a.path)}.bend-inline-${tag ??= crypto.randomUUID()}-${scripts.size}.js`; + if (el.getAttribute("type")?.trim().toLowerCase() !== "module" + || el.getAttribute("src") !== null) { + return; + } + const name = "." + path.basename(a.path) + + ".bend-inline-" + String(names.length) + ".js"; + names.push(name); code = []; - scripts.set(path.join(path.dirname(a.path), name), code); - el.setAttribute("src", "./" + name); - el.setInnerContent(""); + scripts.set(path.join(path.dirname(a.path), name), + { page: a.path, number: names.length, code }); + el.prepend(marker, { html: true }); }, text(text) { code?.push(text.text); }, - }).transform(new Response(Bun.file(a.path))).text(); + }).transform(new Response(source)).text(); + let from = 0; + let removed = 0; + for (const name of names) { + const at = marked.indexOf(marker, from); + const prefix = source.slice(0, at - removed); + scripts.get(path.join(path.dirname(a.path), name))!.code + .unshift(prefix.replace(/[^\r\n]/g, " ")); + from = at + marker.length; + removed += marker.length; + } + let index = 0; + const html = await new HTMLRewriter().on("script", { + element(el) { + if (el.getAttribute("type")?.trim().toLowerCase() !== "module" + || el.getAttribute("src") !== null) { + return; + } + el.setAttribute("type", "module"); + el.setAttribute("src", "./" + names[index++]); + el.setInnerContent(""); + }, + }).transform(new Response(source)).text(); return { contents: html, loader: "html" }; }); }, }; - const out = await Bun.build({ - entrypoints: [page], - outdir: dir, - target: "browser", - minify: true, - plugins: [inline, PLUGIN], - }); - for (const a of out.outputs) { - cli_say(1, a.path + " (" + (a.size / 1024).toFixed(1) + "kb)\n"); + try { + const out = await Bun.build({ + entrypoints: [page], + outdir: dir, + target: "browser", + minify: true, + plugins: [inline, PLUGIN], + }); + for (const a of out.outputs) { + cli_say(1, a.path + " (" + (a.size / 1024).toFixed(1) + "kb)\n"); + } + } catch (e) { + let why = book_err(e); + for (const [id, script] of scripts) { + if (why.includes(id)) { + why = why.replaceAll(id, script.page); + why += "\nInline module " + String(script.number) + + " in " + script.page + "."; + } + } + throw why; } } diff --git a/guide/GUIDE.md b/guide/GUIDE.md old mode 100644 new mode 100755 index 2afd1a4f1..22fb8fa80 --- a/guide/GUIDE.md +++ b/guide/GUIDE.md @@ -556,8 +556,6 @@ bend page.html -o dist # bundle a web page that imports .bend files ./file --gpu 4GB # cap the GPU's heap at 4GB ``` -Page modules, including inline scripts, bundle; failures report diagnostics and exit nonzero. - A `main` that returns `IO` runs compiled; one that returns a value is normalized by the checker (slow for big work) and printed; a file with no `main` just checks. A binary that uses `!` builds its GPU program too, as `file.gpu`, which diff --git a/tests/io/bundle_inline.bend b/tests/io/bundle_inline.bend new file mode 100755 index 000000000..6d4391306 --- /dev/null +++ b/tests/io/bundle_inline.bend @@ -0,0 +1,13 @@ +# Inline page modules share their imports and produce repeatable bundles. +# Syntax diagnostics retain the original HTML page's line coordinates. +import Base + +def Bundle.inline() -> IO(U32): + import "./bundle_inline.js" + +def main() -> IO(Unit): + do IO: + value : U32 <- Bundle.inline() + IO.print(U32.show(value)) + +#|0 diff --git a/tests/io/bundle_inline.js b/tests/io/bundle_inline.js new file mode 100755 index 000000000..c8c9426b4 --- /dev/null +++ b/tests/io/bundle_inline.js @@ -0,0 +1,81 @@ +function bundle_inline() { + const fs = require("node:fs"); + const path = require("node:path"); + const child = require("node:child_process"); + const assert = require("node:assert/strict"); + const dir = fs.mkdtempSync(path.join(process.cwd(), ".bend-page-inline-")); + const cli = path.resolve("bend2/main.ts"); + const deadline = Date.now() + 3500; + const run_cli = (args) => child.spawnSync(process.execPath, args, { + encoding: "utf8", + env: { ...process.env, HOME: dir, BEND_ORIGIN: "http://127.0.0.1:9" }, + timeout: Math.max(1, deadline - Date.now()), + }); + try { + const page = path.join(dir, "page.html"); + const out = path.join(dir, "out"); + fs.writeFileSync(path.join(dir, "state.js"), + "export const state = {value: 0};\n"); + fs.writeFileSync(page, "\n" + + '\n" + + '\n"); + const build = (file, target) => run_cli([cli, file, "-o", target]); + const snapshot = () => fs.readdirSync(out).sort().map((file) => + [file, fs.readFileSync(path.join(out, file))]); + const first = build(page, out); + assert.equal(first.status, 0, first.stderr); + const initial = snapshot(); + const second = build(page, out); + assert.equal(second.status, 0, second.stderr); + assert.deepEqual(snapshot(), initial); + const runner = path.join(dir, "run.mjs"); + fs.writeFileSync(runner, ` +import { pathToFileURL } from "node:url"; +globalThis.__bend_bundle_rows = []; +const modules = []; +let code = null; +await new HTMLRewriter().on("script", { + element(el) { + code = null; + if (el.getAttribute("type")?.trim() === "module") { + code = []; + modules.push({src: el.getAttribute("src"), code}); + } + }, + text(text) { + code?.push(text.text); + }, +}).transform(new Response(Bun.file(process.argv[2]))).text(); +const root = new URL(".", pathToFileURL(process.argv[2])); +for (const [index, module] of modules.entries()) { + const file = new URL(module.src ?? "inline-" + index + ".mjs", root); + if (module.src === null) { + await Bun.write(file, module.code.join("")); + } + await import(file.href); +} +console.log(JSON.stringify(globalThis.__bend_bundle_rows)); +`); + const run = run_cli([runner, path.join(out, "page.html")]); + assert.equal(run.status, 0, run.stderr); + assert.equal(run.stdout.trim(), "[1,2]"); + const bad = path.join(dir, "bad.html"); + fs.writeFileSync(bad, "\n" + + '\n' + + '
quoted fake
\n' + + '\n' + + "const valid = 1;\nconst broken = ;\n\n"); + const failure = build(bad, path.join(dir, "bad-out")); + assert.equal(failure.status, 1); + assert.ok(failure.stderr.includes(bad + ":7:"), failure.stderr); + return 0; + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +io_eff(CID(Bundle.inline), bundle_inline); From 49a1ad36c66437d5bb7be8e060876e6143635444 Mon Sep 17 00:00:00 2001 From: oxura Date: Fri, 9 Oct 2026 10:08:10 +0600 Subject: [PATCH 3/5] fix: preserve documentation and fixture file modes --- CHANGELOG.md | 0 guide/GUIDE.md | 0 tests/io/bundle_inline.bend | 0 tests/io/bundle_inline.js | 0 4 files changed, 0 insertions(+), 0 deletions(-) mode change 100755 => 100644 CHANGELOG.md mode change 100755 => 100644 guide/GUIDE.md mode change 100755 => 100644 tests/io/bundle_inline.bend mode change 100755 => 100644 tests/io/bundle_inline.js diff --git a/CHANGELOG.md b/CHANGELOG.md old mode 100755 new mode 100644 diff --git a/guide/GUIDE.md b/guide/GUIDE.md old mode 100755 new mode 100644 diff --git a/tests/io/bundle_inline.bend b/tests/io/bundle_inline.bend old mode 100755 new mode 100644 diff --git a/tests/io/bundle_inline.js b/tests/io/bundle_inline.js old mode 100755 new mode 100644 From ee0932c74924746e2b90683ec248563ba718337d Mon Sep 17 00:00:00 2001 From: oxura Date: Fri, 9 Oct 2026 15:49:49 +0600 Subject: [PATCH 4/5] docs: leave release changelog to maintainers --- CHANGELOG.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 156d338d4..f9ea5ee75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,12 +3,6 @@ Each release names what changed for a user. `bend update` installs the latest one; the GitHub release carries the same notes. -## Unreleased - -- HTML inline modules bundle with stable output names and shared imports. - Spaces around `type="module"` are accepted, and bundle diagnostics point - to the original HTML page's lines. - ## 2.0.36 (2026-10-07) - **HOC's proving agent is now named BendAI**: `bend login` logs in to From 0eb77d0f0a893b0f9c502d4c144fbab3fd0e1fde Mon Sep 17 00:00:00 2001 From: Lorenzobattistela Date: Fri, 9 Oct 2026 07:52:29 -0300 Subject: [PATCH 5/5] Bundle inline module scripts in one pass, with a smaller test (#1400) --- bend2/main.ts | 103 +++++++++++++----------------------- tests/io/bundle_inline.bend | 4 +- tests/io/bundle_inline.js | 80 ++++++---------------------- 3 files changed, 55 insertions(+), 132 deletions(-) diff --git a/bend2/main.ts b/bend2/main.ts index 5b4d97460..32cd6c871 100755 --- a/bend2/main.ts +++ b/bend2/main.ts @@ -481,74 +481,53 @@ function cli_base(what?: string): void { cli_say(1, want.join("\n\n") + "\n"); } +// cli_bundle builds a page. Bun skips inline module scripts (#1400), so the +// inline plugin moves each to a virtual file beside its page, padded so an +// error points at the page's own line and column. async function cli_bundle(page: string, dir: string): Promise { - // Virtual modules resolve imports beside their page, without disk files. - type Script = { page: string; number: number; code: string[] }; - const scripts = new Map(); + const code = new Map(); const inline: BunPlugin = { - name: "bend inline modules", + name: "bend inline", setup(build) { - build.onResolve({ filter: /\.bend-inline-/ }, (a) => { + build.onResolve({ filter: /\.bend-inline-\d+\.js$/ }, (a) => { const id = path.resolve(a.resolveDir, a.path); - if (scripts.has(id)) { - return { path: id, namespace: "bend-inline" }; - } + return code.has(id) ? { path: id, namespace: "inline" } : undefined; }); - build.onLoad({ filter: /.*/, namespace: "bend-inline" }, (a) => ({ - contents: scripts.get(a.path)!.code.join(""), - loader: "js", - })); + build.onLoad({ filter: /.*/, namespace: "inline" }, (a) => + ({ contents: code.get(a.path)!, loader: "js" })); build.onLoad({ filter: /\.html$/ }, async (a) => { - const source = await Bun.file(a.path).text(); - let marker = ""; - while (source.includes(marker)) { - marker += "."; - } - const names: string[] = []; - let code: string[] | null = null; - // Insert markers without changing the original markup: their offsets, - // less earlier markers, locate script bodies even in multiline tags. - const marked = await new HTMLRewriter().on("script", { + const src = await Bun.file(a.path).text(); + const mods: { id: string; text: string }[] = []; + let mod: { id: string; text: string } | null = null; + const html = new HTMLRewriter().on("script", { element(el) { - code = null; - if (el.getAttribute("type")?.trim().toLowerCase() !== "module" - || el.getAttribute("src") !== null) { - return; + const type = el.getAttribute("type")?.trim().toLowerCase(); + mod = null; + if (type === "module" && !el.hasAttribute("src")) { + const name = "." + path.basename(a.path) + ".bend-inline-" + + mods.length + ".js"; + mod = { id: path.join(path.dirname(a.path), name), text: "" }; + mods.push(mod); + el.setAttribute("src", "./" + name); } - const name = "." + path.basename(a.path) - + ".bend-inline-" + String(names.length) + ".js"; - names.push(name); - code = []; - scripts.set(path.join(path.dirname(a.path), name), - { page: a.path, number: names.length, code }); - el.prepend(marker, { html: true }); }, - text(text) { - code?.push(text.text); - }, - }).transform(new Response(source)).text(); - let from = 0; - let removed = 0; - for (const name of names) { - const at = marked.indexOf(marker, from); - const prefix = source.slice(0, at - removed); - scripts.get(path.join(path.dirname(a.path), name))!.code - .unshift(prefix.replace(/[^\r\n]/g, " ")); - from = at + marker.length; - removed += marker.length; - } - let index = 0; - const html = await new HTMLRewriter().on("script", { - element(el) { - if (el.getAttribute("type")?.trim().toLowerCase() !== "module" - || el.getAttribute("src") !== null) { - return; + text(t) { + if (mod) { + mod.text += t.text; + t.remove(); } - el.setAttribute("type", "module"); - el.setAttribute("src", "./" + names[index++]); - el.setInnerContent(""); }, - }).transform(new Response(source)).text(); + }).transform(src); + let end = 0; + let line = 0; + for (const m of mods) { + const at = src.indexOf(m.text, end); + line += src.slice(end, at).split("\n").length - 1; + const col = at - src.lastIndexOf("\n", at - 1) - 1; + code.set(m.id, "\n".repeat(line) + " ".repeat(col) + m.text); + line += m.text.split("\n").length - 1; + end = at + m.text.length; + } return { contents: html, loader: "html" }; }); }, @@ -565,15 +544,7 @@ async function cli_bundle(page: string, dir: string): Promise { cli_say(1, a.path + " (" + (a.size / 1024).toFixed(1) + "kb)\n"); } } catch (e) { - let why = book_err(e); - for (const [id, script] of scripts) { - if (why.includes(id)) { - why = why.replaceAll(id, script.page); - why += "\nInline module " + String(script.number) - + " in " + script.page + "."; - } - } - throw why; + throw book_err(e).replace(/\.([^/\\]+)\.bend-inline-\d+\.js/g, "$1"); } } diff --git a/tests/io/bundle_inline.bend b/tests/io/bundle_inline.bend index 6d4391306..50d453c56 100644 --- a/tests/io/bundle_inline.bend +++ b/tests/io/bundle_inline.bend @@ -1,5 +1,5 @@ -# Inline page modules share their imports and produce repeatable bundles. -# Syntax diagnostics retain the original HTML page's line coordinates. +# An inline module script in a page is bundled with its .bend imports, and +# an error in it points at the page's own line (#1400). import Base def Bundle.inline() -> IO(U32): diff --git a/tests/io/bundle_inline.js b/tests/io/bundle_inline.js index c8c9426b4..111750914 100644 --- a/tests/io/bundle_inline.js +++ b/tests/io/bundle_inline.js @@ -2,77 +2,29 @@ function bundle_inline() { const fs = require("node:fs"); const path = require("node:path"); const child = require("node:child_process"); - const assert = require("node:assert/strict"); const dir = fs.mkdtempSync(path.join(process.cwd(), ".bend-page-inline-")); - const cli = path.resolve("bend2/main.ts"); - const deadline = Date.now() + 3500; - const run_cli = (args) => child.spawnSync(process.execPath, args, { + const run = (...args) => child.spawnSync(process.execPath, args, { encoding: "utf8", env: { ...process.env, HOME: dir, BEND_ORIGIN: "http://127.0.0.1:9" }, - timeout: Math.max(1, deadline - Date.now()), }); + const put = (name, text) => fs.writeFileSync(path.join(dir, name), text); + const build = (page) => run(path.resolve("bend2/main.ts"), + path.join(dir, page), "-o", path.join(dir, "out")); try { - const page = path.join(dir, "page.html"); + put("m.bend", "import Base\n\nlaw main:\n U32\n\ndef main():\n 42\n"); + put("ok.html", '\n"); + put("bad.html", '

\n\nconst x = ;\n\n'); + if (build("ok.html").status !== 0) { + return 1; + } const out = path.join(dir, "out"); - fs.writeFileSync(path.join(dir, "state.js"), - "export const state = {value: 0};\n"); - fs.writeFileSync(page, "\n" - + '\n" - + '\n"); - const build = (file, target) => run_cli([cli, file, "-o", target]); - const snapshot = () => fs.readdirSync(out).sort().map((file) => - [file, fs.readFileSync(path.join(out, file))]); - const first = build(page, out); - assert.equal(first.status, 0, first.stderr); - const initial = snapshot(); - const second = build(page, out); - assert.equal(second.status, 0, second.stderr); - assert.deepEqual(snapshot(), initial); - const runner = path.join(dir, "run.mjs"); - fs.writeFileSync(runner, ` -import { pathToFileURL } from "node:url"; -globalThis.__bend_bundle_rows = []; -const modules = []; -let code = null; -await new HTMLRewriter().on("script", { - element(el) { - code = null; - if (el.getAttribute("type")?.trim() === "module") { - code = []; - modules.push({src: el.getAttribute("src"), code}); + const js = fs.readdirSync(out).find((f) => f.endsWith(".js")); + if (run(path.join(out, js)).stdout.trim() !== "42") { + return 2; } - }, - text(text) { - code?.push(text.text); - }, -}).transform(new Response(Bun.file(process.argv[2]))).text(); -const root = new URL(".", pathToFileURL(process.argv[2])); -for (const [index, module] of modules.entries()) { - const file = new URL(module.src ?? "inline-" + index + ".mjs", root); - if (module.src === null) { - await Bun.write(file, module.code.join("")); - } - await import(file.href); -} -console.log(JSON.stringify(globalThis.__bend_bundle_rows)); -`); - const run = run_cli([runner, path.join(out, "page.html")]); - assert.equal(run.status, 0, run.stderr); - assert.equal(run.stdout.trim(), "[1,2]"); - const bad = path.join(dir, "bad.html"); - fs.writeFileSync(bad, "\n" - + '\n' - + '

quoted fake
\n' - + '\n' - + "const valid = 1;\nconst broken = ;\n\n"); - const failure = build(bad, path.join(dir, "bad-out")); - assert.equal(failure.status, 1); - assert.ok(failure.stderr.includes(bad + ":7:"), failure.stderr); - return 0; + const bad = build("bad.html"); + return bad.status === 1 && bad.stderr.includes("bad.html:4:") ? 0 : 3; } finally { fs.rmSync(dir, { recursive: true, force: true }); }