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)