Files
wp-release-ci/build-manifest.py
T

252 lines
9.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <h4>) 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 <h16> is demoted to <p><strong>…</strong></p>. `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<path>.+\.(?P<ext>md|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 <h16> to a non-clearing <p><strong>…</strong></p> so the
section doesn't trip the modal's `clear:both` on headings (survives wp_kses)."""
return re.sub(
r"^\s*<h[1-6][^>]*>(.*?)</h[1-6]>",
r"<p><strong>\1</strong></p>",
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("<p>" + html.escape(p).replace("\n", "<br>") + "</p>" 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()