diff --git a/Cargo.lock b/Cargo.lock index 9aa15efdb9..020e6e3373 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -871,9 +871,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" [[package]] name = "cfg_aliases" @@ -1437,6 +1437,29 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25670139e591f1c2869eb8d0d977028f8d05e859132b4c874ecd02a00d3c9174" +[[package]] +name = "cssparser" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.87", +] + [[package]] name = "ctr" version = "0.9.2" @@ -1585,6 +1608,7 @@ dependencies = [ "bincode", "boxed_error", "bytes", + "bytes-str", "capacity_builder", "chrono", "clap", @@ -1650,6 +1674,7 @@ dependencies = [ "libsui", "libz-sys", "log", + "lol_html", "lsp-types", "malva", "markup_fmt", @@ -1699,6 +1724,7 @@ dependencies = [ "tracing", "tracing-opentelemetry", "tracing-subscriber", + "twox-hash 2.1.0", "typed-arena", "unicode-width 0.1.13", "uuid", @@ -2338,7 +2364,7 @@ dependencies = [ "anyhow", "deno_ast", "deno_semver", - "derive_more", + "derive_more 0.99.17", "if_chain", "log", "once_cell", @@ -3391,6 +3417,26 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "derive_more" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] + [[package]] name = "deunicode" version = "1.4.3" @@ -3656,6 +3702,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "dtoa" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6add3b8cff394282be81f3fc1a0605db594ed69890078ca6e2cab1c408bcf04" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + [[package]] name = "dunce" version = "1.0.5" @@ -3867,14 +3928,13 @@ checksum = "31ae425815400e5ed474178a7a22e275a9687086a12ca63ec793ff292d8fdae8" [[package]] name = "esbuild_client" -version = "0.6.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456b3d661f00dac26314d27da56eb41e0e43f270251fe8c53686bb1e32205863" +checksum = "97ec79be0f4b20864729b38953ad77c7f347e436a7532c5f332dce9736aa4b5c" dependencies = [ "anyhow", "async-trait", "deno_unsync", - "derive_builder", "indexmap 2.9.0", "log", "parking_lot", @@ -4332,6 +4392,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + [[package]] name = "generator" version = "0.8.4" @@ -5832,6 +5901,24 @@ dependencies = [ "serde", ] +[[package]] +name = "lol_html" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63d49c99bfbf3400dd6450e516515b7014fcb49b5cb533f4b725a00c1462a36" +dependencies = [ + "bitflags 2.9.3", + "cfg-if", + "cssparser", + "encoding_rs", + "hashbrown 0.15.5", + "memchr", + "mime", + "precomputed-hash", + "selectors", + "thiserror 2.0.12", +] + [[package]] name = "loom" version = "0.7.2" @@ -6841,6 +6928,16 @@ dependencies = [ "phf_shared", ] +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + [[package]] name = "phf_generator" version = "0.11.2" @@ -7002,6 +7099,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "prefix-trie" version = "0.7.0" @@ -8014,6 +8117,25 @@ dependencies = [ "libc", ] +[[package]] +name = "selectors" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df44ba8a7ca7a4d28c589e04f526266ed76b6cc556e33fe69fa25de31939a65" +dependencies = [ + "bitflags 2.9.3", + "cssparser", + "derive_more 2.0.1", + "fxhash", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "servo_arc", + "smallvec", +] + [[package]] name = "semver" version = "0.9.0" @@ -8151,6 +8273,15 @@ dependencies = [ "serde", ] +[[package]] +name = "servo_arc" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "204ea332803bd95a0b60388590d59cf6468ec9becf626e2451f1d26a1d972de4" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "sha1" version = "0.10.6" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 37d45879c8..6c56736fdd 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -103,6 +103,7 @@ base64.workspace = true bincode.workspace = true boxed_error.workspace = true bytes.workspace = true +bytes-str = "0.2.5" capacity_builder.workspace = true chrono = { workspace = true, features = ["now"] } clap = { workspace = true, features = ["env", "string", "wrap_help", "error-context"] } @@ -120,7 +121,7 @@ dprint-plugin-json.workspace = true dprint-plugin-jupyter.workspace = true dprint-plugin-markdown.workspace = true dprint-plugin-typescript.workspace = true -esbuild_client = { version = "0.6.0", features = ["serde"] } +esbuild_client = { version = "0.7.1", features = ["serde"] } fancy-regex.workspace = true faster-hex.workspace = true # If you disable the default __vendored_zlib_ng feature above, you _must_ be able to link against `-lz`. @@ -139,6 +140,7 @@ lazy-regex.workspace = true libc.workspace = true libz-sys.workspace = true log = { workspace = true, features = ["serde"] } +lol_html = "2.6.0" lsp-types.workspace = true malva.workspace = true markup_fmt.workspace = true @@ -181,6 +183,7 @@ tower-lsp.workspace = true tracing = { workspace = true, features = ["log"], optional = true } tracing-opentelemetry = { workspace = true, optional = true } tracing-subscriber = { workspace = true, features = ["env-filter"], optional = true } +twox-hash.workspace = true typed-arena.workspace = true unicode-width.workspace = true uuid = { workspace = true, features = ["serde"] } diff --git a/cli/tools/bundle/html.rs b/cli/tools/bundle/html.rs new file mode 100644 index 0000000000..99f7b73f72 --- /dev/null +++ b/cli/tools/bundle/html.rs @@ -0,0 +1,451 @@ +// Copyright 2018-2025 the Deno authors. MIT license. + +use std::borrow::Cow; +use std::cell::Cell; +use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; + +use capacity_builder::StringBuilder; +use deno_core::anyhow; +use deno_core::error::AnyError; +use lol_html::element; +use lol_html::html_content::ContentType as LolContentType; + +use crate::tools::bundle::OutputFile; + +#[derive(Debug, Clone)] +pub struct Script { + pub src: Option, + pub is_async: bool, + pub is_module: bool, + pub resolved_path: Option, +} + +struct Attr<'a> { + name: Cow<'static, str>, + value: Option>, +} + +impl<'a> Attr<'a> { + fn new( + name: impl Into>, + value: Option>, + ) -> Self { + Self { + name: name.into(), + value, + } + } + fn write_out<'s>(&'s self, out: &mut StringBuilder<'s>) + where + 'a: 's, + { + out.append(&self.name); + if let Some(value) = &self.value { + out.append("=\""); + out.append(value); + out.append('"'); + } + } +} + +fn write_attr_list<'a, 's>(attrs: &'s [Attr<'a>], out: &mut StringBuilder<'s>) +where + 'a: 's, +{ + if attrs.is_empty() { + return; + } + + out.append(' '); + for item in attrs.iter().take(attrs.len() - 1) { + item.write_out(out); + out.append(' '); + } + + attrs.last().unwrap().write_out(out); +} + +impl Script { + pub fn to_element_string(&self) -> String { + let mut attrs = Vec::new(); + if let Some(src) = &self.src { + attrs.push(Attr::new("src", Some(Cow::Borrowed(src)))); + } + if self.is_async { + attrs.push(Attr::new("async", None)); + } + if self.is_module { + attrs.push(Attr::new("type", Some("module".into()))); + } + attrs.push(Attr::new("crossorigin", None)); + StringBuilder::build(|out| { + out.append(""); + }) + .unwrap() + } +} + +struct NoOutput; + +impl lol_html::OutputSink for NoOutput { + fn handle_chunk(&mut self, _: &[u8]) {} +} + +fn collect_scripts(doc: &str) -> Result, AnyError> { + let mut scripts = Vec::new(); + let mut rewriter = lol_html::HtmlRewriter::new( + lol_html::Settings { + element_content_handlers: vec![element!("script[src]", |el| { + let is_ignored = + el.has_attribute("deno-ignore") || el.has_attribute("vite-ignore"); + if is_ignored { + return Ok(()); + } + let typ = el.get_attribute("type"); + let (Some("module") | None) = typ.as_deref() else { + return Ok(()); + }; + let src = el.get_attribute("src").unwrap(); + let is_async = el.has_attribute("async"); + let is_module = matches!(typ.as_deref(), Some("module")); + + scripts.push(Script { + src: Some(src), + is_async, + is_module, + resolved_path: None, + }); + Ok(()) + })], + ..lol_html::Settings::new() + }, + NoOutput, + ); + rewriter.write(doc.as_bytes())?; + rewriter.end()?; + Ok(scripts) +} + +#[derive(Debug, Clone)] +pub struct HtmlEntrypoint { + pub path: PathBuf, + pub scripts: Vec + + + + + + + diff --git a/tests/specs/bundle/html/imports-css.ts b/tests/specs/bundle/html/imports-css.ts new file mode 100644 index 0000000000..e79e8074c7 --- /dev/null +++ b/tests/specs/bundle/html/imports-css.ts @@ -0,0 +1,3 @@ +import "./style.css"; + +document.body.insertAdjacentHTML("beforeend", "

Hello, world!

"); diff --git a/tests/specs/bundle/html/index.html b/tests/specs/bundle/html/index.html new file mode 100644 index 0000000000..803b42e4dd --- /dev/null +++ b/tests/specs/bundle/html/index.html @@ -0,0 +1,13 @@ + + + + + Deno bundle HTML + + + + + + + + diff --git a/tests/specs/bundle/html/index.ts b/tests/specs/bundle/html/index.ts new file mode 100644 index 0000000000..b2a34420ce --- /dev/null +++ b/tests/specs/bundle/html/index.ts @@ -0,0 +1 @@ +document.body.innerHTML = "Hello, world!"; diff --git a/tests/specs/bundle/html/multiple-html-bundle.asserts.ts b/tests/specs/bundle/html/multiple-html-bundle.asserts.ts new file mode 100644 index 0000000000..b50efb1592 --- /dev/null +++ b/tests/specs/bundle/html/multiple-html-bundle.asserts.ts @@ -0,0 +1,35 @@ +import "./basic-bundle.asserts.ts"; + +import { assertFileContains } from "./assert-helpers.ts"; + +assertFileContains("./dist/index.html", /src="\.\/index-[^\.]+\.js"/); +assertFileContains( + "./dist/multiple-html.html", + /src="\.\/multiple-html-[^\.]+\.js"/, +); + +const jsFiles: string[] = []; +Deno.readDirSync("./dist").forEach((entry) => { + if (entry.name.endsWith(".js")) { + jsFiles.push("./dist/" + entry.name); + } +}); + +if (jsFiles.length === 0) { + throw new Error("No .js files found"); +} + +const indexJsFile = jsFiles.find((file) => file.includes("index")); +const multipleHtmlJsFile = jsFiles.find((file) => + file.includes("multiple-html") +); + +if (!indexJsFile || !multipleHtmlJsFile) { + throw new Error("No index.js or multiple-html.js file found"); +} + +assertFileContains(indexJsFile, "Hello, world!"); +assertFileContains( + multipleHtmlJsFile, + 'document.body.insertAdjacentHTML("beforeend", "A");', +); diff --git a/tests/specs/bundle/html/multiple-html-bundle.out b/tests/specs/bundle/html/multiple-html-bundle.out new file mode 100644 index 0000000000..693151f68c --- /dev/null +++ b/tests/specs/bundle/html/multiple-html-bundle.out @@ -0,0 +1,7 @@ +[WILDCARD] +Bundled 4 modules in [WILDLINE] + dist[WILDCHAR]index-[WILDLINE].js [WILDLINE] + dist[WILDCHAR]multiple-html-[WILDLINE].js [WILDLINE] + dist[WILDCHAR]index.html [WILDLINE] + dist[WILDCHAR]multiple-html.html [WILDLINE] + diff --git a/tests/specs/bundle/html/multiple-html.html b/tests/specs/bundle/html/multiple-html.html new file mode 100644 index 0000000000..89dc37df5f --- /dev/null +++ b/tests/specs/bundle/html/multiple-html.html @@ -0,0 +1,13 @@ + + + + + Deno bundle HTML + + + + + + + + diff --git a/tests/specs/bundle/html/multiple-scripts-bundle.asserts.ts b/tests/specs/bundle/html/multiple-scripts-bundle.asserts.ts new file mode 100644 index 0000000000..2f7c4ae913 --- /dev/null +++ b/tests/specs/bundle/html/multiple-scripts-bundle.asserts.ts @@ -0,0 +1,25 @@ +import { assertFileContains } from "./assert-helpers.ts"; + +let jsFile: string | undefined; +Deno.readDirSync("./dist").forEach((entry) => { + if (entry.name.endsWith(".js")) { + jsFile = "./dist/" + entry.name; + } +}); + +if (!jsFile) { + throw new Error("No .js file found"); +} + +assertFileContains( + "./dist/multiple-scripts.html", + /src="\.\/multiple-scripts-[^\.]+\.js"/, +); +assertFileContains( + jsFile, + 'insertAdjacentHTML("beforeend", "A")', +); +assertFileContains( + jsFile, + 'insertAdjacentHTML("beforeend", "B")', +); diff --git a/tests/specs/bundle/html/multiple-scripts-bundle.out b/tests/specs/bundle/html/multiple-scripts-bundle.out new file mode 100644 index 0000000000..ef772a7686 --- /dev/null +++ b/tests/specs/bundle/html/multiple-scripts-bundle.out @@ -0,0 +1,5 @@ +[WILDCARD] +Bundled 3 modules in [WILDLINE] + dist[WILDCHAR]multiple-scripts-[WILDLINE].js [WILDLINE] + dist[WILDCHAR]multiple-scripts.html [WILDLINE] + diff --git a/tests/specs/bundle/html/multiple-scripts.html b/tests/specs/bundle/html/multiple-scripts.html new file mode 100644 index 0000000000..53368d413b --- /dev/null +++ b/tests/specs/bundle/html/multiple-scripts.html @@ -0,0 +1,13 @@ + + + + + Multi + + + + + + + + diff --git a/tests/specs/bundle/html/print-dir.ts b/tests/specs/bundle/html/print-dir.ts new file mode 100644 index 0000000000..f221c0365f --- /dev/null +++ b/tests/specs/bundle/html/print-dir.ts @@ -0,0 +1,56 @@ +function printName(name: string, level = 0) { + console.log("-".repeat(level + 1) + " " + name); +} + +function walk( + dir: string, + fn: ( + { name, dir, level, kind }: { + name: string; + dir: string; + level: number; + kind: "file" | "dir" | "symlink"; + }, + ) => void, + { maxLevel }: { maxLevel: number }, +) { + const walkRecursive = (dir: string, level: number) => { + if (maxLevel > 0 && level > maxLevel) { + return; + } + const files = Deno.readDirSync(dir).toArray(); + files.sort((a, b) => a.name.localeCompare(b.name)); + for (const file of files) { + if (file.isDirectory) { + fn({ name: file.name, dir, level, kind: "dir" }); + walkRecursive(dir + "/" + file.name, level + 1); + } else if (file.isFile) { + fn({ name: file.name, dir, level, kind: "file" }); + } else { + fn({ name: file.name, dir, level, kind: "symlink" }); + } + } + }; + walkRecursive(dir, 0); +} + +function printDir(dir: string, maxLevel = 0) { + walk(dir, ({ name, level, kind }) => { + printName(name + (kind === "symlink" ? " (symlink)" : ""), level); + }, { maxLevel }); +} +let maxLevel = 0; +for (let i = 0; i < Deno.args.length; i++) { + const arg = Deno.args[i]; + if (arg.startsWith("--max-level=")) { + maxLevel = parseInt(arg.split("=")[1]) - 1; + maxLevel = Math.max(0, maxLevel); + } else { + console.log(arg); + printDir(arg, maxLevel); + if (i < Deno.args.length - 1) { + console.log(""); + maxLevel = 0; + } + } +} diff --git a/tests/specs/bundle/html/same-name-sub-folder-bundle.asserts.ts b/tests/specs/bundle/html/same-name-sub-folder-bundle.asserts.ts new file mode 100644 index 0000000000..b34b052e40 --- /dev/null +++ b/tests/specs/bundle/html/same-name-sub-folder-bundle.asserts.ts @@ -0,0 +1,38 @@ +import { assertFileContains } from "./assert-helpers.ts"; + +assertFileContains("./dist/index.html", /src="\.\/index-[^\.]+\.js"/); +assertFileContains("./dist/sub/index.html", /src="\.\/index-[^\.]+\.js"/); + +function walk(dir: string, fn: (entry: string) => void) { + Deno.readDirSync(dir).forEach((entry) => { + if (entry.isDirectory) { + walk(dir + "/" + entry.name, fn); + } else { + fn(dir + "/" + entry.name); + } + }); +} + +const jsFiles: string[] = []; +walk("./dist", (entry) => { + if (entry.endsWith(".js")) { + jsFiles.push(entry); + } +}); + +if (jsFiles.length === 0) { + throw new Error("No .js files found"); +} + +const subJsFile = jsFiles.find((file) => file.includes("sub")); + +const indexJsFile = jsFiles.find((file) => + file.includes("index") && !file.includes("sub") +); + +if (!indexJsFile || !subJsFile) { + throw new Error("No index.js or sub/index.js file found"); +} + +assertFileContains(indexJsFile, "Hello, world!"); +assertFileContains(subJsFile, "Hello, world from sub!"); diff --git a/tests/specs/bundle/html/same-name-sub-folder-bundle.out b/tests/specs/bundle/html/same-name-sub-folder-bundle.out new file mode 100644 index 0000000000..1ad4557f7e --- /dev/null +++ b/tests/specs/bundle/html/same-name-sub-folder-bundle.out @@ -0,0 +1,9 @@ +[WILDCARD] +Bundled 4 modules in [WILDLINE] +[UNORDERED_START] + dist[WILDCHAR]index-[WILDLINE].js [WILDLINE] + dist[WILDCHAR]sub[WILDCHAR]index-[WILDLINE].js [WILDLINE] + dist[WILDCHAR]index.html [WILDLINE] + dist[WILDCHAR]sub[WILDCHAR]index.html [WILDLINE] +[UNORDERED_END] + diff --git a/tests/specs/bundle/html/shared/mod.ts b/tests/specs/bundle/html/shared/mod.ts new file mode 100644 index 0000000000..fe913a7b84 --- /dev/null +++ b/tests/specs/bundle/html/shared/mod.ts @@ -0,0 +1,2 @@ +export const v = 1; +document.body.textContent = "Nested"; diff --git a/tests/specs/bundle/html/style.css b/tests/specs/bundle/html/style.css new file mode 100644 index 0000000000..adc68fa6a4 --- /dev/null +++ b/tests/specs/bundle/html/style.css @@ -0,0 +1,3 @@ +h1 { + color: red; +} diff --git a/tests/specs/bundle/html/sub/index.html b/tests/specs/bundle/html/sub/index.html new file mode 100644 index 0000000000..325023cb42 --- /dev/null +++ b/tests/specs/bundle/html/sub/index.html @@ -0,0 +1,12 @@ + + + + + Nested + + + + + + + diff --git a/tests/specs/bundle/html/sub/index.ts b/tests/specs/bundle/html/sub/index.ts new file mode 100644 index 0000000000..4cfc755b79 --- /dev/null +++ b/tests/specs/bundle/html/sub/index.ts @@ -0,0 +1 @@ +document.body.innerHTML = "Hello, world from sub!"; diff --git a/tests/unit/bundle_test.ts b/tests/unit/bundle_test.ts index c5527c13ad..adceef5a8b 100644 --- a/tests/unit/bundle_test.ts +++ b/tests/unit/bundle_test.ts @@ -8,7 +8,7 @@ import { unindent, } from "./test_util.ts"; -import { join, toFileUrl } from "@std/path"; +import { basename, join, toFileUrl } from "@std/path"; class TempDir implements AsyncDisposable, Disposable { private path: string; @@ -313,3 +313,55 @@ Deno.test("bundle: replaces require shim when platform is deno", async () => { const output = await evalEsmString(js.text()); assertEquals(output.default, ["good", 1]); }); + +Deno.test("bundle: html works", async () => { + using dir = new TempDir(); + const entry = dir.join("index.html"); + const input = unindent` + + + + + + `; + const script = dir.join("index.ts"); + const scriptInput = unindent` + console.log("hello"); + document.body.innerHTML = "hello"; + `; + const outDir = dir.join("dist"); + + await Deno.writeTextFile(entry, input); + await Deno.writeTextFile(script, scriptInput); + + const result = await Deno.bundle({ + entrypoints: [entry], + outputDir: outDir, + write: false, + }); + + const js = result.outputFiles!.find((f) => + !!f.contents && f.path.endsWith(".js") + ); + if (!js) { + throw new Error("No JS file found"); + } + + const html = result.outputFiles!.find((f) => + !!f.contents && f.path.endsWith(".html") + )!; + if (!html) { + throw new Error("No HTML file found"); + } + + assert(result.success); + assertEquals(result.errors.length, 0); + assertEquals(result.outputFiles!.length, 2); + + const jsFileName = basename(js.path); + + assertStringIncludes(html.text(), `src="./${jsFileName}`); + + assertStringIncludes(js.text(), "innerHTML"); + assertStringIncludes(js.text(), "hello"); +});