#!/usr/bin/env python3 """Build a published update manifest from a plugin repo's base manifest + sections. Foundation model: a plugin repo curates a base `manifest.json` (rich, static fields) + section content; the release pipeline computes the moving parts (version, download_url, requires/tested from readme, icons, last_updated) and hands them in as `--computed`. This merges the two and writes the channel manifest. SECTIONS (the "View details" modal body) resolve PER SECTION, gap-filling down a cascade — Manifest-defined > ./sections/.md > auto-generate: 1. base `manifest.json` `sections` — used verbatim, EXCEPT a section whose body is a SINGLE line `@path.(md|html|txt)` existing under the repo root is replaced by that file's converted contents (.md->HTML, .html->as-is, .txt->escaped text). 2. `/sections/.md` fills any section not set by (1). 3. auto-generate (toggle via mu-release.yaml `sections.autogen`, default true): description <- README.md (fallback: readme.txt `== Description ==`) changelog <- CHANGELOG.md ...for description/changelog only, if still unset. `sections.strip_title` (mu-release.yaml) removes a leading H1 title from PARSED markdown only (never inline HTML / .html / .txt): null | false -> off (default) true -> strip from every parsed .md section { "": true|false } -> per-section Computed fields always win for release-critical/readme-derived keys; the base manifest wins for everything else it provides. Usage: build-manifest.py --repo-root . --computed computed.json --out out.json [--config mu-release.yaml] """ import argparse import html import json import os import re import sys try: import markdown as _md except ImportError: sys.exit("build-manifest: python 'markdown' is required (pip install markdown)") # Always taken from --computed; never overridable by the curated base manifest. PROTECTED = { "version", "slug", "download_url", "last_updated", "icons", "requires", "tested", "requires_php", } SECTIONS_DIR = "sections" INCLUDE_RE = re.compile(r"^@(?P.+\.(?Pmd|html|txt))$") def md_to_html(text: str) -> str: return _md.markdown(text, extensions=["extra", "sane_lists", "nl2br"]) def strip_leading_title(md: str) -> str: """Drop a leading H1 (atx `# Title` or setext `Title\\n===`) from markdown.""" lines = md.splitlines() i = 0 while i < len(lines) and lines[i].strip() == "": i += 1 if i < len(lines): if re.match(r"^#\s+\S", lines[i]): # atx H1 i += 1 elif i + 1 < len(lines) and lines[i].strip() and re.match(r"^=+\s*$", lines[i + 1]): i += 2 # setext H1 return "\n".join(lines[i:]).lstrip("\n") def should_strip(name: str, strip_cfg) -> bool: if strip_cfg is True: return True if isinstance(strip_cfg, dict): return bool(strip_cfg.get(name, False)) return False # None / False / anything else def md_section(text: str, name: str, strip_cfg) -> str: if should_strip(name, strip_cfg): text = strip_leading_title(text) return md_to_html(text) def convert(text: str, ext: str, name: str, strip_cfg) -> str: if ext == "md": return md_section(text, name, strip_cfg) if ext == "html": return text # already HTML — not parsed, never title-stripped. paras = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()] return "".join("

" + html.escape(p).replace("\n", "
") + "

" for p in paras) def resolve_in_repo(repo_root: str, rel: str): root = os.path.realpath(repo_root) full = os.path.realpath(os.path.join(root, rel)) if full != root and not full.startswith(root + os.sep): return None # path traversal — reject. return full if os.path.isfile(full) else None def read_text(repo_root: str, name: str): p = os.path.join(repo_root, name) return open(p, encoding="utf-8").read() if os.path.isfile(p) else None def readme_txt_description(repo_root: str): """Fallback description source: the `== Description ==` block of readme.txt.""" text = read_text(repo_root, "readme.txt") if not text: return None m = re.search(r"(?ms)^==\s*Description\s*==\s*\n(.*?)(?=^==\s|\Z)", text) return (m.group(1).strip() or None) if m else None def build_sections(base: dict, repo_root: str, autogen: bool, strip_cfg) -> dict: resolved = {} # 1. manifest-defined (with @include resolution). if isinstance(base.get("sections"), dict): for name, body in base["sections"].items(): name = str(name) if isinstance(body, str): one = body.strip() m = INCLUDE_RE.match(one) if "\n" not in one else None if m: full = resolve_in_repo(repo_root, m.group("path")) if full: with open(full, encoding="utf-8") as fh: resolved[name] = convert(fh.read(), m.group("ext"), name, strip_cfg) continue sys.stderr.write( f"build-manifest: section '{name}' include " f"'{m.group('path')}' not found — using literal\n" ) resolved[name] = body else: resolved[name] = str(body) # 2. ./sections/.md fills any section not already set. sdir = os.path.join(repo_root, SECTIONS_DIR) if os.path.isdir(sdir): for fn in sorted(os.listdir(sdir)): if fn.endswith(".md") and fn[:-3] not in resolved: with open(os.path.join(sdir, fn), encoding="utf-8") as fh: resolved[fn[:-3]] = md_section(fh.read(), fn[:-3], strip_cfg) # 3. auto-generate description (README.md, then readme.txt) + changelog (CHANGELOG.md). if autogen: if "description" not in resolved: raw = read_text(repo_root, "README.md") if not (raw and raw.strip()): raw = readme_txt_description(repo_root) if raw and raw.strip(): resolved["description"] = md_section(raw, "description", strip_cfg) if "changelog" not in resolved: cl = read_text(repo_root, "CHANGELOG.md") if cl and cl.strip(): resolved["changelog"] = md_section(cl, "changelog", strip_cfg) return resolved def read_config(path: str) -> dict: if path and os.path.isfile(path): import yaml with open(path, encoding="utf-8") as fh: return yaml.safe_load(fh) or {} return {} def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--repo-root", required=True) ap.add_argument("--computed", required=True) ap.add_argument("--out", required=True) ap.add_argument("--config", default="", help="mu-release.yaml (for toggles)") args = ap.parse_args() repo_root = args.repo_root cfg = read_config(args.config) sec_cfg = cfg.get("sections") or {} autogen = bool(sec_cfg.get("autogen", True)) strip_cfg = sec_cfg.get("strip_title") # None | bool | dict base = {} base_path = os.path.join(repo_root, "manifest.json") if os.path.isfile(base_path): with open(base_path, encoding="utf-8") as fh: base = json.load(fh) if not isinstance(base, dict): sys.exit("build-manifest: base manifest.json must be a JSON object") with open(args.computed, encoding="utf-8") as fh: computed = json.load(fh) sections = build_sections(base, repo_root, autogen, strip_cfg) result = dict(computed) for k, v in base.items(): if k == "sections" or k in PROTECTED: continue result[k] = v if sections: result["sections"] = sections with open(args.out, "w", encoding="utf-8") as fh: json.dump(result, fh, indent=2, ensure_ascii=False) fh.write("\n") sys.stderr.write( f"build-manifest: wrote {args.out} " f"(autogen={autogen}, strip_title={strip_cfg!r}; sections: {', '.join(sections) or 'none'})\n" ) if __name__ == "__main__": main()