From 711a46d3e8892580ba06837c07dcd42e240bed6e Mon Sep 17 00:00:00 2001 From: Mike Mulligan <2+mmulligan@noreply.gitea.mulligan.casa> Date: Wed, 15 Jul 2026 15:47:25 -0400 Subject: [PATCH] Add update-manifest builder (plugin + theme) --- build-manifest.py | 251 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 build-manifest.py diff --git a/build-manifest.py b/build-manifest.py new file mode 100644 index 0000000..b5e4ec4 --- /dev/null +++ b/build-manifest.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +"""Build a published update manifest from a plugin/theme repo's curated inputs. + +Inputs (all WP-native / YAML, all export-ignored from the shipped zip): + .mu-manifest.yaml curated base — name/homepage/author/tags + optional section + OVERRIDES (inline HTML, or `@path.(md|html|txt)` includes). + .mu-release.yaml pipeline config (passed via --config): sections.autogen, + sections.strip_title, channel, app_build. + readme.txt the WordPress readme — its `== Section ==` blocks are the + DEFAULT modal sections (Description / Installation / FAQ / + Changelog / …). The `=== Title ===` header + the tag block + are naturally skipped (they aren't `== … ==`). + CHANGELOG.md changelog fallback, used only if readme.txt has no Changelog. + +SECTIONS cascade, per modal key: .mu-manifest.yaml override > readme.txt block + ( + changelog: ... > readme.txt == Changelog == > CHANGELOG.md ). + +readme.txt is a markdown subset; its `= Subhead =` lines render as bold paragraphs +(not

) because WP's modal puts `clear:both` on headings, which — when a section +LEADS with one — drops the body below the floated sidebar (a big top gap). For the +same reason a leading is demoted to

. `strip_title` +(null|bool|dict) removes a leading H1 from parsed markdown before that. + +Computed fields (--computed) always win for release-critical/readme-derived keys; +the curated base 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 + import yaml +except ImportError as e: + sys.exit(f"build-manifest: needs python 'markdown' + 'pyyaml' ({e})") + +PROTECTED = { + "version", "slug", "download_url", "last_updated", + "icons", "screenshot_url", "requires", "tested", "requires_php", +} +INCLUDE_RE = re.compile(r"^@(?P.+\.(?Pmd|html|txt))$") +BASE_FILE = ".mu-manifest.yaml" + +# readme.txt section name -> WP modal section key. +_KEYS = { + "description": "description", "installation": "installation", + "faq": "faq", "frequently asked questions": "faq", + "changelog": "changelog", "change log": "changelog", + "screenshots": "screenshots", "other notes": "other_notes", + "upgrade notice": "upgrade_notice", +} + + +def section_key(name: str) -> str: + n = " ".join(name.strip().lower().split()) + return _KEYS.get(n) or re.sub(r"[^a-z0-9]+", "_", n).strip("_") + + +def md_to_html(text: str) -> str: + return _md.markdown(text, extensions=["extra", "sane_lists", "nl2br"]) + + +def wporg_subheads(text: str) -> str: + """wp.org `= Subhead =` -> bold paragraph (NOT a heading: WP's modal clears + floats on h*, so heading-led content gaps below the sidebar).""" + return re.sub(r"(?m)^=[ \t]+(.+?)[ \t]*=[ \t]*$", r"**\1**", text) + + +def strip_leading_title(md: str) -> str: + 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]): + i += 1 + elif i + 1 < len(lines) and lines[i].strip() and re.match(r"^=+\s*$", lines[i + 1]): + i += 2 + return "\n".join(lines[i:]).lstrip("\n") + + +def demote_leading_heading(html_str: str) -> str: + """Demote a LEADING to a non-clearing

so the + section doesn't trip the modal's `clear:both` on headings (survives wp_kses).""" + return re.sub( + r"^\s*]*>(.*?)", + r"

\1

", + html_str, count=1, flags=re.S, + ) + + +def should_strip(key: str, strip_cfg) -> bool: + if strip_cfg is True: + return True + if isinstance(strip_cfg, dict): + return bool(strip_cfg.get(key, False)) + return False + + +def md_section(text: str, key: str, strip_cfg) -> str: + if should_strip(key, strip_cfg): + text = strip_leading_title(text) + return md_to_html(text) + + +def convert(text: str, ext: str, key: str, strip_cfg) -> str: + if ext == "md": + return md_section(text, key, strip_cfg) + if ext == "html": + return text + 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 + 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 parse_readme_sections(repo_root: str) -> dict: + """Split readme.txt on `== Name ==` lines (two equals; the `=== Title ===` + header and the tag block above the first section are skipped).""" + text = read_text(repo_root, "readme.txt") + if not text: + return {} + parts = re.split(r"(?m)^==[ \t]+(.+?)[ \t]+==[ \t]*$", text) + out = {} + for i in range(1, len(parts) - 1, 2): + name, body = parts[i].strip(), parts[i + 1].strip() + if name and body: + out[name] = body + return out + + +def screenshot_captions(repo_root: str) -> dict: + """Numbered captions from the readme `== Screenshots ==` block (N -> caption).""" + for name, body in parse_readme_sections(repo_root).items(): + if section_key(name) == "screenshots": + caps = {} + for line in body.splitlines(): + m = re.match(r"^\s*(\d+)\.\s+(.*\S)", line) + if m: + caps[int(m.group(1))] = m.group(2).strip() + return caps + return {} + + +def build_screenshots(urls: dict, repo_root: str) -> dict: + """Pair release-provided screenshot URLs {N: src} with the readme's numbered + captions into the WP `screenshots` field ({N: {src, caption}}).""" + caps = screenshot_captions(repo_root) + return { + str(n): {"src": urls[str(n)], "caption": caps.get(n, "")} + for n in sorted(int(k) for k in urls) + } + + +def build_sections(base: dict, repo_root: str, autogen: bool, strip_cfg) -> dict: + resolved = {} + # 1. curated overrides (.mu-manifest.yaml sections). + if isinstance(base.get("sections"), dict): + for name, body in base["sections"].items(): + key = section_key(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: + resolved[key] = convert(open(full, encoding="utf-8").read(), m.group("ext"), key, strip_cfg) + continue + sys.stderr.write(f"build-manifest: section '{key}' include '{m.group('path')}' not found — literal\n") + resolved[key] = body + else: + resolved[key] = str(body) + if autogen: + # 2. readme.txt == Section == blocks (the WP-native default). + for name, body in parse_readme_sections(repo_root).items(): + key = section_key(name) + if key == "screenshots": + continue # not a text tab — handled as the `screenshots` gallery. + if key not in resolved: + resolved[key] = md_section(wporg_subheads(body), key, strip_cfg) + # 3. changelog fallback -> CHANGELOG.md. + 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) + # No section may LEAD with a heading (modal clear:both gap fix). + return {k: demote_leading_heading(v) for k, v in resolved.items()} + + +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="") + args = ap.parse_args() + + repo_root = args.repo_root + cfg = {} + if args.config and os.path.isfile(args.config): + cfg = yaml.safe_load(open(args.config, encoding="utf-8")) or {} + sec_cfg = cfg.get("sections") or {} + autogen = bool(sec_cfg.get("autogen", True)) + strip_cfg = sec_cfg.get("strip_title") + + base = {} + base_path = os.path.join(repo_root, BASE_FILE) + if os.path.isfile(base_path): + base = yaml.safe_load(open(base_path, encoding="utf-8")) or {} + if not isinstance(base, dict): + sys.exit(f"build-manifest: {BASE_FILE} must be a mapping") + + computed = json.load(open(args.computed, encoding="utf-8")) + shots = computed.pop("_screenshots", None) # {N: src} from release.sh (channel URLs) + 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 + if shots: + result["screenshots"] = build_screenshots(shots, repo_root) + + 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} (sections: {', '.join(sections) or 'none'})\n") + + +if __name__ == "__main__": + main()