This is an automated email from the ASF dual-hosted git repository. imbajin pushed a commit to branch feat/oink-core-platform in repository https://gitbox.apache.org/repos/asf/hugegraph-doc.git
commit 9c14050d5a5b6c1e98e1afabaa4f0f93dc328d55 Author: dark <[email protected]> AuthorDate: Fri Sep 4 19:34:18 2026 +0800 feat(versioning): add five-version builds - add immutable 1.3 and 1.0 refs to the canonical manifest - migrate legacy routes into the five-group information architecture - enforce real hreflang, archive noindex, and sitemap exclusion - support latest-only aggregates and cross-origin staging menus --- scripts/test_versioning.py | 10 + scripts/versioning.py | 482 ++++++++++++++++++++++++++++++++++++++++----- versions.json | 16 ++ 3 files changed, 456 insertions(+), 52 deletions(-) diff --git a/scripts/test_versioning.py b/scripts/test_versioning.py index ffe3469d0..f9884269f 100644 --- a/scripts/test_versioning.py +++ b/scripts/test_versioning.py @@ -26,6 +26,10 @@ ALLOWED_PATHS = { "/versions/1.7/cn/docs", "/versions/1.5/docs", "/versions/1.5/cn/docs", + "/versions/1.3/docs", + "/versions/1.3/cn/docs", + "/versions/1.0/docs", + "/versions/1.0/cn/docs", } @@ -180,6 +184,8 @@ class VersionUrlTest(unittest.TestCase): {"id": "latest", "name": "latest", "publishPath": ""}, {"id": "1.7", "name": "1.7", "publishPath": "versions/1.7"}, {"id": "1.5", "name": "1.5", "publishPath": "versions/1.5"}, + {"id": "1.3", "name": "1.3", "publishPath": "versions/1.3"}, + {"id": "1.0", "name": "1.0", "publishPath": "versions/1.0"}, ] } self.assertEqual( @@ -188,6 +194,8 @@ class VersionUrlTest(unittest.TestCase): f"{ORIGIN}docs/", f"{ORIGIN}versions/1.7/docs/", f"{ORIGIN}versions/1.5/docs/", + f"{ORIGIN}versions/1.3/docs/", + f"{ORIGIN}versions/1.0/docs/", ], ) self.assertEqual( @@ -196,6 +204,8 @@ class VersionUrlTest(unittest.TestCase): f"{ORIGIN}cn/docs/", f"{ORIGIN}versions/1.7/cn/docs/", f"{ORIGIN}versions/1.5/cn/docs/", + f"{ORIGIN}versions/1.3/cn/docs/", + f"{ORIGIN}versions/1.0/cn/docs/", ], ) self.assertEqual( diff --git a/scripts/versioning.py b/scripts/versioning.py index 2620c7b6b..fb2e04d51 100644 --- a/scripts/versioning.py +++ b/scripts/versioning.py @@ -64,7 +64,10 @@ VERSION_REFS = { "latest": "master", "1.7": "release-1.7.0", "1.5": "release-1.5.0", + "1.3": "release-1.3.0", + "1.0": "release-1.0.0", } +VERSION_ORDER = ("latest", "1.7", "1.5", "1.3", "1.0") KNOWN_HISTORICAL_ROUTES = { "/docs/quickstart/hugegraph-loader": "/docs/quickstart/toolchain/hugegraph-loader/", "/cn/docs/quickstart/hugegraph-loader": "/cn/docs/quickstart/toolchain/hugegraph-loader/", @@ -75,6 +78,10 @@ KNOWN_HISTORICAL_ROUTES_BY_PUBLISH_PATH = { "/cn/docs/introduction": "/cn/docs/introduction/readme/", }, } +LATEST_SHARED_DOC_ROUTES = { + "/docs/guides/security/", + "/cn/docs/guides/security/", +} URL_ATTRIBUTE_RE = re.compile( r"(?P<prefix>[\s<](?:href|src|action|poster|data-td-index-src|data-td-url|data-td-image-zoom)=)" r"(?P<quote>[\"']?)(?P<url>[^\s\"'<>`]+)(?P=quote)", @@ -86,6 +93,11 @@ ACTION_MANIFEST_RE = re.compile( r"(?P<close></script>)", re.IGNORECASE | re.DOTALL, ) +HREFLANG_LINK_RE = re.compile( + r"<link\b(?=[^>]*\brel=[\"']?alternate(?:[\"'\s>]|$))" + r"(?=[^>]*\bhreflang=)[^>]*>", + re.IGNORECASE, +) MARKDOWN_DESTINATION_RE = re.compile( r"(?P<open>\]\(\s*<?)(?P<url>(?:https?://[^\s)>]+|/[^\s)>]+))(?P<close>>?[^)]*\))" ) @@ -121,6 +133,35 @@ DOCS_NAV_EXPECTED_STATS = { "scopedLinks": 10, "treeSha256": "70b2a46f047b3c88a6b1b937eb79676a7f68437485b3248e5f84b9c621d5ad06", }, + "1.3": { + "groups": 5, + "pages": 68, + "removed": 22, + "scopedLinks": 10, + "treeSha256": "78700dfe484d5177f00c9fe657ae12f3ceecca9bbb592cc8626b782a7b9be61a", + }, + "1.0": { + "groups": 5, + "pages": 59, + "removed": 21, + "scopedLinks": 10, + "treeSha256": "52f0c3b5bef6e9db39a37bbd52b7993128951f592d88a835ec7e3c107edec399", + }, +} +LEGACY_IA_ROUTE_MAP = { + "introduction/README.md": "introduction/readme.md", + "quickstart/hugegraph-server.md": "quickstart/hugegraph/hugegraph-server.md", + "quickstart/hugegraph-hubble.md": "quickstart/toolchain/hugegraph-hubble.md", + "quickstart/hugegraph-loader.md": "quickstart/toolchain/hugegraph-loader.md", + "quickstart/hugegraph-tools.md": "quickstart/toolchain/hugegraph-tools.md", + "quickstart/hugegraph-computer.md": "quickstart/computing/hugegraph-computer.md", + "quickstart/hugegraph-client.md": "quickstart/client/hugegraph-client.md", +} +LEGACY_IA_SECTIONS = { + "quickstart/hugegraph": ("HugeGraph", "HugeGraph"), + "quickstart/toolchain": ("Toolchain", "Toolchain"), + "quickstart/computing": ("Graph computing", "图计算"), + "quickstart/client": ("Clients", "客户端"), } @@ -301,8 +342,8 @@ def load_manifest(path: pathlib.Path) -> dict: entry["publishPath"] = publish_path ids.add(version_id) paths.add(publish_path) - if [entry["id"] for entry in versions] != ["latest", "1.7", "1.5"]: - fail("version order must be latest, 1.7, 1.5") + if tuple(entry["id"] for entry in versions) != VERSION_ORDER: + fail(f"version order must be {', '.join(VERSION_ORDER)}") if data.get("repository") != REPOSITORY_URL: fail(f"versions manifest repository must be {REPOSITORY_URL}") return data @@ -351,8 +392,21 @@ def resolve_remote(repository: str, ref: str) -> str: return rows[0][0] +def selected_version_ids(raw: str | None) -> tuple[str, ...]: + if raw is None: + return VERSION_ORDER + selected = tuple(part.strip() for part in raw.split(",") if part.strip()) + if not selected or len(set(selected)) != len(selected): + fail("selected versions must be a non-empty unique comma-separated list") + unknown = set(selected) - set(VERSION_ORDER) + if unknown: + fail(f"unknown selected versions: {', '.join(sorted(unknown))}") + return tuple(version for version in VERSION_ORDER if version in selected) + + def prepare(args: argparse.Namespace) -> None: manifest = load_manifest(args.manifest) + selected = selected_version_ids(args.select) resolved = [] for entry in manifest["versions"]: item = dict(entry) @@ -372,7 +426,7 @@ def prepare(args: argparse.Namespace) -> None: "schemaVersion": 1, "repository": manifest["repository"], "versions": resolved, - "include": resolved, + "include": [item for item in resolved if item["id"] in selected], } rendered = json.dumps( result, ensure_ascii=False, sort_keys=True, separators=(",", ":") @@ -492,16 +546,21 @@ def strip_menu_frontmatter(path: pathlib.Path) -> None: if not path.is_file(): return lines = path.read_text(encoding="utf-8").splitlines(keepends=True) - if not lines or lines[0].strip() != "---": + opening_index = next( + (index for index, line in enumerate(lines) if line.strip()), None + ) + if opening_index is None or lines[opening_index].strip() != "---": fail(f"cannot remove menu from non-YAML front matter: {path}") try: closing_index = next( - index for index, line in enumerate(lines[1:], 1) if line.strip() == "---" + index + for index, line in enumerate(lines[opening_index + 1 :], opening_index + 1) + if line.strip() == "---" ) except StopIteration: fail(f"unterminated YAML front matter: {path}") - frontmatter = lines[1:closing_index] - output = [lines[0]] + frontmatter = lines[opening_index + 1 : closing_index] + output = lines[: opening_index + 1] index = 0 while index < len(frontmatter): line = frontmatter[index] @@ -546,6 +605,65 @@ def ensure_frontmatter( return 1 +def add_frontmatter_alias(path: pathlib.Path, alias: str) -> None: + source = path.read_text(encoding="utf-8") + opening = source.find("---") + if opening < 0 or source[:opening].strip(): + fail(f"cannot add route alias without YAML front matter: {path}") + closing = source.find("\n---", opening + 3) + if closing < 0: + fail(f"unterminated YAML front matter: {path}") + frontmatter = source[opening + 3 : closing] + if re.search(r"(?m)^aliases\s*:", frontmatter): + fail(f"historical page already declares aliases: {path}") + source = source[:closing] + f"\naliases:\n - {alias}\n" + source[closing:] + path.write_text(source, encoding="utf-8") + + +def migrate_legacy_information_architecture( + assembly: pathlib.Path, version: str +) -> int: + """Move 1.3/1.0 flat pages into the current five-group route hierarchy.""" + if version not in {"1.3", "1.0"}: + return 0 + changed = 0 + for language in ("en", "cn"): + docs = assembly / "content" / language / "docs" + for section, titles in LEGACY_IA_SECTIONS.items(): + index = docs / section / "_index.md" + if not index.exists(): + index.parent.mkdir(parents=True, exist_ok=True) + title = titles[0] if language == "en" else titles[1] + index.write_text( + f'---\ntitle: "{title}"\nlinkTitle: "{title}"\n---\n', + encoding="utf-8", + ) + changed += 1 + for old_relative, new_relative in LEGACY_IA_ROUTE_MAP.items(): + source = docs / old_relative + if not source.is_file(): + continue + target = docs / new_relative + if target.exists(): + if not source.samefile(target): + fail(f"historical route migration target already exists: {target}") + intermediate = source.with_name(source.name + ".route-migration-tmp") + source.rename(intermediate) + source = intermediate + target.parent.mkdir(parents=True, exist_ok=True) + source.rename(target) + old_route = "/docs/" + old_route += old_relative.removesuffix(".md").replace("/README", "") + add_frontmatter_alias(target, old_route + "/") + changed += 1 + summary = docs / "SUMMARY.md" + text = summary.read_text(encoding="utf-8") + for old_relative, new_relative in LEGACY_IA_ROUTE_MAP.items(): + text = text.replace(old_relative, new_relative) + summary.write_text(text, encoding="utf-8") + return changed + + def ensure_search_metadata( path: pathlib.Path, *, keywords: tuple[str, ...], boost: float ) -> int: @@ -765,9 +883,9 @@ def materialize_docs_navigation( LEGACY_WECHAT_IMAGE_RE = re.compile( - r'<img\b(?=[^>]*\bsrc="(?:https://github\.com/apache/hugegraph-doc/' + r'<img\b(?=[^>]*\bsrc="(?:https://github\.com/apache/(?:incubator-)?hugegraph-doc/' r"blob/master/assets/images/wechat\.png\?raw=true|https://raw\.githubusercontent\.com/" - r'apache/hugegraph-doc/master/assets/images/wechat\.png)")' + r'apache/(?:incubator-)?hugegraph-doc/master/assets/images/wechat\.png)")' r'(?=[^>]*\bwidth="(?P<width>200|300)")[^>]*?/?>', re.IGNORECASE, ) @@ -1055,10 +1173,19 @@ def apply_exact_legacy_content_fixes(assembly: pathlib.Path, version: str) -> in return fixed +def first_existing_path(assembly: pathlib.Path, candidates: tuple[str, ...]) -> pathlib.Path: + """Resolve one version-specific content path without guessing missing files.""" + matches = [assembly / candidate for candidate in candidates if (assembly / candidate).is_file()] + if len(matches) != 1: + fail(f"expected exactly one historical path, found {len(matches)}: {candidates!r}") + return matches[0] + + def apply_known_legacy_fixes(assembly: pathlib.Path, version: str) -> int: fixed = 0 - if version in {"1.7", "1.5"}: - fixed += apply_exact_legacy_content_fixes(assembly, version) + if version in {"1.7", "1.5", "1.3", "1.0"}: + if version in LEGACY_EXACT_CONTENT_FIXES: + fixed += apply_exact_legacy_content_fixes(assembly, version) for language in ("en", "cn"): language_prefix = "/cn" if language == "cn" else "" legacy_metadata = ( @@ -1074,15 +1201,14 @@ def apply_known_legacy_fixes(assembly: pathlib.Path, version: str) -> int: ), ) for relative, title, link_title in legacy_metadata: - fixed += ensure_frontmatter( - assembly / f"content/{language}/docs/{relative}", - title=title, - link_title=link_title, - weight=100, - ) - fixed += ensure_search_excluded( - assembly / f"content/{language}/docs/CLA.md" - ) + page = assembly / f"content/{language}/docs/{relative}" + if page.is_file(): + fixed += ensure_frontmatter( + page, title=title, link_title=link_title, weight=100 + ) + cla = assembly / f"content/{language}/docs/CLA.md" + if cla.is_file(): + fixed += ensure_search_excluded(cla) search_metadata = ( ( "config/config-option.md", @@ -1098,16 +1224,20 @@ def apply_known_legacy_fixes(assembly: pathlib.Path, version: str) -> int: ), ) for relative, keywords in search_metadata: - fixed += ensure_search_metadata( - assembly / f"content/{language}/docs/{relative}", - keywords=keywords, - boost=1.5, - ) + page = assembly / f"content/{language}/docs/{relative}" + if page.is_file(): + fixed += ensure_search_metadata(page, keywords=keywords, boost=1.5) summary_path = assembly / f"content/{language}/docs/SUMMARY.md" - fixed += repair_historical_performance_routes(summary_path) + if version in {"1.7", "1.5"}: + fixed += repair_historical_performance_routes(summary_path) fixed += normalize_historical_server_headings( - assembly - / f"content/{language}/docs/quickstart/hugegraph/hugegraph-server.md" + first_existing_path( + assembly, + ( + f"content/{language}/docs/quickstart/hugegraph/hugegraph-server.md", + f"content/{language}/docs/quickstart/hugegraph-server.md", + ), + ) ) replacements = [] if language == "cn": @@ -1119,6 +1249,15 @@ def apply_known_legacy_fixes(assembly: pathlib.Path, version: str) -> int: ) replacements.extend( [ + ("/en/docs/", "/docs/"), + ( + "/dcos/clients/restful-api", + f"{language_prefix}/docs/clients/restful-api/", + ), + ( + "/docs/quickstart/hugegraph-studio", + f"{language_prefix}/docs/quickstart/toolchain/hugegraph-hubble", + ), ( "/docs/quickstart/hugegraph-server", f"{language_prefix}/docs/quickstart/hugegraph/hugegraph-server", @@ -1127,6 +1266,10 @@ def apply_known_legacy_fixes(assembly: pathlib.Path, version: str) -> int: "/clients/gremlin-console.html", f"{language_prefix}/docs/clients/gremlin-console/", ), + ( + "/clients/hugegraph-api.html", + f"{language_prefix}/docs/clients/restful-api/", + ), ( "./hugegraph-style.xml", "https://github.com/apache/hugegraph/blob/" @@ -1215,12 +1358,23 @@ def apply_known_legacy_fixes(assembly: pathlib.Path, version: str) -> int: return fixed -def version_urls(manifest: dict, origin: str, language: str = "en") -> list[dict]: +def version_urls( + manifest: dict, + origin: str, + language: str = "en", + historical_origin: str | None = None, +) -> list[dict]: if language not in {"en", "cn"}: fail(f"unsupported version-menu language: {language}") language_prefix = "cn/" if language == "cn" else "" urls = [] for entry in manifest["versions"]: + entry_origin = ( + historical_origin + if entry.get("archived", entry["id"] != "latest") + and historical_origin is not None + else origin + ) path = ( f"{entry['publishPath']}/{language_prefix}docs/" if entry["publishPath"] @@ -1230,20 +1384,25 @@ def version_urls(manifest: dict, origin: str, language: str = "en") -> list[dict { "version": entry["id"], "name": entry["name"], - "url": urllib.parse.urljoin(origin.rstrip("/") + "/", path), + "url": urllib.parse.urljoin(entry_origin.rstrip("/") + "/", path), "pagelinks": False, } ) return urls -def language_version_params(manifest: dict, origin: str, language: str) -> dict: +def language_version_params( + manifest: dict, + origin: str, + language: str, + historical_origin: str | None = None, +) -> dict: """Build language-preserving version selector and archive-banner params.""" if language not in {"en", "cn"}: fail(f"unsupported version-menu language: {language}") return { "version_menu": "Releases" if language == "en" else "版本", - "versions": version_urls(manifest, origin, language), + "versions": version_urls(manifest, origin, language, historical_origin), "url_latest_version": urllib.parse.urljoin( origin.rstrip("/") + "/", "cn/docs/" if language == "cn" else "docs/", @@ -1557,6 +1716,51 @@ def scope_language_fallback_urls( return changed +def normalize_language_switch_urls( + action_data: dict, + relative: str, + artifact_base: str, + output: pathlib.Path, +) -> int: + """Keep language choices on an equivalent page or the same-version locale root.""" + actions = action_data.get("actions") + if not isinstance(actions, list): + return 0 + switches = [ + action + for action in actions + if isinstance(action, dict) and action.get("id") == "switch_language" + ] + if len(switches) != 1 or not isinstance(switches[0].get("options"), list): + return 0 + english_relative = relative.removeprefix("cn/") + chinese_relative = "cn/" + english_relative + candidates = {"en-US": english_relative, "zh-CN": chinese_relative} + roots = {"en-US": "", "zh-CN": "cn/"} + changed = 0 + for option in switches[0]["options"]: + language = option.get("id") + candidate = candidates.get(language) + if candidate is None: + continue + target = output / candidate + route = candidate + if not target.is_file(): + route = roots[language] + elif route.endswith("index.html"): + route = route[: -len("index.html")] + new_url = ( + "/" + route + if not target.is_file() + and urllib.parse.urlsplit(artifact_base).path == "/" + else urllib.parse.urljoin(artifact_base, route) + ) + if option.get("url") != new_url: + option["url"] = new_url + changed += 1 + return changed + + def validate_language_switch_contract( action_data: dict, relative: str, @@ -1665,12 +1869,68 @@ def scope_version_artifact( artifact_base = base_url(origin, entry["publishPath"]) def rewrite(value: str) -> str: - return rewrite_internal_url( + rewritten = rewrite_internal_url( value, origin=origin, publish_path=entry["publishPath"], allowed_paths=allowed_paths, ) + if entry.get("archived", bool(entry.get("publishPath"))): + original = urllib.parse.urlsplit(value) + original_path = original.path.rstrip("/") + "/" + version_prefix = "/" + entry["publishPath"] + if original_path.startswith(version_prefix + "/"): + original_path = original_path[len(version_prefix) :] + rewritten_parts = urllib.parse.urlsplit(rewritten) + scoped_relative = ( + rewritten_parts.path[len("/" + entry["publishPath"]) :] + if rewritten_parts.path.startswith("/" + entry["publishPath"]) + else rewritten_parts.path + ) + if ( + original_path in LATEST_SHARED_DOC_ROUTES + and not target_exists(output, scoped_relative) + ): + return urllib.parse.urlunsplit( + ( + urllib.parse.urlsplit(CANONICAL_ORIGIN).scheme, + urllib.parse.urlsplit(CANONICAL_ORIGIN).netloc, + original_path, + original.query, + original.fragment, + ) + ) + if not target_exists(output, scoped_relative): + counterpart = ( + "/cn" + scoped_relative + if scoped_relative.startswith("/docs/") + else scoped_relative.removeprefix("/cn") + if scoped_relative.startswith("/cn/docs/") + else "" + ) + if counterpart and target_exists(output, counterpart): + counterpart_path = "/" + entry["publishPath"] + counterpart + return urllib.parse.urlunsplit( + ( + urllib.parse.urlsplit(origin).scheme, + urllib.parse.urlsplit(origin).netloc, + counterpart_path, + original.query, + original.fragment, + ) + ) + if re.match( + r"^/(?:cn/)?docs/changelog/hugegraph-0\.[^/]+-release-notes/?$", + scoped_relative, + ): + language_prefix = "/cn" if scoped_relative.startswith("/cn/") else "" + changelog = f"{language_prefix}/docs/changelog/" + if target_exists(output, changelog): + return urllib.parse.urljoin( + base_url(origin, entry["publishPath"]), + changelog.lstrip("/"), + ) + return rewritten stats = {"files": 0, "urls": 0, "manifests": 0, "searchRefs": 0} for path in sorted(output.rglob("*")): @@ -1710,8 +1970,11 @@ def scope_version_artifact( def replace_manifest(match: re.Match) -> str: data = json.loads(match.group("body")) rewritten = rewrite_json_urls(data, rewrite) - scope_language_fallback_urls( - rewritten, path.relative_to(output).as_posix(), artifact_base + normalize_language_switch_urls( + rewritten, + path.relative_to(output).as_posix(), + artifact_base, + output, ) stats["manifests"] += 1 return ( @@ -1787,6 +2050,75 @@ def write_historical_home_redirects(output: pathlib.Path, origin: str) -> int: return len(targets) +def exclude_historical_sitemaps(output: pathlib.Path) -> int: + """Keep archived pages directly reachable without advertising them for indexing.""" + removed = 0 + for path in sorted(output.rglob("sitemap.xml")): + path.unlink() + removed += 1 + robots = output / "robots.txt" + if robots.is_file(): + robots.write_text("User-agent: *\nAllow: /\n", encoding="utf-8") + return removed + + +def remove_non_equivalent_hreflang( + output: pathlib.Path, origin: str, publish_path: str +) -> int: + """Remove Hugo language fallbacks from hreflang while keeping real translations.""" + removed = 0 + prefix = "/" + publish_path.strip("/") if publish_path else "" + origin_parts = urllib.parse.urlsplit(origin.rstrip("/") + "/") + for path in sorted(output.rglob("*.html")): + relative = path.relative_to(output).as_posix() + if relative in {"404.html", "cn/404.html"} or "_print/" in relative: + continue + current_url = public_url_for_file(path, output, origin, publish_path) + current_path = urllib.parse.urlsplit(current_url).path + cn_prefix = prefix + "/cn/" + if current_path.startswith(cn_prefix): + english_path = prefix + current_path[len(prefix + "/cn") :] + else: + english_path = current_path + chinese_path = ( + prefix + "/cn/" + if english_path.rstrip("/") == prefix + else prefix + "/cn" + english_path[len(prefix) :] + ) + expected_paths = {"en-US": english_path, "zh-CN": chinese_path} + + def keep_equivalent(match: re.Match) -> str: + nonlocal removed + parser = DocumentParser() + parser.feed(match.group(0)) + if len(parser.hreflang) != 1: + fail(f"cannot parse hreflang link in {relative}: {match.group(0)}") + language, href = parser.hreflang[0] + expected_path = expected_paths.get(language) + href_parts = urllib.parse.urlsplit( + urllib.parse.urljoin(current_url, href) + ) + local_relative = ( + expected_path[len(prefix) :] if prefix and expected_path else expected_path + ) + if ( + expected_path is not None + and href_parts.scheme == origin_parts.scheme + and href_parts.netloc == origin_parts.netloc + and href_parts.path == expected_path + and target_exists(output, local_relative or "/") + ): + return match.group(0) + removed += 1 + return "" + + source = path.read_text(encoding="utf-8") + rendered = HREFLANG_LINK_RE.sub(keep_equivalent, source) + if rendered != source: + path.write_text(rendered, encoding="utf-8") + return removed + + def public_url_for_file( path: pathlib.Path, root: pathlib.Path, @@ -1972,6 +2304,9 @@ def validate_artifact(args: argparse.Namespace) -> None: ): fail(f"unsafe same-site URL authority in {source_name}: {value}") if parsed_host == canonical_host and parsed_host != origin_host: + if re.match(r"^/versions/(?:1\.7|1\.5|1\.3|1\.0)/(?:cn/)?docs(?:/|$)", parsed.path): + checked_urls += 1 + return fail(f"production-origin URL leaked into staging {source_name}: {value}") if parsed.netloc and parsed_host != origin_host: return @@ -1998,6 +2333,10 @@ def validate_artifact(args: argparse.Namespace) -> None: ) path = parsed.path or "/" normalized = path.rstrip("/") or "/" + normalized_with_slash = normalized.rstrip("/") + "/" + if normalized_with_slash in LATEST_SHARED_DOC_ROUTES: + checked_urls += 1 + return if ( parsed.netloc and (normalized in allowed_paths or is_latest_shared_path(normalized)) @@ -2167,7 +2506,12 @@ def validate_artifact(args: argparse.Namespace) -> None: None, ) language = "cn" if relative.startswith("cn/") else "en" - expected_options = version_urls(manifest, args.site_origin, language) + expected_options = version_urls( + manifest, + args.site_origin, + language, + getattr(args, "historical_origin", None), + ) if switch is None or [ ( item.get("id"), @@ -2272,7 +2616,7 @@ def validate_artifact(args: argparse.Namespace) -> None: if english_path.rstrip("/") == prefix else prefix + "/cn" + english_path[len(prefix) :] ) - expected_hreflang = { + equivalent_urls = { "en-US": urllib.parse.urlunsplit( (origin_parts.scheme, origin_parts.netloc, english_path, "", "") ), @@ -2280,10 +2624,15 @@ def validate_artifact(args: argparse.Namespace) -> None: (origin_parts.scheme, origin_parts.netloc, chinese_path, "", "") ), } - for language, fallback_path in HREFLANG_FALLBACKS.get(relative, {}).items(): - expected_hreflang[language] = urllib.parse.urljoin( - expected_base, fallback_path.lstrip("/") - ) + equivalent_paths = { + "en-US": english_path[len(prefix) :] if prefix else english_path, + "zh-CN": chinese_path[len(prefix) :] if prefix else chinese_path, + } + expected_hreflang = { + language: url + for language, url in equivalent_urls.items() + if target_exists(root, equivalent_paths[language]) + } if actual_hreflang != expected_hreflang: fail( f"hreflang mismatch in {relative}: " @@ -2292,7 +2641,12 @@ def validate_artifact(args: argparse.Namespace) -> None: validate_language_switch_contract( action_data, relative, - expected_hreflang, + { + "en-US": expected_hreflang.get("en-US", expected_base), + "zh-CN": expected_hreflang.get( + "zh-CN", urllib.parse.urljoin(expected_base, "cn/") + ), + }, current_path, "zh-CN" if relative.startswith("cn/") else "en-US", ) @@ -2393,7 +2747,12 @@ def build(args: argparse.Namespace) -> None: "version": entry["id"], "version_menu": "Releases", "version_menu_pagelinks": False, - "versions": version_urls(manifest, args.site_origin, "en"), + "versions": version_urls( + manifest, + args.site_origin, + "en", + args.historical_origin, + ), "archived_version": bool(entry["archived"]), "url_latest_version": urllib.parse.urljoin( args.site_origin.rstrip("/") + "/", "docs/" @@ -2409,7 +2768,10 @@ def build(args: argparse.Namespace) -> None: ) for language in ("en", "cn"): language_overrides[language]["params"] = language_version_params( - manifest, args.site_origin, language + manifest, + args.site_origin, + language, + args.historical_origin, ) override["languages"] = language_overrides override_path = assembly / "version-config.json" @@ -2463,6 +2825,9 @@ def build(args: argparse.Namespace) -> None: cwd=assembly, check=True, ) + route_migrations = migrate_legacy_information_architecture( + assembly, entry["id"] + ) known_fixes = apply_known_legacy_fixes(assembly, entry["id"]) docs_navigation = materialize_docs_navigation(assembly, entry["publishPath"]) subprocess.run( @@ -2501,6 +2866,12 @@ def build(args: argparse.Namespace) -> None: go_directory + os.pathsep + build_environment.get("PATH", "") ) subprocess.run(command, cwd=assembly, check=True, env=build_environment) + non_equivalent_hreflang = remove_non_equivalent_hreflang( + output, args.site_origin, entry["publishPath"] + ) + historical_sitemaps = ( + exclude_historical_sitemaps(output) if entry["archived"] else 0 + ) url_scoping = scope_version_artifact( output, manifest, @@ -2541,10 +2912,13 @@ def build(args: argparse.Namespace) -> None: for item in migration_data.get("files", []) ), "knownFixes": known_fixes, + "routeMigrations": route_migrations, "residual": 0, }, "docsNavigation": docs_navigation, "urlScoping": url_scoping, + "historicalSitemapsRemoved": historical_sitemaps, + "nonEquivalentHreflangRemoved": non_equivalent_hreflang, } ) (output / ".version.json").write_text( @@ -2565,13 +2939,6 @@ def write_aggregate_sitemap(output: pathlib.Path, origin: str, manifest: dict) - urllib.parse.urljoin(origin.rstrip("/") + "/", "en/sitemap.xml"), urllib.parse.urljoin(origin.rstrip("/") + "/", "cn/sitemap.xml"), ] - for entry in manifest["versions"]: - if entry["publishPath"]: - locations.append( - urllib.parse.urljoin( - origin.rstrip("/") + "/", entry["publishPath"] + "/sitemap.xml" - ) - ) root = ET.Element( "sitemapindex", xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" ) @@ -2647,12 +3014,18 @@ def validate_output_security(output: pathlib.Path, site_origin: str) -> None: def aggregate(args: argparse.Namespace) -> None: manifest = load_resolved_manifest(args.resolved_manifest) + selected = selected_version_ids(getattr(args, "select", None)) output = prepare_output_directory(args.output, "aggregate output") output.mkdir(parents=True) seen: set[str] = set() resolved = [] for entry in manifest["versions"]: - source = args.artifacts / f"{args.artifact_prefix}{entry['id']}" + if entry["id"] not in selected: + continue + source = args.artifacts / ( + f"{args.artifact_prefix}{entry['id']}" + f"{getattr(args, 'artifact_suffix', '')}" + ) metadata_path = source / ".version.json" if not metadata_path.is_file(): fail(f"missing version metadata: {metadata_path}") @@ -2720,6 +3093,7 @@ def parser() -> argparse.ArgumentParser: prepare_parser = commands.add_parser("prepare") prepare_parser.add_argument("--local", action="store_true") prepare_parser.add_argument("--latest-sha", default="HEAD") + prepare_parser.add_argument("--select") prepare_parser.add_argument("--output", type=pathlib.Path) prepare_parser.set_defaults(func=prepare) @@ -2727,6 +3101,7 @@ def parser() -> argparse.ArgumentParser: build_parser.add_argument("--version", required=True) build_parser.add_argument("--sha", required=True) build_parser.add_argument("--site-origin", required=True) + build_parser.add_argument("--historical-origin") build_parser.add_argument("--output", type=pathlib.Path, required=True) build_parser.set_defaults(func=build) @@ -2734,16 +3109,19 @@ def parser() -> argparse.ArgumentParser: validate_parser.add_argument("--version", required=True) validate_parser.add_argument("--sha", required=True) validate_parser.add_argument("--site-origin", required=True) + validate_parser.add_argument("--historical-origin") validate_parser.add_argument("--artifact", type=pathlib.Path, required=True) validate_parser.set_defaults(func=validate_artifact) aggregate_parser = commands.add_parser("aggregate") aggregate_parser.add_argument("--artifacts", type=pathlib.Path, required=True) aggregate_parser.add_argument("--artifact-prefix", default="") + aggregate_parser.add_argument("--artifact-suffix", default="") aggregate_parser.add_argument( "--resolved-manifest", type=pathlib.Path, required=True ) aggregate_parser.add_argument("--site-origin", required=True) + aggregate_parser.add_argument("--select") aggregate_parser.add_argument("--output", type=pathlib.Path, required=True) aggregate_parser.add_argument("--asf-profile") aggregate_parser.add_argument("--asf-whoami") diff --git a/versions.json b/versions.json index 0642789de..a319b3a68 100644 --- a/versions.json +++ b/versions.json @@ -25,6 +25,22 @@ "publishPath": "versions/1.5", "archived": true, "githubBranch": "release-1.5.0" + }, + { + "id": "1.3", + "name": "1.3", + "ref": "release-1.3.0", + "publishPath": "versions/1.3", + "archived": true, + "githubBranch": "release-1.3.0" + }, + { + "id": "1.0", + "name": "1.0", + "ref": "release-1.0.0", + "publishPath": "versions/1.0", + "archived": true, + "githubBranch": "release-1.0.0" } ] }
