From 011121a95d693368f5c32642ef6e2679b04f1104 Mon Sep 17 00:00:00 2001 From: Ben Holmes Date: Sat, 4 Jul 2026 09:42:12 -0400 Subject: [PATCH] Add Tailwind HTML embed build Co-Authored-By: Oz --- CHANGELOG.md | 1 + README.md | 18 +- apps/desktop/fixtures/playground/README.md | 6 + .../fixtures/playground/project-ideas.md | 2 + .../playground/tailwind-card.dist.html | 382 ++++++++++++++++++ .../fixtures/playground/tailwind-card.html | 20 + apps/desktop/package.json | 1 + apps/desktop/scripts/build-html-embed.mjs | 101 +++++ biome.json | 10 + 9 files changed, 540 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/fixtures/playground/tailwind-card.dist.html create mode 100644 apps/desktop/fixtures/playground/tailwind-card.html create mode 100644 apps/desktop/scripts/build-html-embed.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index d7cc6ca2..3f6b02f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Format loosely follows [Keep a Changelog](https://keepachangelog.com). ## [Unreleased] ### Added +- HTML iframe embeds can now be built into `.dist.html` files with pruned Tailwind v4 CSS. ### Changed diff --git a/README.md b/README.md index 1567bb25..fbf5f32a 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,22 @@ pnpm check # run Biome pnpm typecheck # typecheck all packages ``` +## HTML App Tailwind builds + +For local HTML Apps or iframe Embeds that use Tailwind utilities, build a static artifact instead of loading Tailwind at runtime: + +```sh +pnpm --filter @hubble.md/desktop build:html-embed path/to/file-index.html +``` + +The default output is `path/to/file-index.dist.html`. Point Markdown embeds at the built file: + +```html + +``` + +The builder scans the source HTML class attributes, compiles Tailwind v4 CSS for those candidates only, and inlines the result into the output HTML. + ## Documentation - [`CONTRIBUTING.md`](./CONTRIBUTING.md) covers the contribution flow, local setup, and pre-PR checks. @@ -95,4 +111,4 @@ This project follows our [Code of Conduct](./CODE_OF_CONDUCT.md). To report a se ## License -Hubble.md is licensed under the [MIT License](./LICENSE). \ No newline at end of file +Hubble.md is licensed under the [MIT License](./LICENSE). diff --git a/apps/desktop/fixtures/playground/README.md b/apps/desktop/fixtures/playground/README.md index e76c621b..6f95f0de 100644 --- a/apps/desktop/fixtures/playground/README.md +++ b/apps/desktop/fixtures/playground/README.md @@ -3,3 +3,9 @@ This workspace is copied into `.dev-electron/playground` for local Electron dev runs. Open `file-index.html` or `todo-demo.html` in Hubble to test full-screen HTML Apps. + +`tailwind-card.html` is the source file for a Tailwind-built iframe Embed. Rebuild it with: + +```sh +pnpm --filter @hubble.md/desktop build:html-embed apps/desktop/fixtures/playground/tailwind-card.html +``` diff --git a/apps/desktop/fixtures/playground/project-ideas.md b/apps/desktop/fixtures/playground/project-ideas.md index c3580ecd..256d616e 100644 --- a/apps/desktop/fixtures/playground/project-ideas.md +++ b/apps/desktop/fixtures/playground/project-ideas.md @@ -12,4 +12,6 @@ Use this note to test **bold text**, *italic text*, `inline code`, [[effective-l Open `file-index.html` or `todo-demo.html` from the sidebar to try full-screen HTML Apps. See [[samples/interview-prep]] for prompts. + + ![Project sketch](project-ideas.assets/project-sketch.jpg) diff --git a/apps/desktop/fixtures/playground/tailwind-card.dist.html b/apps/desktop/fixtures/playground/tailwind-card.dist.html new file mode 100644 index 00000000..801a334d --- /dev/null +++ b/apps/desktop/fixtures/playground/tailwind-card.dist.html @@ -0,0 +1,382 @@ + + + + + + Tailwind embed card + + + +
+

+ Built iframe embed +

+

Tailwind utilities, static CSS

+

+ This file builds to tailwind-card.dist.html + so Hubble can embed it without loading Tailwind in the iframe. +

+
+ + diff --git a/apps/desktop/fixtures/playground/tailwind-card.html b/apps/desktop/fixtures/playground/tailwind-card.html new file mode 100644 index 00000000..b0d054bf --- /dev/null +++ b/apps/desktop/fixtures/playground/tailwind-card.html @@ -0,0 +1,20 @@ + + + + + + Tailwind embed card + + +
+

+ Built iframe embed +

+

Tailwind utilities, static CSS

+

+ This file builds to tailwind-card.dist.html + so Hubble can embed it without loading Tailwind in the iframe. +

+
+ + diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 08f99aed..5067e684 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -24,6 +24,7 @@ "bundle:linux": "pnpm build && electron-builder --linux", "bundle:linux:arm64": "pnpm build && electron-builder --linux --arm64", "preview": "vite preview", + "build:html-embed": "node scripts/build-html-embed.mjs", "sync:playground": "node scripts/sync-playground.mjs", "test": "vitest run" }, diff --git a/apps/desktop/scripts/build-html-embed.mjs b/apps/desktop/scripts/build-html-embed.mjs new file mode 100644 index 00000000..775ab0c6 --- /dev/null +++ b/apps/desktop/scripts/build-html-embed.mjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node + +import { readFile, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { compile } from "tailwindcss"; + +const require = createRequire(import.meta.url); + +const sourceArg = process.argv[2]; +const outputArg = process.argv[3]; +const callerCwd = process.env.INIT_CWD ?? process.cwd(); + +if ( + !sourceArg || + process.argv.includes("--help") || + process.argv.includes("-h") +) { + console.log(`Usage: pnpm --filter @hubble.md/desktop build:html-embed [output.html] + +Builds a local HTML App or iframe Embed with Tailwind v4 utilities inlined. +Default output: .dist.html`); + process.exit(sourceArg ? 0 : 1); +} + +const sourcePath = path.resolve(callerCwd, sourceArg); +const outputPath = outputArg + ? path.resolve(callerCwd, outputArg) + : sourcePath.replace(/\.html$/i, ".dist.html"); + +if (sourcePath === outputPath) { + throw new Error("Output path must differ from source path."); +} + +const html = await readFile(sourcePath, "utf8"); +const classNames = extractClassNames(html); +const compiler = await compile('@import "tailwindcss";', { + base: path.dirname(sourcePath), + loadStylesheet, +}); +const css = compiler.build(classNames); +const builtHtml = injectBuiltCss(html, css, classNames.length); + +await writeFile(outputPath, builtHtml); +console.log(`Built ${path.relative(process.cwd(), outputPath)}`); + +function extractClassNames(html) { + const classes = new Set(); + const attributePattern = + /(?:class|:class|x-bind:class)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi; + for (const match of html.matchAll(attributePattern)) { + const value = match[1] ?? match[2] ?? ""; + for (const token of value.matchAll(/[A-Za-z0-9_:/![\].%#()@-]+/g)) { + const className = token[0]; + if (isTailwindCandidate(className)) classes.add(className); + } + } + return [...classes].sort(); +} + +function isTailwindCandidate(value) { + if (!value || value === "true" || value === "false") return false; + if (/^\d+$/.test(value)) return false; + return /[-:[\]/]/.test(value) || /^[mp][trblxyise]?-\d/.test(value); +} + +async function loadStylesheet(id, base) { + const filePath = stylesheetPath(id, base); + return { + content: await readFile(filePath, "utf8"), + base: path.dirname(filePath), + }; +} + +function stylesheetPath(id, base) { + if (id === "tailwindcss") return require.resolve("tailwindcss/index.css"); + if (id.startsWith("tailwindcss/")) return require.resolve(id); + return path.resolve(base, id); +} + +function injectBuiltCss(html, css, classCount) { + const withoutTailwindRuntime = html + .replace( + /]*\bsrc=(["'])https:\/\/cdn\.tailwindcss\.com\/?\1[^>]*><\/script>\s*/gi, + "", + ) + .replace( + /]*\bsrc=(["'])https:\/\/unpkg\.com\/@tailwindcss\/browser[^>]*><\/script>\s*/gi, + "", + ); + const style = ``; + if (/<\/head\s*>/i.test(withoutTailwindRuntime)) { + return withoutTailwindRuntime.replace( + /<\/head\s*>/i, + `${style}\n\t`, + ); + } + return `${style}\n${withoutTailwindRuntime}`; +} diff --git a/biome.json b/biome.json index 377bd319..a44c0d14 100644 --- a/biome.json +++ b/biome.json @@ -54,6 +54,16 @@ } } } + }, + { + "includes": ["**/*.dist.html"], + "linter": { + "rules": { + "complexity": { + "noImportantStyles": "off" + } + } + } } ] }