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()