mirror of
https://github.com/kbenestad/mdcms.git
synced 2026-06-18 15:24:32 +00:00
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:
parent
321202e5e0
commit
9b7639cc62
3 changed files with 160 additions and 0 deletions
|
|
@ -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 <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 --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 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.
|
**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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1321,6 +1321,7 @@ body {
|
||||||
|
|
||||||
// ─── Fonts ────────────────────────────────────────────────
|
// ─── Fonts ────────────────────────────────────────────────
|
||||||
function loadFonts(tc) {
|
function loadFonts(tc) {
|
||||||
|
if (document.querySelector('link[data-mdcms-fonts]')) return;
|
||||||
function parseFont(spec) {
|
function parseFont(spec) {
|
||||||
if (!spec) return null;
|
if (!spec) return null;
|
||||||
const parts = spec.split(':');
|
const parts = spec.split(':');
|
||||||
|
|
|
||||||
157
mdcms.py
157
mdcms.py
|
|
@ -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 ─────────────────────────────────
|
# ─── GitHub template download ─────────────────────────────────
|
||||||
|
|
||||||
def _github_get(url: str) -> bytes:
|
def _github_get(url: str) -> bytes:
|
||||||
|
|
@ -732,6 +856,39 @@ def build(name, path_override):
|
||||||
click.echo(click.style("Build complete.", fg="green"))
|
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 ─────────────────────────────────────────────
|
# ─── Entry point ─────────────────────────────────────────────
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue