Unify vendor assets configuration in single JSON file (#141)

This commit is contained in:
Bas Nijholt
2025-12-31 19:02:41 +01:00
committed by GitHub
parent eac9338352
commit 8302f1d97a
6 changed files with 200 additions and 105 deletions

View File

@@ -1,7 +1,7 @@
"""Hatch build hook to vendor CDN assets for offline use. """Hatch build hook to vendor CDN assets for offline use.
During wheel builds, this hook: 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 2. Downloads each CDN asset to a temporary vendor directory
3. Rewrites base.html to use local /static/vendor/ paths 3. Rewrites base.html to use local /static/vendor/ paths
4. Fetches and bundles license information 4. Fetches and bundles license information
@@ -13,6 +13,7 @@ distributed wheel has vendored assets.
from __future__ import annotations from __future__ import annotations
import json
import re import re
import shutil import shutil
import subprocess import subprocess
@@ -23,22 +24,6 @@ from urllib.request import Request, urlopen
from hatchling.builders.hooks.plugin.interface import BuildHookInterface from hatchling.builders.hooks.plugin.interface import BuildHookInterface
# Matches elements with data-vendor attribute: extracts URL and target filename
# Example: <script src="https://..." data-vendor="htmx.js">
# Captures: (1) src/href, (2) URL, (3) attributes between, (4) vendor filename
VENDOR_PATTERN = re.compile(r'(src|href)="(https://[^"]+)"([^>]*?)data-vendor="([^"]+)"')
# License URLs for each package (GitHub raw URLs)
LICENSE_URLS: dict[str, tuple[str, str]] = {
"htmx": ("MIT", "https://raw.githubusercontent.com/bigskysoftware/htmx/master/LICENSE"),
"xterm": ("MIT", "https://raw.githubusercontent.com/xtermjs/xterm.js/master/LICENSE"),
"daisyui": ("MIT", "https://raw.githubusercontent.com/saadeghi/daisyui/master/LICENSE"),
"tailwindcss": (
"MIT",
"https://raw.githubusercontent.com/tailwindlabs/tailwindcss/master/LICENSE",
),
}
def _download(url: str) -> bytes: def _download(url: str) -> bytes:
"""Download a URL, trying urllib first then curl as fallback.""" """Download a URL, trying urllib first then curl as fallback."""
@@ -61,7 +46,14 @@ def _download(url: str) -> bytes:
return bytes(result.stdout) return bytes(result.stdout)
def _generate_licenses_file(temp_dir: Path) -> None: def _load_vendor_assets(root: Path) -> dict[str, Any]:
"""Load vendor-assets.json from the web module."""
json_path = root / "src" / "compose_farm" / "web" / "vendor-assets.json"
with json_path.open() as f:
return json.load(f)
def _generate_licenses_file(temp_dir: Path, licenses: dict[str, dict[str, str]]) -> None:
"""Download and combine license files into LICENSES.txt.""" """Download and combine license files into LICENSES.txt."""
lines = [ lines = [
"# Vendored Dependencies - License Information", "# Vendored Dependencies - License Information",
@@ -73,7 +65,9 @@ def _generate_licenses_file(temp_dir: Path) -> None:
"", "",
] ]
for pkg_name, (license_type, license_url) in LICENSE_URLS.items(): for pkg_name, license_info in licenses.items():
license_type = license_info["type"]
license_url = license_info["url"]
lines.append(f"## {pkg_name} ({license_type})") lines.append(f"## {pkg_name} ({license_type})")
lines.append(f"Source: {license_url}") lines.append(f"Source: {license_url}")
lines.append("") lines.append("")
@@ -107,44 +101,57 @@ class VendorAssetsHook(BuildHookInterface): # type: ignore[misc]
if not base_html_path.exists(): if not base_html_path.exists():
return return
# Load vendor assets configuration
vendor_config = _load_vendor_assets(Path(self.root))
assets_to_vendor = vendor_config["assets"]
if not assets_to_vendor:
return
# Create temp directory for vendored assets # Create temp directory for vendored assets
temp_dir = Path(tempfile.mkdtemp(prefix="compose_farm_vendor_")) temp_dir = Path(tempfile.mkdtemp(prefix="compose_farm_vendor_"))
vendor_dir = temp_dir / "vendor" vendor_dir = temp_dir / "vendor"
vendor_dir.mkdir() vendor_dir.mkdir()
# Read and parse base.html # Read base.html
html_content = base_html_path.read_text() html_content = base_html_path.read_text()
# Build URL to filename mapping and download assets
url_to_filename: dict[str, str] = {} url_to_filename: dict[str, str] = {}
for asset in assets_to_vendor:
# Find all elements with data-vendor attribute and download them url = asset["url"]
for match in VENDOR_PATTERN.finditer(html_content): filename = asset["filename"]
url = match.group(2)
filename = match.group(4)
if url in url_to_filename:
continue
url_to_filename[url] = filename url_to_filename[url] = filename
filepath = vendor_dir / filename
filepath.parent.mkdir(parents=True, exist_ok=True)
content = _download(url) content = _download(url)
(vendor_dir / filename).write_bytes(content) filepath.write_bytes(content)
if not url_to_filename: # Generate LICENSES.txt from the JSON config
return _generate_licenses_file(vendor_dir, vendor_config["licenses"])
# Generate LICENSES.txt # Rewrite HTML: replace CDN URLs with local paths and remove data-vendor attributes
_generate_licenses_file(vendor_dir) # Pattern matches: src="URL" ... data-vendor="filename" or href="URL" ... data-vendor="filename"
vendor_pattern = re.compile(r'(src|href)="(https://[^"]+)"([^>]*?)data-vendor="([^"]+)"')
# Rewrite HTML to use local paths (remove data-vendor, update URL)
def replace_vendor_tag(match: re.Match[str]) -> str: def replace_vendor_tag(match: re.Match[str]) -> str:
attr = match.group(1) # src or href attr = match.group(1) # src or href
url = match.group(2) url = match.group(2)
between = match.group(3) # attributes between URL and data-vendor between = match.group(3) # attributes between URL and data-vendor
filename = match.group(4)
if url in url_to_filename: if url in url_to_filename:
filename = url_to_filename[url]
return f'{attr}="/static/vendor/{filename}"{between}' return f'{attr}="/static/vendor/{filename}"{between}'
return match.group(0) return match.group(0)
modified_html = VENDOR_PATTERN.sub(replace_vendor_tag, html_content) modified_html = vendor_pattern.sub(replace_vendor_tag, html_content)
# Inject vendored mode flag for JavaScript to detect
# Insert right after <head> tag so it's available early
modified_html = modified_html.replace(
"<head>",
"<head>\n <script>window.CF_VENDORED=true;</script>",
1, # Only replace first occurrence
)
# Write modified base.html to temp # Write modified base.html to temp
templates_dir = temp_dir / "templates" templates_dir = temp_dir / "templates"

View File

@@ -1,78 +1,39 @@
"""CDN asset definitions and caching for tests and demo recordings. """CDN asset definitions and caching for tests and demo recordings.
This module provides a single source of truth for CDN asset URLs used in This module provides CDN asset URLs used in browser tests and demo recordings.
browser tests and demo recordings. Assets are intercepted and served from Assets are intercepted and served from a local cache to eliminate network
a local cache to eliminate network variability. variability.
Note: The canonical list of CDN assets for production is in base.html The canonical list of CDN assets is in vendor-assets.json. This module loads
(with data-vendor attributes). This module includes those plus dynamically that file and provides the CDN_ASSETS dict for test caching.
loaded assets (like Monaco editor modules loaded by app.js).
""" """
from __future__ import annotations from __future__ import annotations
import json
import subprocess 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 # CDN assets to cache locally for tests/demos
# Format: URL -> (local_filename, content_type) # Format: URL -> (local_filename, content_type)
# #
# If tests fail with "Uncached CDN request", add the URL here. # If tests fail with "Uncached CDN request", add the URL to vendor-assets.json.
CDN_ASSETS: dict[str, tuple[str, str]] = { CDN_ASSETS: dict[str, tuple[str, str]] = _load_cdn_assets()
# 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",
),
}
def download_url(url: str) -> bytes | None: def download_url(url: str) -> bytes | None:
@@ -107,6 +68,7 @@ def ensure_vendor_cache(cache_dir: Path) -> Path:
filepath = cache_dir / filename filepath = cache_dir / filename
if filepath.exists(): if filepath.exists():
continue continue
filepath.parent.mkdir(parents=True, exist_ok=True)
content = download_url(url) content = download_url(url)
if not content: if not content:
msg = f"Failed to download {url} - check network/curl" msg = f"Failed to download {url} - check network/curl"

View File

@@ -332,10 +332,14 @@ function loadMonaco(callback) {
monacoLoading = true; monacoLoading = true;
// Load the Monaco loader script // 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'); 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() { 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() { require(['vs/editor/editor.main'], function() {
monacoLoaded = true; monacoLoaded = true;
monacoLoading = false; monacoLoading = false;

View File

@@ -97,8 +97,8 @@
<!-- Scripts - HTMX first --> <!-- Scripts - HTMX first -->
<script src="https://unpkg.com/htmx.org@2.0.4" data-vendor="htmx.js"></script> <script src="https://unpkg.com/htmx.org@2.0.4" data-vendor="htmx.js"></script>
<script src="https://unpkg.com/idiomorph/dist/idiomorph.min.js"></script> <script src="https://unpkg.com/idiomorph/dist/idiomorph.min.js" data-vendor="idiomorph.js"></script>
<script src="https://unpkg.com/idiomorph/dist/idiomorph-ext.min.js"></script> <script src="https://unpkg.com/idiomorph/dist/idiomorph-ext.min.js" data-vendor="idiomorph-ext.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js" data-vendor="xterm.js"></script> <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js" data-vendor="xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js" data-vendor="xterm-fit.js"></script> <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js" data-vendor="xterm-fit.js"></script>
<script src="/static/app.js"></script> <script src="/static/app.js"></script>

View File

@@ -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"
}
}
}

View File

@@ -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 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 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 from __future__ import annotations
@@ -90,7 +90,7 @@ def page(page: Page, vendor_cache: Path) -> Page:
return return
# Uncached CDN request - abort with helpful error # Uncached CDN request - abort with helpful error
route.abort("failed") 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) raise RuntimeError(msg)
page.route(re.compile(r"https://(cdn\.jsdelivr\.net|unpkg\.com)/.*"), handle_cdn) page.route(re.compile(r"https://(cdn\.jsdelivr\.net|unpkg\.com)/.*"), handle_cdn)