#!/usr/bin/env python3 """Build a published update manifest from a plugin 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", "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()