diff --git a/.changeset/smooth-bars-relate.md b/.changeset/smooth-bars-relate.md
new file mode 100644
index 00000000..6fa5b5ba
--- /dev/null
+++ b/.changeset/smooth-bars-relate.md
@@ -0,0 +1,5 @@
+---
+"@stephansama/single-file": minor
+---
+
+created single file cli package
diff --git a/.config/.cspell.json b/.config/.cspell.json
index d9082118..5bffaacd 100644
--- a/.config/.cspell.json
+++ b/.config/.cspell.json
@@ -13,6 +13,7 @@
"barhandles",
"bluwy",
"catppuccin",
+ "cleye",
"commitlint",
"dotenvx",
"esbuild",
diff --git a/.config/lefthook.yml b/.config/lefthook.yml
new file mode 100644
index 00000000..9037e6d7
--- /dev/null
+++ b/.config/lefthook.yml
@@ -0,0 +1,15 @@
+prepare-commit-msg:
+ jobs:
+ - run: ai-commit-msg -o {1}
+commit-msg:
+ jobs:
+ - run: pnpm dlx commitlint --config .config/.commitlintrc.ts --edit {1}
+pre-commit:
+ parallel: true
+ jobs:
+ - run: auto-readme -vg
+ name: Update README
+ - run: lint-staged -v
+ name: Lint staged
+ - run: pnpm --workspace-root run scripts:lint-examples
+ name: Lint examples
diff --git a/.gitignore b/.gitignore
index 35db97d6..6403776d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,8 +9,8 @@
.config/www/public/*
.env**
.next
-.react-router
.publish
+.react-router
.svelte-kit
.turbo
.wrangler
@@ -22,4 +22,5 @@ core/types-lhci/config
dist*/
examples/catppuccin-xsl/vanilla/public/**/*
node_modules
+single-file.html
storybook-static
diff --git a/.husky/commit-msg b/.husky/commit-msg
deleted file mode 100644
index f5d44c79..00000000
--- a/.husky/commit-msg
+++ /dev/null
@@ -1,3 +0,0 @@
-#!/bin/sh
-
-pnpm dlx commitlint --config .config/.commitlintrc.ts --edit "$1"
diff --git a/.husky/pre-commit b/.husky/pre-commit
deleted file mode 100644
index 00f78934..00000000
--- a/.husky/pre-commit
+++ /dev/null
@@ -1,5 +0,0 @@
-#!/bin/sh
-
-auto-readme -g
-lint-staged -v
-pnpm --workspace-root run scripts:lint-examples
diff --git a/.husky/prepare-commit-msg b/.husky/prepare-commit-msg
deleted file mode 100644
index e932fce7..00000000
--- a/.husky/prepare-commit-msg
+++ /dev/null
@@ -1,3 +0,0 @@
-#!/bin/sh
-
-ai-commit-msg -o "$1"
diff --git a/README.md b/README.md
index 4d704125..e69de29b 100644
--- a/README.md
+++ b/README.md
@@ -1,86 +0,0 @@
-
-
-# [`@stephansama`](https://github.com/stephansama/packages) packages
-
-[](https://github.com/search?q=repo%3Astephansama%2Fnvim%20language%3Alua&type=code)
-[](https://github.com/search?q=repo%3Astephansama%2Fnvim%20language%3ATypeScript&type=code)
-[](https://turborepo.com/)
-
-[](https://codecov.io/github/stephansama/packages)
-[](https://github.com/stephansama/packages/actions/workflows/release.yml)
-[](https://github.com/stephansama/packages/actions/workflows/github-code-scanning/codeql)
-
-Collection of open-source [npm](https://www.npmx.dev/) packages
-
-
-
-##### Table of contents
-
-Open Table of contents
-
-- [Introduction](#introduction)
-- [📦 Packages](#-packages)
- - [☂️ Codecov coverage graph](#️-codecov-coverage-graph)
- - [⭐ Stargazers](#-stargazers)
-- [Related repositories](#related-repositories)
-
-
-
-## Introduction
-
-view examples here 👉 [](https://pkg.pr.new/~/stephansama/packages)
-
-or install an example with [`create-stephansama-example`](https://github.com/stephansama/packages/tree/main/core/example)
-via `pnpm create stephansama-example`
-
-## 📦 Packages
-
-All packages are packaged underneath the `@stephansama` scope (for example: `@stephansama/remark-asciinema`)
-
-
-
-### 🏭 workspace
-
-| 🏷️ Name | Version | 📥 Downloads | 📝 Description |
-| ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
-| [ai-commit-msg](core/ai-commit-msg/README.md) |  |  | generate commit messages using ai |
-| [alfred-kaomoji](core/alfred-kaomoji/README.md) |  |  | Alfred Kaomoji Picker |
-| [astro-iconify-svgmap](core/astro-iconify-svgmap/README.md) |  |  | Astro integration for generating iconify svgmaps for ssg sites |
-| [auto-readme](core/auto-readme/README.md) |  |  | Generate lists and tables for your README automagically based on your repository and comments |
-| [catppuccin-jsonresume-theme](core/catppuccin-jsonresume-theme/README.md) |  |  | theme for resume cli website |
-| [catppuccin-opml](core/catppuccin-opml/README.md) |  |  | Catppuccin styled opml stylesheet |
-| [catppuccin-rss](core/catppuccin-rss/README.md) |  |  | Catppuccin x Pretty-feed-v3 |
-| [catppuccin-typedoc](core/catppuccin-typedoc/README.md) |  |  | Catppuccin css variable theme for typedoc |
-| [catppuccin-xsl](core/catppuccin-xsl/README.md) |  |  | Catppuccin styles for various xsl formats |
-| [create-stephansama-example](core/example/README.md) |  |  | Download an example from the @stephansama/packages examples |
-| [find-makefile-targets](core/find-makefile-targets/README.md) |  |  | Find makefile targets used to pipe into fzf |
-| [github-env](core/github-env/README.md) |  |  | \[Deprecated] Additional environment variable types for GitHub CI |
-| [multipublish](core/multipublish/README.md) |  |  | Publish packages to multiple providers easily |
-| [prettier-plugin-handlebars](core/prettier-plugin-handlebars/README.md) |  |  | Prettier plugin that automatically assigns the default parser for various handlebars files |
-| [remark-asciinema](core/remark-asciinema/README.md) |  |  | A remark plugin that transforms Asciinema links into embedded players or screenshots. |
-| [svelte-social-share-links](core/svelte-social-share-links/README.md) |  |  | Svelte/Web component to share the current url with various social media providers |
-| [typed-env](core/typed-env/README.md) |  |  | standard schema compatible environment validator |
-| [typed-events](core/typed-events/README.md) |  |  | Typed events store using standard schema |
-| [typed-nocodb-api](core/typed-nocodb-api/README.md) |  |  | Typed API client for NocoDB using Zod |
-| [typed-templates](core/typed-templates/README.md) |  |  | Use standard schema to validate and use handlebar template directories |
-| [types-github-action-env](core/types-github-action-env/README.md) |  |  | environment variable types for GitHub Action environment |
-| [types-lhci](core/types-lhci/README.md) |  |  | types for lhci configuration |
-
-
-
-
-
-### ☂️ Codecov coverage graph
-
-
-
-### ⭐ Stargazers
-
-[](https://github.com/stephansama/packages/stargazers)
-
-
-
-## Related repositories
-
-- [stow.nvim](https://github.com/stephansama/stow.nvim)
-- [@stephansama/actions](https://github.com/stephansama/actions)
diff --git a/core/single-file/README.md b/core/single-file/README.md
new file mode 100644
index 00000000..e569d588
--- /dev/null
+++ b/core/single-file/README.md
@@ -0,0 +1,60 @@
+# @stephansama/single-file
+
+[](https://github.com/stephansama/packages/tree/main/core/single-file)
+[](https://packages.stephansama.info/api/@stephansama/single-file)
+[](https://www.npmx.dev/package/@stephansama/single-file)
+[](https://jsr.io/@stephansama/single-file)
+[](https://socket.dev/npm/package/@stephansama/single-file/overview)
+[](https://www.npmx.dev/package/@stephansama/single-file)
+
+Fetch any webpage and produce a fully self-contained HTML file with all external resources — images, stylesheets, scripts, and SVGs — inlined directly into the document.
+
+##### Table of contents
+
+Open Table of contents
+
+- [Installation](#installation)
+- [CLI](#cli)
+- [Usage](#usage)
+
+
+
+## Installation
+
+```sh
+pnpm install @stephansama/single-file
+```
+
+## CLI
+
+```sh
+# outputs to single-file.html by default
+npx @stephansama/single-file
+
+# custom output path
+npx @stephansama/single-file --output my-page.html
+npx @stephansama/single-file -o my-page.html
+
+# verbose logging
+npx @stephansama/single-file --verbose
+npx @stephansama/single-file -v
+```
+
+| Flag | Alias | Default | Description |
+| ----------- | ----- | ------------------ | ----------------------------- |
+| `--output` | `-o` | `single-file.html` | Output path for the HTML file |
+| `--verbose` | `-v` | `false` | Enable verbose output |
+
+## Usage
+
+```javascript
+import singleFile from "@stephansama/single-file";
+
+export async function useAPI() {
+ const file = await singleFile.convertPageToSingleFile(
+ "https://blog.stephansama.info",
+ );
+
+ console.info(file);
+}
+```
diff --git a/core/single-file/cli.mjs b/core/single-file/cli.mjs
new file mode 100644
index 00000000..3e298e1a
--- /dev/null
+++ b/core/single-file/cli.mjs
@@ -0,0 +1,10 @@
+#!/usr/bin/env node
+
+"use strict";
+
+import("./dist/cli.js")
+ .then((mod) => mod.run())
+ .catch((error) => {
+ console.error(error);
+ process.exit(1);
+ });
diff --git a/core/single-file/example/index.js b/core/single-file/example/index.js
new file mode 100644
index 00000000..19f106ff
--- /dev/null
+++ b/core/single-file/example/index.js
@@ -0,0 +1,9 @@
+import singleFile from "../dist/index.cjs";
+
+export async function useAPI() {
+ const file = await singleFile.convertPageToSingleFile(
+ "https://blog.stephansama.info",
+ );
+
+ console.info(file);
+}
diff --git a/core/single-file/package.json b/core/single-file/package.json
new file mode 100644
index 00000000..7d3b0a93
--- /dev/null
+++ b/core/single-file/package.json
@@ -0,0 +1,60 @@
+{
+ "name": "@stephansama/single-file",
+ "version": "0.0.0",
+ "description": "create a single html file from a website url",
+ "keywords": [
+ "single-file"
+ ],
+ "homepage": "https://packages.stephansama.info/api/@stephansama/single-file",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/stephansama/packages.git",
+ "directory": "core/single-file"
+ },
+ "license": "MIT",
+ "author": {
+ "name": "Stephan Randle",
+ "email": "stephanrandle.dev@gmail.com",
+ "url": "https://stephansama.info"
+ },
+ "type": "module",
+ "exports": {
+ ".": {
+ "import": "./dist/index.js",
+ "require": "./dist/index.cjs"
+ },
+ "./package.json": "./package.json"
+ },
+ "main": "./dist/index.cjs",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.cts",
+ "bin": "./cli.mjs",
+ "files": [
+ "cli.mjs",
+ "dist"
+ ],
+ "scripts": {
+ "build": "tsdown",
+ "dev": "tsdown --watch",
+ "lint": "eslint ./ --pass-on-no-patterns --no-error-on-unmatched-pattern",
+ "lint:fix": "eslint ./ --fix"
+ },
+ "dependencies": {
+ "cheerio": "catalog:",
+ "dedent": "catalog:",
+ "he": "catalog:",
+ "ky": "catalog:",
+ "obug": "catalog:cli",
+ "oxc-parser": "catalog:"
+ },
+ "devDependencies": {
+ "@types/he": "catalog:",
+ "cleye": "catalog:cli",
+ "tsdown": "catalog:"
+ },
+ "publishConfig": {
+ "access": "public",
+ "provenance": true
+ },
+ "readme": "./README.md"
+}
diff --git a/core/single-file/src/cli.ts b/core/single-file/src/cli.ts
new file mode 100644
index 00000000..fdb7ed20
--- /dev/null
+++ b/core/single-file/src/cli.ts
@@ -0,0 +1,32 @@
+import { cli } from "cleye";
+import * as fs from "node:fs";
+
+import { convertPageToSingleFile } from ".";
+import * as log from "./log";
+
+export async function run() {
+ const argv = cli({
+ flags: {
+ output: {
+ alias: "o",
+ default: "single-file.html",
+ description: "output path for single html file",
+ type: String,
+ },
+ verbose: {
+ alias: "v",
+ default: false,
+ description: "Verbose output",
+ type: Boolean,
+ },
+ },
+ name: "single-file",
+ parameters: [``],
+ });
+
+ log.enable(argv.flags.verbose);
+
+ const file = await convertPageToSingleFile(argv._.url);
+
+ await fs.promises.writeFile(argv.flags.output, file, "utf8");
+}
diff --git a/core/single-file/src/import-map.test.ts b/core/single-file/src/import-map.test.ts
new file mode 100644
index 00000000..1369e7a2
--- /dev/null
+++ b/core/single-file/src/import-map.test.ts
@@ -0,0 +1,184 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import {
+ determineImportType,
+ imports,
+ loadImport,
+ writeImportMap,
+} from "./import-map";
+
+const mockKy = vi.hoisted(() => ({ get: vi.fn() }));
+
+vi.mock("ky", () => ({ default: mockKy }));
+
+function makeMockResponse(opts: {
+ buffer?: ArrayBuffer;
+ contentType?: null | string;
+ text?: string;
+}) {
+ return {
+ arrayBuffer: vi
+ .fn()
+ .mockResolvedValue(opts.buffer ?? new ArrayBuffer(0)),
+ headers: { get: vi.fn().mockReturnValue(opts.contentType ?? null) },
+ text: vi.fn().mockResolvedValue(opts.text ?? ""),
+ };
+}
+
+beforeEach(() => {
+ imports.clear();
+});
+
+afterEach(vi.clearAllMocks);
+
+describe("determineImportType", () => {
+ it.each([
+ ["application/javascript", "js"],
+ ["text/javascript", "js"],
+ ["application/ecmascript", "js"],
+ ["text/ecmascript", "js"],
+ ] as const)("%s → %s", (contentType, expected) => {
+ expect(determineImportType(contentType)).toBe(expected);
+ });
+
+ it("returns unknown for text/css", () => {
+ expect(determineImportType("text/css")).toBe("unknown");
+ });
+
+ it("returns unknown for null", () => {
+ expect(determineImportType(null)).toBe("unknown");
+ });
+
+ it("returns unknown for undefined", () => {
+ expect(determineImportType(undefined)).toBe("unknown");
+ });
+});
+
+describe("loadImport", () => {
+ it("loads text content and stores it in the imports map", async () => {
+ mockKy.get.mockReturnValue(
+ makeMockResponse({
+ contentType: "text/javascript",
+ text: "console.log('hi')",
+ }),
+ );
+
+ const result = await loadImport({ file: "https://example.com/app.js" });
+
+ expect(result).toBe("console.log('hi')");
+ const cached = imports.get("https://example.com/app.js");
+ expect(cached?.type).toBe("js");
+ expect(cached?.data).toBe("console.log('hi')");
+ });
+
+ it("loads binary content when isBinary is true", async () => {
+ const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47]).buffer;
+ mockKy.get.mockReturnValue(
+ makeMockResponse({ buffer, contentType: "image/png" }),
+ );
+
+ const result = await loadImport({
+ file: "https://example.com/img.png",
+ isBinary: true,
+ });
+
+ expect(result).toBeInstanceOf(ArrayBuffer);
+ expect(imports.get("https://example.com/img.png")?.type).toBe("binary");
+ });
+
+ it("returns cached text without making a second HTTP request", async () => {
+ mockKy.get.mockReturnValue(
+ makeMockResponse({
+ contentType: "text/javascript",
+ text: "cached",
+ }),
+ );
+
+ await loadImport({ file: "https://example.com/lib.js" });
+ await loadImport({ file: "https://example.com/lib.js" });
+
+ expect(mockKy.get).toHaveBeenCalledTimes(1);
+ });
+
+ it("returns cached binary without making a second HTTP request", async () => {
+ mockKy.get.mockReturnValue(
+ makeMockResponse({
+ buffer: new ArrayBuffer(4),
+ contentType: "image/png",
+ }),
+ );
+
+ await loadImport({
+ file: "https://example.com/img.png",
+ isBinary: true,
+ });
+ await loadImport({
+ file: "https://example.com/img.png",
+ isBinary: true,
+ });
+
+ expect(mockKy.get).toHaveBeenCalledTimes(1);
+ });
+
+ it("joins dirname with file when dirname is provided", async () => {
+ mockKy.get.mockReturnValue(makeMockResponse({ text: "body" }));
+
+ await loadImport({ dirname: "/assets", file: "script.js" });
+
+ expect(mockKy.get).toHaveBeenCalledWith("/assets/script.js");
+ });
+});
+
+describe("writeImportMap", () => {
+ it("returns an empty registry when there are no JS imports", async () => {
+ imports.set("https://example.com/style.css", {
+ contentType: "text/css",
+ data: "body {}",
+ type: "unknown",
+ });
+
+ const result = await writeImportMap();
+
+ expect(result).toContain("`;
+}
diff --git a/core/single-file/src/index.ts b/core/single-file/src/index.ts
new file mode 100644
index 00000000..b6740a4d
--- /dev/null
+++ b/core/single-file/src/index.ts
@@ -0,0 +1,30 @@
+import * as cheerio from "cheerio";
+import ky from "ky";
+
+import { writeImportMap } from "./import-map";
+import * as inline from "./inline";
+import * as log from "./log";
+
+export async function convertPageToSingleFile(url: string) {
+ const pageResponse = await ky.get(url);
+
+ log.info(`loading ${url}`);
+
+ if (!pageResponse?.headers?.get("Content-Type")?.includes("text/html")) {
+ throw new Error(`requested url \`${url}\` must be an html page.`);
+ }
+
+ const page = await pageResponse.text();
+
+ const $ = cheerio.load(page);
+
+ for (const inlineCallback of Object.values(inline)) {
+ await inlineCallback($, url);
+ }
+
+ const registryScript = await writeImportMap();
+
+ $("head").prepend(registryScript);
+
+ return $.html();
+}
diff --git a/core/single-file/src/inline.test.ts b/core/single-file/src/inline.test.ts
new file mode 100644
index 00000000..d8d747fe
--- /dev/null
+++ b/core/single-file/src/inline.test.ts
@@ -0,0 +1,266 @@
+import * as cheerio from "cheerio";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { imports } from "./import-map";
+import { img, link, script, svgUse } from "./inline";
+
+const mockKy = vi.hoisted(() => ({ get: vi.fn() }));
+
+vi.mock("ky", () => ({ default: mockKy }));
+
+function makeMockResponse(opts: {
+ buffer?: ArrayBuffer;
+ contentType?: null | string;
+ text?: string;
+}) {
+ return {
+ arrayBuffer: vi
+ .fn()
+ .mockResolvedValue(opts.buffer ?? new ArrayBuffer(0)),
+ headers: { get: vi.fn().mockReturnValue(opts.contentType ?? null) },
+ text: vi.fn().mockResolvedValue(opts.text ?? ""),
+ };
+}
+
+beforeEach(() => {
+ imports.clear();
+});
+
+afterEach(vi.clearAllMocks);
+
+describe("img", () => {
+ it("inlines a PNG image as a base64 data URI", async () => {
+ mockKy.get.mockReturnValue(
+ makeMockResponse({
+ buffer: new Uint8Array([0x89, 0x50, 0x4e, 0x47]).buffer,
+ contentType: "image/png",
+ }),
+ );
+
+ const $ = cheerio.load('
');
+ await img($, "https://example.com");
+
+ expect($("img").attr("src")).toMatch(/^data:image\/png;base64,/);
+ });
+
+ it("inlines a plain SVG (no fragment) as a base64 data URI", async () => {
+ const svgContent = ``;
+ mockKy.get.mockReturnValue(
+ makeMockResponse({
+ buffer: Buffer.from(svgContent).buffer as ArrayBuffer,
+ contentType: "image/svg+xml",
+ }),
+ );
+
+ const $ = cheerio.load('
');
+ await img($, "https://example.com");
+
+ expect($("img").attr("src")).toMatch(/^data:image\/svg\+xml;base64,/);
+ });
+
+ it("inlines an SVG symbol fragment as a URL-encoded data URI", async () => {
+ const svgContent = ``;
+ mockKy.get.mockReturnValue(
+ makeMockResponse({
+ buffer: Buffer.from(svgContent).buffer as ArrayBuffer,
+ contentType: "image/svg+xml",
+ }),
+ );
+
+ const $ = cheerio.load(
+ '
',
+ );
+ await img($, "https://example.com");
+
+ expect($("img").attr("src")).toMatch(/^data:image\/svg\+xml,/);
+ });
+
+ it("skips img elements with a non-URL src", async () => {
+ const $ = cheerio.load('
');
+ await img($, "https://example.com");
+
+ expect($("img").attr("src")).toBe("not a valid url");
+ expect(mockKy.get).not.toHaveBeenCalled();
+ });
+});
+
+describe("link", () => {
+ it("replaces a stylesheet link with an inline style tag", async () => {
+ mockKy.get.mockReturnValue(
+ makeMockResponse({
+ contentType: "text/css",
+ text: "body { color: red; }",
+ }),
+ );
+
+ const $ = cheerio.load(
+ '',
+ );
+ await link($, "https://example.com");
+
+ expect($("link").length).toBe(0);
+ expect($("style").text()).toContain("body { color: red; }");
+ });
+
+ it("replaces a favicon href with a data URI", async () => {
+ mockKy.get.mockReturnValue(
+ makeMockResponse({
+ buffer: new Uint8Array([0, 1, 2]).buffer,
+ contentType: "image/x-icon",
+ }),
+ );
+
+ const $ = cheerio.load(
+ '',
+ );
+ await link($, "https://example.com");
+
+ expect($("link").attr("href")).toMatch(/^data:/);
+ });
+
+ it("replaces an apple-touch-icon href with a data URI", async () => {
+ mockKy.get.mockReturnValue(
+ makeMockResponse({
+ buffer: new Uint8Array([0, 1, 2]).buffer,
+ contentType: "image/png",
+ }),
+ );
+
+ const $ = cheerio.load(
+ '',
+ );
+ await link($, "https://example.com");
+
+ expect($("link").attr("href")).toMatch(/^data:/);
+ });
+
+ it("leaves unrecognized rel attributes unchanged", async () => {
+ const $ = cheerio.load(
+ '',
+ );
+ await link($, "https://example.com");
+
+ expect(mockKy.get).not.toHaveBeenCalled();
+ expect($("link").attr("href")).toBe("https://fonts.googleapis.com");
+ });
+
+ it("skips link elements with a non-URL href", async () => {
+ const $ = cheerio.load('');
+ await link($, "https://example.com");
+
+ expect(mockKy.get).not.toHaveBeenCalled();
+ });
+});
+
+describe("script", () => {
+ it("inlines script content and removes the src attribute", async () => {
+ mockKy.get.mockReturnValue(
+ makeMockResponse({
+ contentType: "text/javascript",
+ text: "console.log('hello');",
+ }),
+ );
+
+ const $ = cheerio.load(
+ '',
+ );
+ await script($, "https://example.com");
+
+ expect($("script").attr("src")).toBeUndefined();
+ expect($("script").text()).toContain("console.log('hello')");
+ });
+
+ it("rewrites static imports to use the import map", async () => {
+ mockKy.get.mockReturnValue(
+ makeMockResponse({
+ contentType: "text/javascript",
+ text: `import { foo } from "https://example.com/lib.js";\nfoo();`,
+ }),
+ );
+
+ const $ = cheerio.load(
+ '',
+ );
+ await script($, "https://example.com");
+
+ const content = $("script").text();
+ expect(content).toContain(`window["imports"]`);
+ expect(content).toContain("await import(");
+ expect(content).not.toContain(`import { foo } from`);
+ });
+
+ it("rewrites dynamic imports to use the import map", async () => {
+ mockKy.get.mockReturnValue(
+ makeMockResponse({
+ contentType: "text/javascript",
+ text: `const mod = import("https://example.com/lib.js");`,
+ }),
+ );
+
+ const $ = cheerio.load(
+ '',
+ );
+ await script($, "https://example.com");
+
+ expect($("script").text()).toContain(`window["imports"]`);
+ });
+
+ it("skips script elements with a non-URL src", async () => {
+ const $ = cheerio.load('');
+ await script($, "https://example.com");
+
+ expect(mockKy.get).not.toHaveBeenCalled();
+ expect($("script").attr("src")).toBe("not a url");
+ });
+});
+
+describe("svgUse", () => {
+ const svgSprite = ``;
+
+ it("replaces the use element with symbol content and sets viewBox on the parent svg", async () => {
+ mockKy.get.mockReturnValue(
+ makeMockResponse({ contentType: "image/svg+xml", text: svgSprite }),
+ );
+
+ const $ = cheerio.load(
+ '',
+ );
+ await svgUse($, "https://example.com");
+
+ expect($("use").length).toBe(0);
+ expect($("svg").attr("viewBox")).toBe("0 0 32 32");
+ });
+
+ it("warns and skips use elements without a hash fragment", async () => {
+ const $ = cheerio.load(
+ '',
+ );
+ await svgUse($, "https://example.com");
+
+ expect(mockKy.get).not.toHaveBeenCalled();
+ expect($("use").length).toBe(1);
+ });
+
+ it("throws when the referenced symbol is not found in the fetched SVG", async () => {
+ mockKy.get.mockReturnValue(
+ makeMockResponse({ contentType: "image/svg+xml", text: svgSprite }),
+ );
+
+ const $ = cheerio.load(
+ '',
+ );
+
+ await expect(svgUse($, "https://example.com")).rejects.toThrow(
+ "unable to parse parent",
+ );
+ });
+
+ it("logs an error and skips use elements with a non-URL href", async () => {
+ const $ = cheerio.load(
+ '',
+ );
+ await svgUse($, "https://example.com");
+
+ expect(mockKy.get).not.toHaveBeenCalled();
+ });
+});
diff --git a/core/single-file/src/inline.ts b/core/single-file/src/inline.ts
new file mode 100644
index 00000000..4d545781
--- /dev/null
+++ b/core/single-file/src/inline.ts
@@ -0,0 +1,184 @@
+import * as cheerio from "cheerio";
+import { default as html, default as js } from "dedent";
+import path from "node:path";
+import * as oxc from "oxc-parser";
+
+import * as importMap from "./import-map";
+import * as log from "./log";
+import * as utilities from "./utilities";
+
+export type InlineFunction = (
+ $: cheerio.CheerioAPI,
+ baseUrl: string,
+) => Promise;
+
+export const img: InlineFunction = async ($, baseUrl) => {
+ for (const img of $("img[src]")) {
+ log.info(`loading \`img\` ${img.attribs.src}`);
+
+ const src = utilities.isUrl(img.attribs.src, baseUrl);
+ if (!src) continue;
+
+ const importedImage = await importMap.loadImport({
+ file: src,
+ isBinary: true,
+ });
+ const extension = img.attribs.src
+ .split(".")
+ .at(-1)
+ ?.replace(/#.*/, "")
+ .replace(/\?.*/, "");
+
+ switch (extension) {
+ case "svg": {
+ if (img.attribs.src.includes("#")) {
+ const $$ = cheerio.load(Buffer.from(importedImage), {
+ xmlMode: true,
+ });
+ const [_, hash] = img.attribs.src.split("#");
+ if (!hash) continue;
+
+ const symbol = $$(`symbol#${hash}`);
+ const viewBox = symbol.attr("viewBox") || "0 0 24 24";
+ const inner = symbol.html();
+ const svg = html`
+
+ `.trim();
+
+ const encoded = encodeURIComponent(svg)
+ .replace(/'/g, "%27")
+ .replace(/"/g, "%22");
+
+ $(img).attr("src", `data:image/svg+xml,${encoded}`);
+ break;
+ } else {
+ const dataUri = await utilities.bufferToDataUri(
+ importedImage,
+ importMap.imports.get(src)?.contentType,
+ );
+ $(img).attr("src", dataUri);
+ }
+ break;
+ }
+ default: {
+ const dataUri = await utilities.bufferToDataUri(
+ importedImage,
+ importMap.imports.get(src)?.contentType,
+ );
+ $(img).attr("src", dataUri);
+ }
+ }
+ }
+};
+
+export const link: InlineFunction = async ($, baseUrl) => {
+ for (const link of $("link[href]")) {
+ log.info(`loading \`link\` ${link.attribs.href}`);
+
+ const src = utilities.isUrl(link.attribs.href, baseUrl);
+ if (!src) continue;
+
+ switch (link.attribs.rel) {
+ case "apple-touch-icon":
+ case "icon":
+ case "shortcut icon": {
+ const buffer = await importMap.loadImport({
+ file: src,
+ isBinary: true,
+ });
+ const mime = importMap.imports.get(src)?.contentType;
+ const dataUri = await utilities.bufferToDataUri(buffer, mime);
+ $(link).attr("href", dataUri);
+ break;
+ }
+
+ case "stylesheet": {
+ const linkSrc = await importMap.loadImport({ file: src });
+
+ $(link).replaceWith(
+ html``,
+ );
+ break;
+ }
+ }
+ }
+};
+
+export const script: InlineFunction = async ($, baseUrl) => {
+ for (const script of $("script[src]")) {
+ log.info(`loading \`script\` ${script.attribs.src}`);
+ const src = utilities.isUrl(script.attribs.src, baseUrl);
+ if (!src) continue;
+
+ const dirname = src.startsWith("http")
+ ? undefined
+ : path.posix.dirname(new URL(src).pathname);
+
+ let scriptSrc = await importMap.loadImport({ dirname, file: src });
+
+ const parsed = await oxc.parse(src, scriptSrc);
+
+ for (const imported of parsed.module.staticImports) {
+ const entries = imported.entries
+ .map((entry) => {
+ return `${entry.importName}${entry.localName ? " as " + entry.localName : ""}`;
+ })
+ .join(",");
+
+ scriptSrc = [
+ scriptSrc.slice(0, imported.start),
+ js`const {${entries}}=await import(window["${importMap.WINDOW_KEY}"]["${imported.moduleRequest}"]);`,
+ scriptSrc.slice(imported.end),
+ ].join("");
+ }
+
+ for (const imported of parsed.module.dynamicImports) {
+ scriptSrc = [
+ scriptSrc.slice(0, imported.start),
+ js`await import(window["${importMap.WINDOW_KEY}"]["${imported.moduleRequest}"]);`,
+ scriptSrc.slice(imported.end),
+ ].join("");
+ }
+
+ $(script).removeAttr("src");
+ $(script).text(scriptSrc);
+ }
+};
+
+export const svgUse: InlineFunction = async ($, baseUrl) => {
+ for (const current of $("use[href]")) {
+ const [url, hash] = current.attribs.href.split("#");
+ if (!hash) {
+ log.warn(`no hash found for use element ${current.attribs.href}`);
+ continue;
+ }
+
+ log.info(`loading \`svg>use\` ${url}#${hash}`);
+
+ const src = utilities.isUrl(url, baseUrl);
+ if (!src) {
+ log.error(`unable to load source for use element`);
+ continue;
+ }
+
+ const svgMap = await importMap.loadImport({ file: src });
+ const $$ = cheerio.load(svgMap, { xmlMode: true });
+
+ const symbol = $$(`symbol#${hash}`);
+ const viewBox = symbol.attr("viewBox") || "0 0 24 24";
+ const inner = symbol.html();
+ if (!inner) {
+ throw new Error("unable to parse parent");
+ }
+
+ $(current).parent().attr("viewBox", viewBox);
+ $(current).replaceWith(inner);
+ }
+};
diff --git a/core/single-file/src/log.ts b/core/single-file/src/log.ts
new file mode 100644
index 00000000..f3cd2731
--- /dev/null
+++ b/core/single-file/src/log.ts
@@ -0,0 +1,30 @@
+import * as obug from "obug";
+
+export const DEBUG_BASE_NAMESPACE = "single-file" as const;
+export const DEBUG_NAMESPACES = ["error", "info", "warn"] as const;
+export type DEBUG_NAMESPACE = (typeof DEBUG_NAMESPACES)[number];
+export type DEBUG_SCOPE = `${typeof DEBUG_BASE_NAMESPACE}:${DEBUG_NAMESPACE}`;
+
+export const VERBOSE_SCOPE = "info" satisfies DEBUG_NAMESPACE;
+
+export const commonDebugOptions = {
+ log: console.info,
+ useColors: true,
+} satisfies obug.DebugOptions;
+
+export const debug = obug.createDebug(DEBUG_BASE_NAMESPACE, commonDebugOptions);
+
+export const [error, info, warn] = DEBUG_NAMESPACES.map((namespace, index) => {
+ debug.color = index + 1;
+ return debug.extend(namespace);
+});
+
+export function enable(isVerbose: boolean) {
+ const enabledScopes = DEBUG_NAMESPACES.filter((scope) => {
+ return scope !== VERBOSE_SCOPE || isVerbose;
+ })
+ .map((scope) => `${DEBUG_BASE_NAMESPACE}:${scope}`)
+ .join(",");
+
+ obug.enable(enabledScopes);
+}
diff --git a/core/single-file/src/utilities.test.ts b/core/single-file/src/utilities.test.ts
new file mode 100644
index 00000000..5ac7cc54
--- /dev/null
+++ b/core/single-file/src/utilities.test.ts
@@ -0,0 +1,146 @@
+import { describe, expect, it } from "vitest";
+
+import { bufferToDataUri, escapeScript, isProbablyUrl, isUrl } from "./utilities";
+
+describe("bufferToDataUri", () => {
+ it("defaults to image/png mime type", async () => {
+ const result = await bufferToDataUri(new Uint8Array([1, 2, 3]).buffer);
+ expect(result).toMatch(/^data:image\/png;base64,/);
+ });
+
+ it("defaults to image/png when mime is null", async () => {
+ const result = await bufferToDataUri(new Uint8Array([1, 2, 3]).buffer, null);
+ expect(result).toMatch(/^data:image\/png;base64,/);
+ });
+
+ it("uses the provided mime type", async () => {
+ const result = await bufferToDataUri(
+ new Uint8Array([1]).buffer,
+ "image/svg+xml",
+ );
+ expect(result).toMatch(/^data:image\/svg\+xml;base64,/);
+ });
+
+ it("encodes buffer content as base64", async () => {
+ // "hello" in ASCII bytes — use Uint8Array to avoid Node Buffer shared pool
+ const bytes = new Uint8Array([104, 101, 108, 108, 111]);
+ const result = await bufferToDataUri(bytes.buffer);
+ expect(result).toBe("data:image/png;base64,aGVsbG8=");
+ });
+
+ it("handles empty buffer", async () => {
+ const result = await bufferToDataUri(new ArrayBuffer(0));
+ expect(result).toBe("data:image/png;base64,");
+ });
+});
+
+describe("escapeScript", () => {
+ it("escapes backslashes", () => {
+ expect(escapeScript("a\\b")).toBe("a\\\\b");
+ });
+
+ it("escapes backticks", () => {
+ expect(escapeScript("`foo`")).toBe("\\`foo\\`");
+ });
+
+ it("escapes template literal expressions", () => {
+ expect(escapeScript("${x}")).toBe("\\${x}");
+ });
+
+ it("escapes opening script tags", () => {
+ expect(escapeScript("")).toBe("<\\/script>");
+ });
+
+ it("converts newlines to \\n literal", () => {
+ expect(escapeScript("a\nb")).toBe("a\\nb");
+ });
+
+ it("removes carriage returns", () => {
+ expect(escapeScript("a\r\nb")).toBe("a\\nb");
+ });
+
+ it("handles multiple replacements in one string", () => {
+ const input = "const x = `${y}`;\n";
+ const result = escapeScript(input);
+ // backtick is escaped (preceded by backslash)
+ expect(result).toContain("\\`");
+ expect(result).not.toMatch(/(?");
+ expect(result).toContain("<\\/script>");
+ });
+});
+
+describe("isProbablyUrl", () => {
+ it("returns false for empty string", () => {
+ expect(isProbablyUrl("")).toBe(false);
+ });
+
+ it("returns false for string containing only a newline", () => {
+ expect(isProbablyUrl("\n")).toBe(false);
+ });
+
+ it("returns false for opening brace", () => {
+ expect(isProbablyUrl("{")).toBe(false);
+ });
+
+ it("returns true for https URL", () => {
+ expect(isProbablyUrl("https://example.com/script.js")).toBe(true);
+ });
+
+ it("returns true for http URL", () => {
+ expect(isProbablyUrl("http://example.com/img.png")).toBe(true);
+ });
+
+ it("returns true for root-relative path", () => {
+ expect(isProbablyUrl("/assets/style.css")).toBe(true);
+ });
+
+ it("returns true for ./ relative path", () => {
+ expect(isProbablyUrl("./foo.js")).toBe(true);
+ });
+
+ it("returns true for ../ parent-relative path", () => {
+ expect(isProbablyUrl("../foo.js")).toBe(true);
+ });
+
+ it("returns true for bare filename", () => {
+ expect(isProbablyUrl("foo.js")).toBe(true);
+ });
+});
+
+describe("isUrl", () => {
+ const base = "https://example.com";
+
+ it("returns the resolved href for an absolute URL", () => {
+ expect(isUrl("https://cdn.example.com/style.css", base)).toBe(
+ "https://cdn.example.com/style.css",
+ );
+ });
+
+ it("resolves a root-relative URL against the base", () => {
+ expect(isUrl("/style.css", base)).toBe("https://example.com/style.css");
+ });
+
+ it("resolves a ./ relative URL against the base", () => {
+ expect(isUrl("./script.js", base)).toBe(
+ "https://example.com/script.js",
+ );
+ });
+
+ it("returns false when isProbablyUrl fails", () => {
+ expect(isUrl("\n", base)).toBe(false);
+ });
+
+ it("returns false when the URL cannot be resolved against the base", () => {
+ expect(isUrl("foo.js", "not-a-valid-base")).toBe(false);
+ });
+});
diff --git a/core/single-file/src/utilities.ts b/core/single-file/src/utilities.ts
new file mode 100644
index 00000000..8ab115b9
--- /dev/null
+++ b/core/single-file/src/utilities.ts
@@ -0,0 +1,42 @@
+export async function bufferToDataUri(
+ buffer: ArrayBuffer,
+ mime?: null | string,
+) {
+ const base64 = Buffer.from(buffer).toString("base64");
+ return `data:${mime || "image/png"};base64,${base64}`;
+}
+
+export function escapeScript(script: string) {
+ return script
+ .replaceAll("\\", "\\\\")
+ .replaceAll("`", "\\`")
+ .replaceAll("${", "\\${")
+ .replaceAll("