mirror of
https://github.com/basnijholt/compose-farm.git
synced 2026-02-03 06:03:25 +00:00
Unify vendor assets configuration in single JSON file (#141)
This commit is contained in:
@@ -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: <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:
|
||||
"""Download a URL, trying urllib first then curl as fallback."""
|
||||
@@ -61,7 +46,14 @@ def _download(url: str) -> bytes:
|
||||
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."""
|
||||
lines = [
|
||||
"# 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"Source: {license_url}")
|
||||
lines.append("")
|
||||
@@ -107,44 +101,57 @@ class VendorAssetsHook(BuildHookInterface): # type: ignore[misc]
|
||||
if not base_html_path.exists():
|
||||
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
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="compose_farm_vendor_"))
|
||||
vendor_dir = temp_dir / "vendor"
|
||||
vendor_dir.mkdir()
|
||||
|
||||
# Read and parse base.html
|
||||
# Read base.html
|
||||
html_content = base_html_path.read_text()
|
||||
|
||||
# Build URL to filename mapping and download assets
|
||||
url_to_filename: dict[str, str] = {}
|
||||
|
||||
# Find all elements with data-vendor attribute and download them
|
||||
for match in VENDOR_PATTERN.finditer(html_content):
|
||||
url = match.group(2)
|
||||
filename = match.group(4)
|
||||
|
||||
if url in url_to_filename:
|
||||
continue
|
||||
|
||||
for asset in assets_to_vendor:
|
||||
url = asset["url"]
|
||||
filename = asset["filename"]
|
||||
url_to_filename[url] = filename
|
||||
filepath = vendor_dir / filename
|
||||
filepath.parent.mkdir(parents=True, exist_ok=True)
|
||||
content = _download(url)
|
||||
(vendor_dir / filename).write_bytes(content)
|
||||
filepath.write_bytes(content)
|
||||
|
||||
if not url_to_filename:
|
||||
return
|
||||
# Generate LICENSES.txt from the JSON config
|
||||
_generate_licenses_file(vendor_dir, vendor_config["licenses"])
|
||||
|
||||
# Generate LICENSES.txt
|
||||
_generate_licenses_file(vendor_dir)
|
||||
# Rewrite HTML: replace CDN URLs with local paths and remove data-vendor attributes
|
||||
# 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:
|
||||
attr = match.group(1) # src or href
|
||||
url = match.group(2)
|
||||
between = match.group(3) # attributes between URL and data-vendor
|
||||
filename = match.group(4)
|
||||
if url in url_to_filename:
|
||||
filename = url_to_filename[url]
|
||||
return f'{attr}="/static/vendor/{filename}"{between}'
|
||||
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
|
||||
templates_dir = temp_dir / "templates"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -97,8 +97,8 @@
|
||||
|
||||
<!-- Scripts - HTMX first -->
|
||||
<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-ext.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" 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/addon-fit@0.10.0/lib/addon-fit.js" data-vendor="xterm-fit.js"></script>
|
||||
<script src="/static/app.js"></script>
|
||||
|
||||
122
src/compose_farm/web/vendor-assets.json
Normal file
122
src/compose_farm/web/vendor-assets.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user