v0.4 Phase 6: fetch-deps command for offline/local dependency mode

- Add _http_get() general HTTP helper (SSL via certifi, 30s timeout)
- Add CDN_DEPS table: 6 jsDelivr assets (js-yaml, marked, fuse.js,
  highlight.js, 2x highlight CSS)
- Add _fetch_bunny_fonts(): reads theme.yml font-body/heading/code keys,
  fetches CSS from fonts.bunny.net, downloads woff2 files to assets/fonts/,
  rewrites CSS to use relative local paths, writes per-font CSS file
- Add _patch_index_html(): replaces CDN URLs with local vendor paths,
  injects <link data-mdcms-fonts> tags for locally downloaded fonts
- Add fetch-deps CLI command: downloads vendors, fetches fonts if theme.yml
  present, patches index.html — site makes no external network requests
- index.html loadFonts(): skip if data-mdcms-fonts link already present
  (set by patched index.html after fetch-deps)
- Update CLAUDE.md CLI command table with fetch-deps entries

https://claude.ai/code/session_015XtsgTMi8UtmgxEgb5Qt2c
This commit is contained in:
Claude 2026-05-16 16:57:58 +00:00
parent 321202e5e0
commit 9b7639cc62
No known key found for this signature in database
3 changed files with 160 additions and 0 deletions

View file

@ -59,6 +59,8 @@ During development, run directly: `python3 mdcms.py <command>`
| `mdcms build <name>` | Build `nav.yml` and `search.json` for a registered site. |
| `mdcms build --path <path>` | Build using an explicit path — no registry needed. Intended for CI/CD. |
| `mdcms build` | Build using current working directory. Simplest form for GitHub Actions. |
| `mdcms fetch-deps [name]` | Download all external JS/CSS deps to `assets/required/vendors/` and Bunny Fonts to `assets/fonts/`. Patches `index.html` to use local paths — no CDN requests after this. |
| `mdcms fetch-deps --path <path>` | Same, using an explicit path. |
**Local preview:** Run `python3 -m http.server 8800` in the site directory and open `http://localhost:8800`. Do not open `index.html` directly — browsers block local file access due to CORS.

View file

@ -1321,6 +1321,7 @@ body {
// ─── Fonts ────────────────────────────────────────────────
function loadFonts(tc) {
if (document.querySelector('link[data-mdcms-fonts]')) return;
function parseFont(spec) {
if (!spec) return null;
const parts = spec.split(':');

157
mdcms.py
View file

@ -492,6 +492,130 @@ def run_build(site_path: Path):
))
# ─── Dependency fetching ──────────────────────────────────────
CDN_DEPS = [
(
"https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.min.js",
"assets/required/vendors/js-yaml.min.js",
),
(
"https://cdn.jsdelivr.net/npm/marked@12.0.0/marked.min.js",
"assets/required/vendors/marked.min.js",
),
(
"https://cdn.jsdelivr.net/npm/fuse.js@7.0.0/dist/fuse.min.js",
"assets/required/vendors/fuse.min.js",
),
(
"https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js",
"assets/required/vendors/highlight.min.js",
),
(
"https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github.min.css",
"assets/required/vendors/github.min.css",
),
(
"https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github-dark.min.css",
"assets/required/vendors/github-dark.min.css",
),
]
_WOFF2_URL_RE = re.compile(
r"""url\(\s*['"]?(https://fonts\.bunny\.net/[^'"\s)]+\.woff2)['"]?\s*\)""",
re.IGNORECASE,
)
def _http_get(url: str, timeout: int = 30) -> bytes:
ssl_ctx = ssl.create_default_context(cafile=certifi.where())
req = urllib.request.Request(url, headers={"User-Agent": f"mdcms/{CLI_VERSION}"})
with urllib.request.urlopen(req, context=ssl_ctx, timeout=timeout) as resp:
return resp.read()
def _fetch_bunny_fonts(site_path: Path, theme_file: str) -> list:
"""Download Bunny Fonts from theme.yml to assets/fonts/. Returns list of local CSS paths."""
theme_path = site_path / theme_file
if not theme_path.exists():
return []
try:
theme_data = yaml.safe_load(theme_path.read_text(encoding="utf-8")) or {}
except (OSError, yaml.YAMLError):
return []
fonts_dir = site_path / "assets" / "fonts"
fonts_dir.mkdir(parents=True, exist_ok=True)
seen: set = set()
local_css_paths: list = []
for key in ("font-body", "font-heading", "font-code"):
spec = theme_data.get(key)
if not spec:
continue
parts = str(spec).split(":")
if len(parts) < 3 or parts[0].strip().lower() != "bunny":
continue
name = parts[1].strip()
weight = parts[-1].strip()
font_id = f"{name}:{weight}"
if font_id in seen:
continue
seen.add(font_id)
bunny_url = f"https://fonts.bunny.net/css?family={name.replace(' ', '+')}:{weight}"
click.echo(f" Fetching font: {name} {weight}")
try:
css_text = _http_get(bunny_url).decode("utf-8")
except Exception as e:
click.echo(click.style(f" Warning: could not fetch {bunny_url}: {e}", fg="yellow"))
continue
def _rewrite(m: re.Match) -> str:
woff2_url = m.group(1)
filename = woff2_url.split("/")[-1].split("?")[0]
dest = fonts_dir / filename
if not dest.exists():
try:
dest.write_bytes(_http_get(woff2_url))
except Exception as e:
click.echo(click.style(f" Warning: could not fetch {woff2_url}: {e}", fg="yellow"))
return m.group(0)
return f"url('../fonts/{filename}')"
local_css = _WOFF2_URL_RE.sub(_rewrite, css_text)
safe_name = name.lower().replace(" ", "-")
css_filename = f"{safe_name}-{weight}.css"
(fonts_dir / css_filename).write_text(local_css, encoding="utf-8")
local_css_paths.append(f"assets/fonts/{css_filename}")
click.echo(f" Wrote assets/fonts/{css_filename}")
return local_css_paths
def _patch_index_html(site_path: Path, local_font_css: list):
"""Replace CDN tags with local paths and inject font link tags."""
index_path = site_path / "index.html"
if not index_path.exists():
raise click.ClickException("index.html not found in site directory.")
html = index_path.read_text(encoding="utf-8")
for cdn_url, local_path in CDN_DEPS:
html = html.replace(cdn_url, local_path)
if local_font_css:
links = "\n".join(
f'<link rel="stylesheet" href="{p}" data-mdcms-fonts="1">'
for p in local_font_css
)
html = html.replace("</head>", f"{links}\n</head>", 1)
index_path.write_text(html, encoding="utf-8")
click.echo(" Patched index.html")
# ─── GitHub template download ─────────────────────────────────
def _github_get(url: str) -> bytes:
@ -732,6 +856,39 @@ def build(name, path_override):
click.echo(click.style("Build complete.", fg="green"))
@cli.command("fetch-deps")
@click.argument("name", required=False, default=None)
@click.option("--path", "path_override", default=None, type=click.Path(),
help="Explicit site path (no registry lookup).")
def fetch_deps(name, path_override):
"""Download external JS/CSS dependencies and patch index.html for offline use."""
site_path = resolve_site_path(name, path_override)
if not (site_path / "index.html").exists():
raise click.ClickException(f"No index.html found at {site_path}")
click.echo(f"Fetching dependencies for {site_path} ...")
vendors_dir = site_path / "assets" / "required" / "vendors"
vendors_dir.mkdir(parents=True, exist_ok=True)
for cdn_url, rel_dest in CDN_DEPS:
dest = site_path / rel_dest
click.echo(f" {rel_dest}")
try:
dest.write_bytes(_http_get(cdn_url))
except Exception as e:
raise click.ClickException(f"Failed to download {cdn_url}: {e}")
cfg = read_config(site_path)
local_font_css: list = []
if cfg.get("theme"):
local_font_css = _fetch_bunny_fonts(site_path, cfg["theme"])
_patch_index_html(site_path, local_font_css)
click.echo(click.style("Done. Site is ready for offline use.", fg="green"))
# ─── Entry point ─────────────────────────────────────────────
def main():