diff --git a/hatch_build.py b/hatch_build.py
index ffaa08f..cc52462 100644
--- a/hatch_build.py
+++ b/hatch_build.py
@@ -1,7 +1,7 @@
"""Hatch build hook to vendor CDN assets for offline use.
During wheel builds, this hook:
-1. Parses base.html to find elements with data-vendor attributes
+1. Reads vendor-assets.json to find assets marked for vendoring
2. Downloads each CDN asset to a temporary vendor directory
3. Rewrites base.html to use local /static/vendor/ paths
4. Fetches and bundles license information
@@ -13,6 +13,7 @@ distributed wheel has vendored assets.
from __future__ import annotations
+import json
import re
import shutil
import subprocess
@@ -23,22 +24,6 @@ from urllib.request import Request, urlopen
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
-# Matches elements with data-vendor attribute: extracts URL and target filename
-# Example: ",
+ 1, # Only replace first occurrence
+ )
# Write modified base.html to temp
templates_dir = temp_dir / "templates"
diff --git a/src/compose_farm/web/cdn.py b/src/compose_farm/web/cdn.py
index 6b727ea..aedaa4c 100644
--- a/src/compose_farm/web/cdn.py
+++ b/src/compose_farm/web/cdn.py
@@ -1,78 +1,39 @@
"""CDN asset definitions and caching for tests and demo recordings.
-This module provides a single source of truth for CDN asset URLs used in
-browser tests and demo recordings. Assets are intercepted and served from
-a local cache to eliminate network variability.
+This module provides CDN asset URLs used in browser tests and demo recordings.
+Assets are intercepted and served from a local cache to eliminate network
+variability.
-Note: The canonical list of CDN assets for production is in base.html
-(with data-vendor attributes). This module includes those plus dynamically
-loaded assets (like Monaco editor modules loaded by app.js).
+The canonical list of CDN assets is in vendor-assets.json. This module loads
+that file and provides the CDN_ASSETS dict for test caching.
"""
from __future__ import annotations
+import json
import subprocess
-from typing import TYPE_CHECKING
+from pathlib import Path
+
+
+def _load_cdn_assets() -> dict[str, tuple[str, str]]:
+ """Load CDN assets from vendor-assets.json.
+
+ Returns:
+ Dict mapping URL to (filename, content_type) tuple.
+
+ """
+ json_path = Path(__file__).parent / "vendor-assets.json"
+ with json_path.open() as f:
+ config = json.load(f)
+
+ return {asset["url"]: (asset["filename"], asset["content_type"]) for asset in config["assets"]}
-if TYPE_CHECKING:
- from pathlib import Path
# CDN assets to cache locally for tests/demos
# Format: URL -> (local_filename, content_type)
#
-# If tests fail with "Uncached CDN request", add the URL here.
-CDN_ASSETS: dict[str, tuple[str, str]] = {
- # From base.html (data-vendor attributes)
- "https://cdn.jsdelivr.net/npm/daisyui@5/themes.css": ("daisyui-themes.css", "text/css"),
- "https://cdn.jsdelivr.net/npm/daisyui@5": ("daisyui.css", "text/css"),
- "https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4": (
- "tailwind.js",
- "application/javascript",
- ),
- "https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css": ("xterm.css", "text/css"),
- "https://unpkg.com/htmx.org@2.0.4": ("htmx.js", "application/javascript"),
- "https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js": (
- "xterm.js",
- "application/javascript",
- ),
- "https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js": (
- "xterm-fit.js",
- "application/javascript",
- ),
- "https://unpkg.com/idiomorph/dist/idiomorph.min.js": (
- "idiomorph.js",
- "application/javascript",
- ),
- "https://unpkg.com/idiomorph/dist/idiomorph-ext.min.js": (
- "idiomorph-ext.js",
- "application/javascript",
- ),
- # Monaco editor - dynamically loaded by app.js
- "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/loader.js": (
- "monaco-loader.js",
- "application/javascript",
- ),
- "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/editor/editor.main.js": (
- "monaco-editor-main.js",
- "application/javascript",
- ),
- "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/editor/editor.main.css": (
- "monaco-editor-main.css",
- "text/css",
- ),
- "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/base/worker/workerMain.js": (
- "monaco-workerMain.js",
- "application/javascript",
- ),
- "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/basic-languages/yaml/yaml.js": (
- "monaco-yaml.js",
- "application/javascript",
- ),
- "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/base/browser/ui/codicons/codicon/codicon.ttf": (
- "monaco-codicon.ttf",
- "font/ttf",
- ),
-}
+# If tests fail with "Uncached CDN request", add the URL to vendor-assets.json.
+CDN_ASSETS: dict[str, tuple[str, str]] = _load_cdn_assets()
def download_url(url: str) -> bytes | None:
@@ -107,6 +68,7 @@ def ensure_vendor_cache(cache_dir: Path) -> Path:
filepath = cache_dir / filename
if filepath.exists():
continue
+ filepath.parent.mkdir(parents=True, exist_ok=True)
content = download_url(url)
if not content:
msg = f"Failed to download {url} - check network/curl"
diff --git a/src/compose_farm/web/static/app.js b/src/compose_farm/web/static/app.js
index ab4e8fd..c877c19 100644
--- a/src/compose_farm/web/static/app.js
+++ b/src/compose_farm/web/static/app.js
@@ -332,10 +332,14 @@ function loadMonaco(callback) {
monacoLoading = true;
// Load the Monaco loader script
+ // Use local paths when running from vendored wheel, CDN otherwise
+ const monacoBase = window.CF_VENDORED
+ ? '/static/vendor/monaco'
+ : 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs';
const script = document.createElement('script');
- script.src = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/loader.js';
+ script.src = monacoBase + '/loader.js';
script.onload = function() {
- require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs' }});
+ require.config({ paths: { vs: monacoBase }});
require(['vs/editor/editor.main'], function() {
monacoLoaded = true;
monacoLoading = false;
diff --git a/src/compose_farm/web/templates/base.html b/src/compose_farm/web/templates/base.html
index 09be520..7c75ebf 100644
--- a/src/compose_farm/web/templates/base.html
+++ b/src/compose_farm/web/templates/base.html
@@ -97,8 +97,8 @@
-
-
+
+
diff --git a/src/compose_farm/web/vendor-assets.json b/src/compose_farm/web/vendor-assets.json
new file mode 100644
index 0000000..d2cbcae
--- /dev/null
+++ b/src/compose_farm/web/vendor-assets.json
@@ -0,0 +1,122 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$comment": "CDN assets vendored into production builds and cached for tests",
+ "assets": [
+ {
+ "url": "https://cdn.jsdelivr.net/npm/daisyui@5",
+ "filename": "daisyui.css",
+ "content_type": "text/css",
+ "package": "daisyui"
+ },
+ {
+ "url": "https://cdn.jsdelivr.net/npm/daisyui@5/themes.css",
+ "filename": "daisyui-themes.css",
+ "content_type": "text/css",
+ "package": "daisyui"
+ },
+ {
+ "url": "https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4",
+ "filename": "tailwind.js",
+ "content_type": "application/javascript",
+ "package": "tailwindcss"
+ },
+ {
+ "url": "https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css",
+ "filename": "xterm.css",
+ "content_type": "text/css",
+ "package": "xterm"
+ },
+ {
+ "url": "https://unpkg.com/htmx.org@2.0.4",
+ "filename": "htmx.js",
+ "content_type": "application/javascript",
+ "package": "htmx"
+ },
+ {
+ "url": "https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js",
+ "filename": "xterm.js",
+ "content_type": "application/javascript",
+ "package": "xterm"
+ },
+ {
+ "url": "https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js",
+ "filename": "xterm-fit.js",
+ "content_type": "application/javascript",
+ "package": "xterm"
+ },
+ {
+ "url": "https://unpkg.com/idiomorph/dist/idiomorph.min.js",
+ "filename": "idiomorph.js",
+ "content_type": "application/javascript",
+ "package": "idiomorph"
+ },
+ {
+ "url": "https://unpkg.com/idiomorph/dist/idiomorph-ext.min.js",
+ "filename": "idiomorph-ext.js",
+ "content_type": "application/javascript",
+ "package": "idiomorph"
+ },
+ {
+ "url": "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/loader.js",
+ "filename": "monaco/loader.js",
+ "content_type": "application/javascript",
+ "package": "monaco-editor"
+ },
+ {
+ "url": "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/editor/editor.main.js",
+ "filename": "monaco/editor/editor.main.js",
+ "content_type": "application/javascript",
+ "package": "monaco-editor"
+ },
+ {
+ "url": "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/editor/editor.main.css",
+ "filename": "monaco/editor/editor.main.css",
+ "content_type": "text/css",
+ "package": "monaco-editor"
+ },
+ {
+ "url": "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/base/worker/workerMain.js",
+ "filename": "monaco/base/worker/workerMain.js",
+ "content_type": "application/javascript",
+ "package": "monaco-editor"
+ },
+ {
+ "url": "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/basic-languages/yaml/yaml.js",
+ "filename": "monaco/basic-languages/yaml/yaml.js",
+ "content_type": "application/javascript",
+ "package": "monaco-editor"
+ },
+ {
+ "url": "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/base/browser/ui/codicons/codicon/codicon.ttf",
+ "filename": "monaco/base/browser/ui/codicons/codicon/codicon.ttf",
+ "content_type": "font/ttf",
+ "package": "monaco-editor"
+ }
+ ],
+ "licenses": {
+ "htmx": {
+ "type": "MIT",
+ "url": "https://raw.githubusercontent.com/bigskysoftware/htmx/master/LICENSE"
+ },
+ "idiomorph": {
+ "type": "BSD-2-Clause",
+ "url": "https://raw.githubusercontent.com/bigskysoftware/idiomorph/main/LICENSE"
+ },
+ "xterm": {
+ "type": "MIT",
+ "url": "https://raw.githubusercontent.com/xtermjs/xterm.js/master/LICENSE"
+ },
+ "daisyui": {
+ "type": "MIT",
+ "url": "https://raw.githubusercontent.com/saadeghi/daisyui/master/LICENSE"
+ },
+ "tailwindcss": {
+ "type": "MIT",
+ "url": "https://raw.githubusercontent.com/tailwindlabs/tailwindcss/master/LICENSE"
+ },
+ "monaco-editor": {
+ "type": "MIT",
+ "url": "https://raw.githubusercontent.com/microsoft/monaco-editor/main/LICENSE.txt"
+ }
+ }
+}
diff --git a/tests/web/test_htmx_browser.py b/tests/web/test_htmx_browser.py
index 390c779..e1d0d9c 100644
--- a/tests/web/test_htmx_browser.py
+++ b/tests/web/test_htmx_browser.py
@@ -4,7 +4,7 @@ Run with: uv run pytest tests/web/test_htmx_browser.py -v --no-cov
CDN assets are cached locally (in .pytest_cache/vendor/) to eliminate network
variability. If a test fails with "Uncached CDN request", add the URL to
-compose_farm.web.cdn.CDN_ASSETS.
+src/compose_farm/web/vendor-assets.json.
"""
from __future__ import annotations
@@ -90,7 +90,7 @@ def page(page: Page, vendor_cache: Path) -> Page:
return
# Uncached CDN request - abort with helpful error
route.abort("failed")
- msg = f"Uncached CDN request: {url}\n\nAdd this URL to CDN_ASSETS in tests/web/test_htmx_browser.py"
+ msg = f"Uncached CDN request: {url}\n\nAdd this URL to src/compose_farm/web/vendor-assets.json"
raise RuntimeError(msg)
page.route(re.compile(r"https://(cdn\.jsdelivr\.net|unpkg\.com)/.*"), handle_cdn)