diff --git a/.ci/build-manifest.py b/.ci/build-manifest.py index 9926476..529bb92 100644 --- a/.ci/build-manifest.py +++ b/.ci/build-manifest.py @@ -1,34 +1,31 @@ #!/usr/bin/env python3 -"""Build a published update manifest from a plugin repo's base manifest + sections. +"""Build a published update manifest from a plugin repo's curated inputs. -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. +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 (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 cascade, per modal key: .mu-manifest.yaml override > readme.txt block + ( + changelog: ... > readme.txt == Changelog == > CHANGELOG.md ). -`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 +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 always win for release-critical/readme-derived keys; the base -manifest wins for everything else it provides. +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] + build-manifest.py --repo-root . --computed computed.json --out out.json [--config .mu-release.yaml] """ import argparse import html @@ -39,55 +36,84 @@ import sys try: import markdown as _md -except ImportError: - sys.exit("build-manifest: python 'markdown' is required (pip install markdown)") + import yaml +except ImportError as e: + sys.exit(f"build-manifest: needs python 'markdown' + 'pyyaml' ({e})") -# 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))$") +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: - """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 + 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 # setext H1 + i += 2 return "\n".join(lines[i:]).lstrip("\n") -def should_strip(name: str, strip_cfg) -> bool: +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(name, False)) - return False # None / False / anything else + return bool(strip_cfg.get(key, False)) + return False -def md_section(text: str, name: str, strip_cfg) -> str: - if should_strip(name, strip_cfg): +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, name: str, strip_cfg) -> str: +def convert(text: str, ext: str, key: str, strip_cfg) -> str: if ext == "md": - return md_section(text, name, strip_cfg) + return md_section(text, key, strip_cfg) if ext == "html": - return text # already HTML — not parsed, never title-stripped. + 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) @@ -96,7 +122,7 @@ 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 None return full if os.path.isfile(full) else None @@ -105,69 +131,52 @@ def read_text(repo_root: str, name: str): 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.""" +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 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 + 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 build_sections(base: dict, repo_root: str, autogen: bool, strip_cfg) -> dict: resolved = {} - - # 1. manifest-defined (with @include resolution). + # 1. curated overrides (.mu-manifest.yaml sections). if isinstance(base.get("sections"), dict): for name, body in base["sections"].items(): - name = str(name) + 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: - with open(full, encoding="utf-8") as fh: - resolved[name] = convert(fh.read(), m.group("ext"), name, strip_cfg) + resolved[key] = convert(open(full, encoding="utf-8").read(), m.group("ext"), key, strip_cfg) continue - sys.stderr.write( - f"build-manifest: section '{name}' include " - f"'{m.group('path')}' not found — using literal\n" - ) - resolved[name] = body + sys.stderr.write(f"build-manifest: section '{key}' include '{m.group('path')}' not found — literal\n") + resolved[key] = 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). + resolved[key] = str(body) 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) + # 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 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) - - 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 {} + # 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: @@ -175,26 +184,25 @@ def main() -> None: 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)") + ap.add_argument("--config", default="") args = ap.parse_args() repo_root = args.repo_root - cfg = read_config(args.config) + 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") # None | bool | dict + strip_cfg = sec_cfg.get("strip_title") base = {} - base_path = os.path.join(repo_root, "manifest.json") + base_path = os.path.join(repo_root, BASE_FILE) if os.path.isfile(base_path): - with open(base_path, encoding="utf-8") as fh: - base = json.load(fh) + base = yaml.safe_load(open(base_path, encoding="utf-8")) or {} 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) + sys.exit(f"build-manifest: {BASE_FILE} must be a mapping") + computed = json.load(open(args.computed, encoding="utf-8")) sections = build_sections(base, repo_root, autogen, strip_cfg) result = dict(computed) @@ -208,10 +216,7 @@ def main() -> None: 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" - ) + sys.stderr.write(f"build-manifest: wrote {args.out} (sections: {', '.join(sections) or 'none'})\n") if __name__ == "__main__": diff --git a/.ci/release.sh b/.ci/release.sh index 227a937..fb4064c 100644 --- a/.ci/release.sh +++ b/.ci/release.sh @@ -20,16 +20,16 @@ SLUG="$(basename "${GITHUB_REPOSITORY}")" VER="${GITHUB_REF_NAME#v}" MAIN="${SLUG}.php" HOST="${GITHUB_SERVER_URL}" -CFG="mu-release.yaml" +CFG=".mu-release.yaml" # --- config helpers (yaml via python; pure-convention defaults if absent) ----- cfg() { python3 - "$1" "$2" <<'PY' 2>/dev/null || true import sys, os key, default = sys.argv[1], sys.argv[2] val = default -if os.path.isfile("mu-release.yaml"): +if os.path.isfile(".mu-release.yaml"): import yaml - data = yaml.safe_load(open("mu-release.yaml")) or {} + data = yaml.safe_load(open(".mu-release.yaml")) or {} cur = data for part in key.split("."): cur = cur.get(part) if isinstance(cur, dict) else None