['\"]?)(?P[^'\"\s)]+)(?P=quote)\s*\)" - r"|@import\s+(?P ['\"])(?P [^'\"]+)" - r"(?P=import_quote)", - re.IGNORECASE, - ) - return [ - match.group("url") or match.group("import_url") - for match in pattern.finditer(value) - ] + """Extract browser request URLs from CSS without regex token ambiguity.""" + + def consume_escape(source: str, position: int) -> tuple[str, int]: + position += 1 + if position >= len(source): + return "", position + if source[position] in "\r\n\f": + if source[position] == "\r" and position + 1 < len(source): + position += source[position + 1] == "\n" + return "", position + 1 + end = position + while ( + end < len(source) + and end - position < 6 + and source[end] in "0123456789abcdefABCDEF" + ): + end += 1 + if end > position: + codepoint = int(source[position:end], 16) + if end < len(source) and source[end].isspace(): + end += 1 + if codepoint == 0 or codepoint > 0x10FFFF or 0xD800 <= codepoint <= 0xDFFF: + return "\N{REPLACEMENT CHARACTER}", end + return chr(codepoint), end + return source[position], position + 1 + + def skip_space_and_comments(source: str, position: int) -> int: + while position < len(source): + if source[position].isspace(): + position += 1 + elif source.startswith("/*", position): + closing = source.find("*/", position + 2) + position = len(source) if closing < 0 else closing + 2 + else: + break + return position + + def consume_name(source: str, position: int) -> tuple[str, int]: + decoded: list[str] = [] + while position < len(source): + char = source[position] + if char == "\\": + escaped, position = consume_escape(source, position) + decoded.append(escaped) + elif char.isalnum() or char in "_-" or ord(char) >= 0x80: + decoded.append(char) + position += 1 + else: + break + return "".join(decoded), position + + def consume_string(source: str, position: int) -> tuple[str, int]: + quote = source[position] + position += 1 + decoded: list[str] = [] + while position < len(source): + char = source[position] + if char == quote: + return "".join(decoded), position + 1 + if char == "\\": + escaped, position = consume_escape(source, position) + decoded.append(escaped) + continue + decoded.append(char) + position += 1 + return "".join(decoded), position + + def matching_paren(source: str, position: int) -> int: + depth = 1 + while position < len(source): + if source.startswith("/*", position): + position = skip_space_and_comments(source, position) + continue + char = source[position] + if char in "'\"": + _string, position = consume_string(source, position) + elif char == "\\": + _escaped, position = consume_escape(source, position) + else: + depth += char == "(" + depth -= char == ")" + position += 1 + if depth == 0: + return position - 1 + return len(source) + + def consume_url_function(source: str, position: int) -> tuple[str, int]: + position = skip_space_and_comments(source, position) + if position < len(source) and source[position] in "'\"": + resource, position = consume_string(source, position) + position = skip_space_and_comments(source, position) + closed = position < len(source) and source[position] == ")" + return resource, position + closed + + decoded: list[str] = [] + while position < len(source): + if source.startswith("/*", position): + position = skip_space_and_comments(source, position) + continue + char = source[position] + if char == ")": + return "".join(decoded).strip(), position + 1 + if char.isspace(): + position = skip_space_and_comments(source, position) + while position < len(source) and source[position] != ")": + position += 1 + return "".join(decoded), position + (position < len(source)) + if char == "\\": + escaped, position = consume_escape(source, position) + decoded.append(escaped) + continue + decoded.append(char) + position += 1 + return "".join(decoded).strip(), position + + def scan(source: str) -> list[str]: + resources: list[str] = [] + position = 0 + while position < len(source): + if source.startswith("/*", position) or source[position].isspace(): + position = skip_space_and_comments(source, position) + continue + if source[position] in "'\"": + _string, position = consume_string(source, position) + continue + if source[position] == "@": + name, after_name = consume_name(source, position + 1) + if name.lower() == "import": + candidate = skip_space_and_comments(source, after_name) + if candidate < len(source) and source[candidate] in "'\"": + resource, position = consume_string(source, candidate) + resources.append(resource) + continue + position = max(after_name, position + 1) + continue + if ( + source[position].isalnum() + or source[position] in "_-\\" + or ord(source[position]) >= 0x80 + ): + name, after_name = consume_name(source, position) + opening = skip_space_and_comments(source, after_name) + if opening >= len(source) or source[opening] != "(": + position = max(after_name, position + 1) + continue + lowered = name.lower() + if lowered == "url": + resource, position = consume_url_function(source, opening + 1) + if resource: + resources.append(resource) + continue + if lowered in {"image-set", "-webkit-image-set"}: + closing = matching_paren(source, opening + 1) + body = source[opening + 1 : closing] + candidate_start = 0 + depth = 0 + cursor = 0 + while cursor <= len(body): + at_end = cursor == len(body) + if not at_end and body.startswith("/*", cursor): + cursor = skip_space_and_comments(body, cursor) + continue + if not at_end and body[cursor] in "'\"": + _string, cursor = consume_string(body, cursor) + continue + if not at_end and body[cursor] == "\\": + _escaped, cursor = consume_escape(body, cursor) + continue + if not at_end: + depth += body[cursor] == "(" + depth -= body[cursor] == ")" + if at_end or (body[cursor] == "," and depth == 0): + candidate = body[candidate_start:cursor] + first = skip_space_and_comments(candidate, 0) + if first < len(candidate) and candidate[first] in "'\"": + resource, _end = consume_string(candidate, first) + resources.append(resource) + else: + resources.extend(scan(candidate)) + candidate_start = cursor + 1 + cursor += 1 + position = closing + (closing < len(source)) + continue + position = opening + 1 + continue + position += 1 + return resources + + return scan(value) def css_external_resources( @@ -199,10 +446,59 @@ def image_url_allowed_by_asf_csp( return hostname in ASF_CSP_IMAGE_HOSTS or hostname.endswith(ASF_CSP_IMAGE_SUFFIXES) -def error_document_seo_errors(parser: DocumentParser, page_name: str) -> list[str]: +def error_document_paths(root: pathlib.Path | None = None) -> set[str]: + """Return root and version-scoped error documents from the active manifest.""" + + manifest_paths = [] + if root is not None: + manifest_paths.append(root / "build-metadata/versions.json") + manifest_paths.append(pathlib.Path(__file__).resolve().parents[1] / "versions.json") + manifest_path = next((path for path in manifest_paths if path.is_file()), None) + if manifest_path is None: + return set(ERROR_DOCUMENT_PATHS) + + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise ValueError(f"cannot parse version manifest {manifest_path}: {exc}") from exc + versions = manifest.get("versions") + if not isinstance(versions, list): + raise ValueError(f"version manifest {manifest_path} has no versions list") + + paths = set(ERROR_DOCUMENT_PATHS) + for entry in versions: + publish_path = entry.get("publishPath") if isinstance(entry, dict) else None + if not isinstance(publish_path, str): + raise ValueError( + f"version manifest {manifest_path} has invalid publishPath" + ) + if not publish_path: + continue + pure_path = pathlib.PurePosixPath(publish_path) + if ( + pure_path.is_absolute() + or ".." in pure_path.parts + or pure_path.as_posix() != publish_path + ): + raise ValueError( + f"version manifest {manifest_path} has unsafe publishPath " + f"{publish_path!r}" + ) + paths.add(f"{publish_path}/404.html") + paths.add(f"{publish_path}/cn/404.html") + return paths + + +def error_document_seo_errors( + parser: DocumentParser, + page_name: str, + error_paths: set[str] | None = None, +) -> list[str]: """Require error documents to stay out of indexes and canonical clusters.""" - if page_name not in ERROR_DOCUMENT_PATHS: + if page_name not in ( + error_paths if error_paths is not None else error_document_paths() + ): return [] errors: list[str] = [] @@ -304,6 +600,10 @@ def document_security_errors( f"{page_name}: unsafe content markup: {violation}" for violation in parser.authored_violations ] + if parser._content_markers[0] != parser._content_markers[1]: + errors.append( + f"{page_name}: unsafe content markup: unbalanced authored-content markers" + ) errors.extend( f"{page_name}: mixed-content CSS resource: {resource}" for resource in parser.inline_css_http_resources @@ -323,7 +623,14 @@ def document_security_errors( f"{page_name}: external active resource is forbidden <{tag}> " f"{attribute}: {resource}" for tag, attribute, resource in parser.resources - if (tag, attribute) in EXTERNAL_ACTIVE_RESOURCE_ATTRIBUTES + if ( + (tag, attribute) in EXTERNAL_ACTIVE_RESOURCE_ATTRIBUTES + or attribute in RUNTIME_ACTIVE_URL_ATTRIBUTES + or ( + attribute in {"href", "xlink:href"} + and tag not in {"a", "image", "feimage"} + ) + ) and urllib.parse.urlsplit(resource.strip()).netloc and urllib.parse.urlsplit(resource.strip()).netloc != base_parts.netloc and urllib.parse.urlsplit(resource.strip()).scheme.lower() != "http" @@ -346,6 +653,7 @@ class DocumentParser(html.parser.HTMLParser): def __init__(self) -> None: super().__init__(convert_charrefs=True) self.urls: list[tuple[str, str]] = [] + self.navigation_urls: list[tuple[str, str, str]] = [] self.canonical: list[str] = [] self.hreflang: list[tuple[str, str]] = [] self.meta: list[dict[str, str]] = [] @@ -358,11 +666,49 @@ def __init__(self) -> None: self.action_manifest = "" self._in_action_manifest = False self._content_depth = 0 + self._content_markers = [0, 0] + self._in_content_marker = False self._in_style = False + self._svg_depth = 0 + self._media_elements: list[str] = [] def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: tag = tag.lower() + if tag in {"audio", "video"}: + self._media_elements.append(tag) + in_svg = bool(self._svg_depth) or tag == "svg" + if tag == "svg": + self._svg_depth += 1 + seen_attributes: set[str] = set() + duplicate_attributes: set[str] = set() + for key, _value in attrs: + attribute = key.lower() + if attribute in seen_attributes and attribute not in duplicate_attributes: + self.authored_violations.append( + f"duplicate {attribute} attribute on <{tag}>" + ) + duplicate_attributes.add(attribute) + seen_attributes.add(attribute) values = {key.lower(): value or "" for key, value in attrs} + content_marker = ( + values.get("data-hg-authored-content") + if tag == "template" + else None + ) + if content_marker == "start": + self._content_markers[0] += 1 + if self._in_content_marker: + self.authored_violations.append( + "nested authored-content start marker" + ) + self._in_content_marker = True + elif content_marker == "end": + self._content_markers[1] += 1 + if not self._in_content_marker: + self.authored_violations.append( + "unexpected authored-content end marker" + ) + self._in_content_marker = False if tag == "nav" and "TableOfContents" in [ value or "" for key, value in attrs if key.lower() == "id" ]: @@ -371,7 +717,7 @@ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None ) if tag in {"main", "article"}: self._content_depth += 1 - if self._content_depth: + if self._content_depth or self._in_content_marker: if tag in UNSAFE_AUTHORED_ELEMENTS and not is_inert_oink_diagram_source( tag, values ): @@ -382,7 +728,10 @@ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None f"authored {attribute} event attribute on <{tag}>" ) - if tag in {"a", "link"} and values.get("href"): + if tag in {"a", "area"} and values.get("href"): + self.urls.append(("href", values["href"])) + self.navigation_urls.append((tag, "href", values["href"])) + if tag == "link" and values.get("href"): self.urls.append(("href", values["href"])) if tag in {"img", "script", "source"} and values.get("src"): self.urls.append(("src", values["src"])) @@ -394,32 +743,100 @@ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None resource_attributes.append(("poster", values["poster"])) if tag == "object" and values.get("data"): resource_attributes.append(("data", values["data"])) + self.urls.append(("data", values["data"])) + if tag == "form" and values.get("action"): + resource_attributes.append(("action", values["action"])) + self.urls.append(("action", values["action"])) + if tag in {"button", "input"} and values.get("formaction"): + resource_attributes.append(("formaction", values["formaction"])) + self.urls.append(("formaction", values["formaction"])) + if tag in SVG_ACTIVE_IRI_ELEMENTS | {"image", "feimage"}: + for attribute in ("href", "xlink:href"): + if not values.get(attribute): + continue + if (attribute, values[attribute]) not in resource_attributes: + resource_attributes.append((attribute, values[attribute])) + if (attribute, values[attribute]) not in self.urls: + self.urls.append((attribute, values[attribute])) + if ( + in_svg + and tag not in {"a", "image", "feimage", "link"} + and values.get("href") + ): + if ("href", values["href"]) not in resource_attributes: + resource_attributes.append(("href", values["href"])) + if ("href", values["href"]) not in self.urls: + self.urls.append(("href", values["href"])) + if tag == "a" and values.get("xlink:href"): + self.urls.append(("xlink:href", values["xlink:href"])) + self.navigation_urls.append((tag, "xlink:href", values["xlink:href"])) + elif ( + values.get("xlink:href") + and tag not in SVG_ACTIVE_IRI_ELEMENTS | {"image", "feimage"} + ): + resource_attributes.append(("xlink:href", values["xlink:href"])) + self.urls.append(("xlink:href", values["xlink:href"])) if tag == "iframe" and values.get("src"): resource_attributes.append(("src", values["src"])) + if tag == "frame" and values.get("src"): + resource_attributes.append(("src", values["src"])) + self.urls.append(("src", values["src"])) if ( tag == "input" and values.get("type", "").lower() == "image" and values.get("src") ): resource_attributes.append(("src", values["src"])) - if tag == "image" and values.get("href"): - resource_attributes.append(("href", values["href"])) if tag == "link" and values.get("href"): rel = set(values.get("rel", "").lower().split()) - if rel & { - "stylesheet", - "preload", - "modulepreload", - "icon", - "apple-touch-icon", - "manifest", - }: + is_metadata = rel == LINK_METADATA_RELS or ( + rel == {"alternate"} and bool(values.get("hreflang")) + ) + if rel & LINK_RESOURCE_RELS or not is_metadata: resource_attributes.append(("href", values["href"])) + else: + self.navigation_urls.append((tag, "href", values["href"])) + if tag == "link" and values.get("imagesrcset"): + urls = srcset_urls(values["imagesrcset"]) + resource_attributes.extend( + ("imagesrcset", url) for url in urls + ) + self.urls.extend(("imagesrcset", url) for url in urls) + self.image_urls.extend( + (tag, "imagesrcset", url) for url in urls + ) + if tag in {"a", "area"} and "ping" in values: + self.authored_violations.append(f"forbidden ping attribute on <{tag}>") + if tag == "base" and "href" in values: + self.authored_violations.append("forbidden base[href]") + if tag == "iframe" and "srcdoc" in values: + self.authored_violations.append("forbidden iframe[srcdoc]") + if "attributionsrc" in values: + self.authored_violations.append( + f"forbidden attributionsrc attribute on <{tag}>" + ) + if tag in {"body", "table", "td", "th"} and values.get("background"): + resource_attributes.append(("background", values["background"])) + self.urls.append(("background", values["background"])) + self.image_urls.append((tag, "background", values["background"])) + for attribute in RUNTIME_ACTIVE_URL_ATTRIBUTES | RUNTIME_IMAGE_URL_ATTRIBUTES: + if not values.get(attribute): + continue + resource_attributes.append((attribute, values[attribute])) + self.urls.append((attribute, values[attribute])) + if attribute in RUNTIME_IMAGE_URL_ATTRIBUTES: + self.image_urls.append((tag, attribute, values[attribute])) + resource_tag = ( + "media-source" + if tag == "source" and self._media_elements + else tag + ) self.resources.extend( - (tag, attribute, url) for attribute, url in resource_attributes + (resource_tag, attribute, url) + for attribute, url in resource_attributes ) - if tag in {"img", "source"}: + if tag == "img" or (tag == "source" and not self._media_elements): for attribute in ("src", "srcset"): if not values.get(attribute): continue @@ -430,7 +847,16 @@ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None ) self.image_urls.extend((tag, attribute, url) for url in urls) if attribute == "srcset": - self.resources.extend((tag, attribute, url) for url in urls) + self.resources.extend( + (resource_tag, attribute, url) for url in urls + ) + self.urls.extend((attribute, url) for url in urls) + elif tag == "source" and values.get("srcset"): + urls = srcset_urls(values["srcset"]) + self.resources.extend( + (resource_tag, "srcset", url) for url in urls + ) + self.urls.extend(("srcset", url) for url in urls) if tag == "video" and values.get("poster"): self.image_urls.append((tag, "poster", values["poster"])) if ( @@ -439,12 +865,20 @@ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None and values.get("src") ): self.image_urls.append((tag, "src", values["src"])) - if tag == "image" and values.get("href"): - self.image_urls.append((tag, "href", values["href"])) + if tag in {"image", "feimage"}: + for attribute in ("href", "xlink:href"): + if values.get(attribute): + self.image_urls.append((tag, attribute, values[attribute])) if values.get("style"): self.inline_css_sources.append(values["style"]) self.inline_css_http_resources.extend(css_http_resources(values["style"])) + for attribute in CSS_PRESENTATION_ATTRIBUTES: + if values.get(attribute): + self.inline_css_sources.append(values[attribute]) + self.inline_css_http_resources.extend( + css_http_resources(values[attribute]) + ) if tag == "link" and values.get("rel", "").lower() == "canonical": self.canonical.append(values.get("href", "")) if ( @@ -468,10 +902,24 @@ def handle_data(self, data: str) -> None: self.inline_css_http_resources.extend(css_http_resources(data)) def handle_endtag(self, tag: str) -> None: + tag = tag.lower() if tag == "script" and self._in_action_manifest: self._in_action_manifest = False if tag == "style": self._in_style = False + if tag == "svg" and self._svg_depth: + self._svg_depth -= 1 + if tag in {"audio", "video"}: + if self._media_elements and self._media_elements[-1] == tag: + self._media_elements.pop() + elif self._media_elements: + self.authored_violations.append( + f"mismatched {tag}> inside <{self._media_elements[-1]}>" + ) + else: + self.authored_violations.append( + f"unmatched {tag}>" + ) if tag in {"main", "article"} and self._content_depth: self._content_depth -= 1 @@ -514,6 +962,44 @@ def internal_output_target( return output_path(root, parts.path or "/") +def css_internal_output_target( + root: pathlib.Path, + stylesheet: pathlib.Path, + base_parts: urllib.parse.SplitResult, + url: str, +) -> pathlib.Path | None: + """Resolve one same-origin CSS request against its emitted stylesheet.""" + + parts = urllib.parse.urlsplit(url) + if parts.netloc and parts.netloc != base_parts.netloc: + return None + decoded = urllib.parse.unquote(parts.path) + if not decoded: + return None if parts.fragment else stylesheet + if "\x00" in decoded or "\\" in decoded: + raise ValueError("contains a NUL or backslash") + + if decoded.startswith("/"): + artifact_base = urllib.parse.unquote(base_parts.path).rstrip("/") + if artifact_base and ( + decoded == artifact_base or decoded.startswith(artifact_base + "/") + ): + decoded = decoded[len(artifact_base) :] or "/" + candidate = root / decoded.lstrip("/") + else: + candidate = stylesheet.parent / decoded + candidate = candidate.resolve() + try: + candidate.relative_to(root) + except ValueError as exc: + raise ValueError("escapes output directory") from exc + if decoded.endswith("/") or ( + not candidate.is_file() and not pathlib.PurePosixPath(decoded).suffix + ): + candidate /= "index.html" + return candidate + + def refresh_target(parser: DocumentParser) -> str | None: refresh = [ item.get("content", "") @@ -530,6 +1016,81 @@ def refresh_target(parser: DocumentParser) -> str | None: return match.group(1).strip(" \"'") +def rendered_url_shape_error( + value: str, + page_name: str, + attribute: str, + *, + allow_contact: bool = False, +) -> str | None: + """Reject URL spellings that browsers and RFC parsers interpret differently.""" + if any( + char.isspace() or ord(char) < 0x20 or ord(char) == 0x7F for char in value + ): + return ( + f"{page_name}: unsafe whitespace/control URL in {attribute}: {value}" + ) + if "\\" in value: + return f"{page_name}: unsafe backslash URL in {attribute}: {value}" + if value.startswith("//"): + return f"{page_name}: protocol-relative {attribute} is forbidden: {value}" + try: + parts = urllib.parse.urlsplit(value) + except ValueError as exc: + return f"{page_name}: malformed URL in {attribute}: {value}: {exc}" + if parts.scheme.lower() in {"http", "https"} and not parts.netloc: + return f"{page_name}: HTTP(S) URL has no authority in {attribute}: {value}" + allowed_schemes = {"", "http", "https"} + if allow_contact: + allowed_schemes.update({"mailto", "tel"}) + if parts.scheme.lower() not in allowed_schemes: + return f"{page_name}: forbidden URL scheme in {attribute}: {value}" + return None + + +def document_url_shape_errors( + parser: DocumentParser, + page_name: str, +) -> list[str]: + """Apply the browser-safe URL shape contract to every rendered URL token.""" + tokens: list[tuple[str, str, bool]] = [ + (f"{tag}[{attribute}]", value, tag in {"a", "area"}) + for tag, attribute, value in parser.navigation_urls + ] + tokens.extend( + (f"{tag}[{attribute}]", value, False) + for tag, attribute, value in parser.resources + ) + tokens.extend( + ("inline CSS", value, False) + for source in parser.inline_css_sources + for value in css_resource_urls(source) + ) + try: + alias_target = refresh_target(parser) + except ValueError as exc: + return [f"{page_name}: {exc}"] + if alias_target: + tokens.append(("meta refresh", alias_target, False)) + + errors = [] + seen: set[tuple[str, str, bool]] = set() + for attribute, value, allow_contact in tokens: + key = (attribute, value, allow_contact) + if key in seen: + continue + seen.add(key) + error = rendered_url_shape_error( + value, + page_name, + attribute, + allow_contact=allow_contact, + ) + if error: + errors.append(error) + return errors + + def main() -> int: argument_parser = argparse.ArgumentParser( description="Validate a generated HugeGraph documentation artifact." @@ -550,6 +1111,11 @@ def main() -> int: base = args.expected_base_url.rstrip("/") + "/" base_parts = urllib.parse.urlsplit(base) errors: list[str] = [] + try: + error_paths = error_document_paths(root) + except ValueError as exc: + errors.append(str(exc)) + error_paths = set(ERROR_DOCUMENT_PATHS) if not root.is_dir(): errors.append(f"missing output directory: {root}") @@ -591,8 +1157,12 @@ def main() -> int: continue page_name = page.relative_to(root).as_posix() + shape_errors = document_url_shape_errors(parser, page_name) + errors.extend(shape_errors) + if shape_errors: + continue errors.extend(document_security_errors(parser, page_name, base_parts)) - errors.extend(error_document_seo_errors(parser, page_name)) + errors.extend(error_document_seo_errors(parser, page_name, error_paths)) if args.security_only: continue errors.extend(toc_accessibility_errors(parser, page_name)) @@ -601,7 +1171,7 @@ def main() -> int: except ValueError as exc: errors.append(f"{page_name}: {exc}") alias_target = None - is_error_document = page_name in ERROR_DOCUMENT_PATHS + is_error_document = page_name in error_paths if not is_error_document and page_name != "client-go/index.html": if len(parser.canonical) != 1: errors.append( @@ -708,7 +1278,14 @@ def main() -> int: ) for attribute, raw_url in parser.urls: - url = raw_url.strip() + if rendered_url_shape_error( + raw_url, + page_name, + attribute, + allow_contact=True, + ): + continue + url = raw_url lower_url = url.lower() if "/_nav/" in lower_url: errors.append( @@ -721,12 +1298,6 @@ def main() -> int: or lower_url.startswith(("mailto:", "tel:")) ): continue - if url.startswith("//"): - errors.append( - f"{page_name}: protocol-relative {attribute} is forbidden: {url}" - ) - continue - parts = urllib.parse.urlsplit(url) if parts.scheme and parts.scheme.lower() not in {"http", "https"}: errors.append( @@ -761,6 +1332,21 @@ def main() -> int: except (OSError, UnicodeError) as exc: errors.append(f"cannot parse {stylesheet.relative_to(root)}: {exc}") continue + stylesheet_name = stylesheet.relative_to(root).as_posix() + shape_errors = [ + error + for resource in css_resource_urls(stylesheet_text) + if ( + error := rendered_url_shape_error( + resource, + stylesheet_name, + "CSS resource", + ) + ) + ] + errors.extend(shape_errors) + if shape_errors: + continue for resource in css_http_resources(stylesheet_text): errors.append( f"{stylesheet.relative_to(root)}: mixed-content CSS resource: {resource}" @@ -770,6 +1356,22 @@ def main() -> int: f"{stylesheet.relative_to(root)}: external CSS resource is forbidden: " f"{resource}" ) + for resource in css_resource_urls(stylesheet_text): + try: + target = css_internal_output_target( + root, stylesheet, base_parts, resource + ) + except ValueError as exc: + errors.append( + f"{stylesheet_name}: unsafe internal CSS resource " + f"{resource}: {exc}" + ) + continue + if target is not None and not target.is_file(): + errors.append( + f"{stylesheet_name}: broken internal CSS resource {resource} -> " + f"{target.relative_to(root)}" + ) if args.security_only: if errors: diff --git a/hugo.yaml b/hugo.yaml index 8b0afe361..d276b93c5 100644 --- a/hugo.yaml +++ b/hugo.yaml @@ -17,13 +17,14 @@ languages: params: description: Apache HugeGraph documentation and project updates. version_menu: Releases - versions: - - { version: latest, name: latest, url: 'https://hugegraph.apache.org/docs/', pagelinks: false } - - { version: '1.7', name: '1.7', url: 'https://hugegraph.apache.org/versions/1.7/docs/', pagelinks: false } - - { version: '1.5', name: '1.5', url: 'https://hugegraph.apache.org/versions/1.5/docs/', pagelinks: false } menus: main: - { identifier: docs, name: Documentation, pageRef: /docs, weight: 10 } + - { identifier: docs-start, parent: docs, name: Get Started, pageRef: /docs/quickstart, weight: 11, params: { icon: 'fa-solid fa-rocket' } } + - { identifier: docs-components, parent: docs, name: Components, pageRef: /docs/quickstart/hugegraph, weight: 12, params: { icon: 'fa-solid fa-cubes' } } + - { identifier: docs-develop, parent: docs, name: Develop, pageRef: /docs/clients, weight: 13, params: { icon: 'fa-solid fa-code' } } + - { identifier: docs-operate, parent: docs, name: Operate, pageRef: /docs/config, weight: 14, params: { icon: 'fa-solid fa-screwdriver-wrench' } } + - { identifier: docs-reference, parent: docs, name: Reference, pageRef: /docs/changelog, weight: 15, params: { icon: 'fa-solid fa-book-open' } } - { identifier: download, name: Download, pageRef: /docs/download/download, weight: 20 } - { identifier: blog, name: Blog, pageRef: /blog, weight: 30 } - { identifier: community, name: Community, pageRef: /community, weight: 40 } @@ -38,13 +39,14 @@ languages: params: description: Apache HugeGraph 中文文档与项目动态。 version_menu: 版本 - versions: - - { version: latest, name: latest, url: 'https://hugegraph.apache.org/cn/docs/', pagelinks: false } - - { version: '1.7', name: '1.7', url: 'https://hugegraph.apache.org/versions/1.7/cn/docs/', pagelinks: false } - - { version: '1.5', name: '1.5', url: 'https://hugegraph.apache.org/versions/1.5/cn/docs/', pagelinks: false } menus: main: - { identifier: docs, name: 文档, pageRef: /docs, weight: 10 } + - { identifier: docs-start, parent: docs, name: 开始, pageRef: /docs/quickstart, weight: 11, params: { icon: 'fa-solid fa-rocket' } } + - { identifier: docs-components, parent: docs, name: 组件, pageRef: /docs/quickstart/hugegraph, weight: 12, params: { icon: 'fa-solid fa-cubes' } } + - { identifier: docs-develop, parent: docs, name: 开发, pageRef: /docs/clients, weight: 13, params: { icon: 'fa-solid fa-code' } } + - { identifier: docs-operate, parent: docs, name: 运维, pageRef: /docs/config, weight: 14, params: { icon: 'fa-solid fa-screwdriver-wrench' } } + - { identifier: docs-reference, parent: docs, name: 参考, pageRef: /docs/changelog, weight: 15, params: { icon: 'fa-solid fa-book-open' } } - { identifier: download, name: 下载, pageRef: /docs/download/download, weight: 20 } - { identifier: blog, name: 博客, pageRef: /blog, weight: 30 } - { identifier: community, name: 社区, pageRef: /community, weight: 40 } @@ -86,6 +88,7 @@ outputs: params: description: Apache HugeGraph is a full-stack graph database ecosystem for OLTP, OLAP, and graph AI. + images: [/img/social/hugegraph-default.png] github_repo: https://github.com/apache/hugegraph-doc github_project_repo: https://github.com/apache/hugegraph github_branch: master @@ -102,12 +105,22 @@ params: offline_search_index: summary offline_search_summary_length: 70 offline_search_max_results: 10 + # Optional enhancement: keep disabled until both reviewed latest-only Kapa + # source groups and the staging CSP/corpus acceptance gates pass. + ai_search: + enabled: false + provider: kapa + website_id: 0b277570-4740-451e-96fa-1e4ac1ac5e88 + source_groups: + en: '' + cn: '' footer_center_info: '' # The compact ASF footer owns the legal line; global controls stay in header. copyright: false print: toc: true ui: + theme_color: '#532fc9' dark_mode: enable: true show_menu: true @@ -120,6 +133,8 @@ params: wide_nav_sections: [community] sidebar_icon_policy: groups sidebar_item_overflow: wrap + backlinks: true + image_zoom: true page_context_menu: enable: true assistant_links: false diff --git a/i18n/en.yaml b/i18n/en.yaml new file mode 100644 index 000000000..dfea2d878 --- /dev/null +++ b/i18n/en.yaml @@ -0,0 +1 @@ +ui_version_fallback: This page is not available in the selected version. You have been redirected to that version's documentation home. diff --git a/layouts/_partials/actions/manifest.html b/layouts/_partials/actions/manifest.html new file mode 100644 index 000000000..3029720f3 --- /dev/null +++ b/layouts/_partials/actions/manifest.html @@ -0,0 +1,23 @@ +{{- $outputFormat := lower (.Store.Get "tdOutputFormat" | default "html") -}} +{{- $context := partialCached "actions/context.html" . .Site.BaseURL .Site.Language.Lang .RelPermalink $outputFormat -}} +{{- $versionOptions := partial "version-options.html" . -}} +{{- $actions := slice -}} +{{- range $context.actions -}} + {{- $action := . -}} + {{- if eq .id "switch_version" -}} + {{- $action = merge . (dict + "options" $versionOptions + "available" (gt (len $versionOptions) 0) + "disabledReason" (cond (gt (len $versionOptions) 0) "" (printf "%s: %s" .title (T "ui_action_unavailable"))) + ) -}} + {{- end -}} + {{- $actions = $actions | append $action -}} +{{- end -}} +{{- return (dict + "version" 1 + "language" .Site.Language.Lang + "actions" $actions + "commands" (partialCached "actions/site-commands.html" . .Site.Language.Lang .Site.BaseURL) + "quickLinks" (partialCached "actions/quick-links.html" . .Site.Language.Lang .Site.BaseURL) + "rootOrder" (partialCached "actions/root-order.html" . .Site.Language.Lang .Site.BaseURL) +) -}} diff --git a/layouts/_partials/ai/config.html b/layouts/_partials/ai/config.html new file mode 100644 index 000000000..4a0217381 --- /dev/null +++ b/layouts/_partials/ai/config.html @@ -0,0 +1,33 @@ +{{- $raw := .Site.Params.ai_search | default dict -}} +{{- $enabled := false -}} +{{- $provider := "" -}} +{{- $websiteID := "" -}} +{{- $sourceGroups := dict -}} +{{- if reflect.IsMap $raw -}} + {{- $enabled = index $raw "enabled" | default false -}} + {{- if ne (printf "%T" $enabled) "bool" -}} + {{- errorf "params.ai_search.enabled must be a boolean" -}} + {{- end -}} + {{- $provider = index $raw "provider" | default "" -}} + {{- $websiteID = index $raw "website_id" | default "" -}} + {{- $sourceGroups = index $raw "source_groups" | default dict -}} +{{- else -}} + {{- errorf "params.ai_search must be a map" -}} +{{- end -}} +{{- $enGroup := "" -}} +{{- $cnGroup := "" -}} +{{- if reflect.IsMap $sourceGroups -}} + {{- $enGroup = index $sourceGroups "en" | default "" -}} + {{- $cnGroup = index $sourceGroups "cn" | default "" -}} +{{- end -}} +{{- if $enabled -}} + {{- if ne $provider "kapa" }}{{ errorf "params.ai_search.provider must be kapa when AI search is enabled" }}{{ end -}} + {{- if not $websiteID }}{{ errorf "params.ai_search.website_id is required when AI search is enabled" }}{{ end -}} + {{- if or (not $enGroup) (not $cnGroup) }}{{ errorf "params.ai_search.source_groups.en and .cn are required when AI search is enabled" }}{{ end -}} +{{- end -}} +{{- return (dict + "enabled" $enabled + "provider" $provider + "websiteID" $websiteID + "sourceGroups" (dict "en" $enGroup "cn" $cnGroup) +) -}} diff --git a/layouts/_partials/backlinks-sources.html b/layouts/_partials/backlinks-sources.html new file mode 100644 index 000000000..f76a18b1f --- /dev/null +++ b/layouts/_partials/backlinks-sources.html @@ -0,0 +1,21 @@ +{{- /* HugeGraph exposes backlinks only for latest documentation. Historical + builds and non-doc sections retain their original, uncluttered output. */ -}} +{{- $page := . -}} +{{- $enabled := and + (eq ($page.Site.Params.version | default "latest") "latest") + (eq $page.Section "docs") +-}} +{{- if isset $page.Params "backlinks" -}} + {{- $enabled = partial "validate.html" (dict + "value" (index $page.Params "backlinks") "kind" "bool" + "fallback" $enabled "key" "front matter backlinks" + "where" $page.Path) -}} +{{- end -}} +{{- $sources := slice -}} +{{- if $enabled -}} + {{- $backlinkIndex := partialCached "backlinks-index.html" $page.Site $page.Site.Language.Lang -}} + {{- range sort (index $backlinkIndex $page.Path | default (slice)) "Path" -}} + {{- $sources = $sources | append . -}} + {{- end -}} +{{- end -}} +{{- return $sources -}} diff --git a/layouts/_partials/backlinks.html b/layouts/_partials/backlinks.html new file mode 100644 index 000000000..4c0917334 --- /dev/null +++ b/layouts/_partials/backlinks.html @@ -0,0 +1,50 @@ +{{- /* Keep the right rail compact: five backlinks remain immediately visible + and any additional sources use a native, keyboard-accessible disclosure. + Input: dict "page" . "sources" (from backlinks-sources.html). */ -}} +{{- $p := .page -}} +{{- $sources := .sources -}} +{{- with $sources -}} +{{- $shown := first 5 . -}} +{{- $rest := after 5 . -}} + + ++{{- end -}} diff --git a/layouts/_partials/content/image-zoom-config.html b/layouts/_partials/content/image-zoom-config.html new file mode 100644 index 000000000..d6421621b --- /dev/null +++ b/layouts/_partials/content/image-zoom-config.html @@ -0,0 +1,9 @@ +{{- /* Docs and Blog use OINK's on-demand image preview in every version. */ -}} +{{- $enabled := in (slice "docs" "blog") .Section -}} +{{- if isset .Params "image_zoom" -}} + {{- $enabled = partial "validate.html" (dict + "value" (index .Params "image_zoom") "kind" "bool" + "fallback" $enabled "key" "front matter image_zoom" + "where" .Path) -}} +{{- end -}} +{{- return (dict "enable" $enabled) -}} diff --git a/layouts/_partials/content/render.html b/layouts/_partials/content/render.html new file mode 100644 index 000000000..77763cc28 --- /dev/null +++ b/layouts/_partials/content/render.html @@ -0,0 +1,5 @@ +{{- /* Mark the exact authored-content boundary for the publication scanner. */ -}} +{{- $content := .Content -}} +{{- partial "code/register-rendered-ids.html" (dict "page" . "content" $content) -}} +{{- partial "content/register-derived.html" (dict "page" . "html" $content "raw" .RawContent) -}} +{{- return (printf "%s" $content | safeHTML) -}} diff --git a/layouts/_partials/hooks/body-end.html b/layouts/_partials/hooks/body-end.html new file mode 100644 index 000000000..b8e361764 --- /dev/null +++ b/layouts/_partials/hooks/body-end.html @@ -0,0 +1,54 @@ +{{- $basePath := (urls.Parse .Site.BaseURL).Path | default "/" -}} +{{- $localePrefix := cond (eq .Site.Language.Lang "cn") "cn/" "" -}} +{{- $docsRoot := printf "%s%sdocs/" $basePath $localePrefix -}} +{{- $fallbackMessage := "" -}} +{{- if eq .Site.Language.Lang "cn" -}} + {{- $fallbackMessage = "目标版本没有此页面,已转到该版本的文档首页。" -}} +{{- else -}} + {{- $fallbackMessage = T "ui_version_fallback" -}} +{{- end -}} +{{- $shellConfig := dict + "version" (.Site.Params.version | default "latest") + "locale" .Site.Language.Lang + "docsRoot" $docsRoot + "versionFallbackMessage" $fallbackMessage +-}} + +{{- $shell := resources.Get "js/hugegraph-shell.js" -}} +{{- if hugo.IsProduction }}{{ $shell = $shell | minify | fingerprint }}{{ end }} + + +{{- $ai := partial "ai/config.html" . -}} +{{- if $ai.enabled -}} + {{- $lang := .Site.Language.Lang -}} + {{- $sourceGroup := index $ai.sourceGroups $lang -}} + {{- $themeColor := index .Site.Params.ui "theme_color" -}} + {{- $historical := ne (.Site.Params.version | default "latest") "latest" -}} + {{- $labels := cond (eq $lang "cn") + (dict "ask" "询问 AI" "description" "由 Kapa 提供;仅发送你的问题。" "latest" "回答基于 latest 文档" "retry" "重试" "error" "AI 暂时不可用,本地搜索不受影响。") + (dict "ask" "Ask AI" "description" "Powered by Kapa; only your question is sent." "latest" "Answers use the latest documentation" "retry" "Retry" "error" "AI is temporarily unavailable. Local search is unaffected.") + -}} + {{- $clientConfig := dict + "websiteId" $ai.websiteID + "sourceGroupId" $sourceGroup + "locale" (cond (eq $lang "cn") "zh" "en") + "themeColor" $themeColor + "historical" $historical + "labels" $labels + -}} + + ++++++ {{- range $shown }} +
+ {{- with $rest }} +- + {{- .LinkTitle | default .Title -}} +
+ {{- end }} +++ {{- end }} +{{ T "ui_backlinks_more" (len .) }}
++ {{- range . }} +
+- + {{- .LinkTitle | default .Title -}} +
+ {{- end }} ++ {{ index $labels "description" }}{{ if $historical }} {{ index $labels "latest" }}.{{ end }} +
+ + {{- $adapter := resources.Get "js/kapa-adapter.js" -}} + {{- if hugo.IsProduction }}{{ $adapter = $adapter | minify | fingerprint }}{{ end }} + +{{- end -}} diff --git a/layouts/_partials/hooks/head-end.html b/layouts/_partials/hooks/head-end.html new file mode 100644 index 000000000..43baeb1fb --- /dev/null +++ b/layouts/_partials/hooks/head-end.html @@ -0,0 +1,5 @@ +{{- $themeColor := index .Site.Params.ui "theme_color" | default "" -}} +{{- if not (findRE `^#[0-9a-fA-F]{6}$` $themeColor) -}} + {{- errorf "params.ui.theme_color must be a six-digit hexadecimal color" -}} +{{- end }} + diff --git a/layouts/_partials/navbar-item.html b/layouts/_partials/navbar-item.html index a3f2dec9d..d07dd32d2 100644 --- a/layouts/_partials/navbar-item.html +++ b/layouts/_partials/navbar-item.html @@ -6,7 +6,6 @@ {{- $mode := .mode -}} {{- $index := .index -}} {{- $hasChildren := $entry.HasChildren -}} -{{- $hasVersionLinks := and (eq $entry.Identifier "docs") (gt (len ($page.Site.Params.versions | default slice)) 0) -}} {{- $taxonomyPage := false -}} {{- with $entry.Page -}} {{- if eq .Kind "taxonomy" }}{{ $taxonomyPage = . }}{{ end -}} @@ -28,7 +27,7 @@ {{- end -}} {{- end -}} {{- end -}} -{{- $hasPanel := or $hasChildren $taxonomyPage $hasVersionLinks -}} +{{- $hasPanel := or $hasChildren $taxonomyPage -}} {{- $key := $entry.Identifier | default $entry.Name | urlize -}} {{- $panelID := printf "td-navbar-%s-%s-%d" $mode $key $index -}} @@ -53,19 +52,6 @@ {{ partialCached "navbar-taxonomy-tags.html" (dict "page" $page "taxonomyPage" $taxonomyPage) $page.Site.Language.Lang $taxonomyPage.RelPermalink }} - {{- else if $hasVersionLinks -}} - {{- range $page.Site.Params.versions -}} - {{- $url := strings.TrimSuffix "/" (.url | default "") -}} - {{- if $url -}} - {{- $isActive := eq .version $page.Site.Params.version -}} - - - - - {{- end -}} - {{- end -}} {{- else -}} {{ partial "navbar-group-items.html" (dict "page" $page "items" $entry.Children "mode" "desktop" "top" $entry "depth" 1) }} {{- end }} diff --git a/layouts/_partials/navbar.html b/layouts/_partials/navbar.html index bfcfca10e..0f7d8ab88 100644 --- a/layouts/_partials/navbar.html +++ b/layouts/_partials/navbar.html @@ -53,6 +53,13 @@ {{- /* Right zone: the search box leads as the elastic boundary before the fixed controls — version, language, theme, GitHub. */ -}}+ {{- if $drawerMode }} + + {{- end }} {{- if $localSearch }}diff --git a/layouts/_partials/print/page-content.html b/layouts/_partials/print/page-content.html new file mode 100644 index 000000000..2234924f8 --- /dev/null +++ b/layouts/_partials/print/page-content.html @@ -0,0 +1,8 @@ +{{- $page := .page -}} +{{- $page.Store.Set "tdOutputFormat" "print" -}} +{{- if .book }}{{ $page.Store.Set "tdBookAggregate" true }}{{ end -}} +{{- $content := $page.RenderString (dict "display" "block") $page.RawContent -}} +{{- $content = partial "content/static-image-output.html" $content -}} +{{- partial "code/register-rendered-ids.html" (dict "page" $page "content" $content) -}} +{{- if .book }}{{ $page.Store.Delete "tdBookAggregate" }}{{ end -}} +{{- return (printf "%s" $content | safeHTML) -}} diff --git a/layouts/_partials/share/bar.html b/layouts/_partials/share/bar.html new file mode 100644 index 000000000..39e0820fa --- /dev/null +++ b/layouts/_partials/share/bar.html @@ -0,0 +1,13 @@ +{{- if and .IsPage (eq .Section "blog") -}} ++ +{{- end -}} diff --git a/layouts/_partials/shell/sidebar-panel.html b/layouts/_partials/shell/sidebar-panel.html index ae8692525..a433197ba 100644 --- a/layouts/_partials/shell/sidebar-panel.html +++ b/layouts/_partials/shell/sidebar-panel.html @@ -66,10 +66,10 @@ {{- range . }} {{- $url := strings.TrimSuffix "/" (.url | default "") -}} {{- if $url }} - - {{- partialCached "shell/icon.html" "code-branch" (printf "mobile-version-%s" .version) -}} - {{ .name | default .version }} - + {{- partial "version-link.html" (dict + "page" $ "version" . "active" (eq .version $.Site.Params.version) + "class" "" "iconKey" (printf "mobile-version-%s" .version) + ) -}} {{- end }} {{- end }} diff --git a/layouts/_partials/version-link.html b/layouts/_partials/version-link.html new file mode 100644 index 000000000..3b95544a8 --- /dev/null +++ b/layouts/_partials/version-link.html @@ -0,0 +1,15 @@ +{{- $p := .page -}} +{{- $version := .version -}} +{{- $versionID := $version.version | default ($version.name | urlize) -}} +{{- $target := partial "version-target.html" (dict "page" $p "version" $version) -}} + + {{- if ne .icon false -}} + {{- partialCached "shell/icon.html" "code-branch" .iconKey -}} + {{- end -}} + {{ $version.name | default $version.version | markdownify }} + diff --git a/layouts/_partials/version-options.html b/layouts/_partials/version-options.html new file mode 100644 index 000000000..e8c40362c --- /dev/null +++ b/layouts/_partials/version-options.html @@ -0,0 +1,22 @@ +{{- $p := . -}} +{{- $options := slice -}} +{{- $baseURL := strings.TrimSuffix "/" $p.Site.BaseURL -}} +{{- range $p.Site.Params.versions -}} + {{- if ne .name "---" -}} + {{- $versionID := .version | default (.name | urlize) -}} + {{- $rawURL := .url | default "" -}} + {{- $available := ne $rawURL "" -}} + {{- $target := partial "version-target.html" (dict "page" $p "version" .) -}} + {{- $options = $options | append (dict + "id" $versionID + "title" (.name | default .version | plainify) + "url" $target.url + "active" (or (eq .version $p.Site.Params.version) (eq $baseURL (strings.TrimSuffix "/" $rawURL))) + "available" $available + "disabledReason" (cond $available "" (printf "URL %s" (T "ui_field_required"))) + "equivalent" $target.equivalent + "fallback" $target.fallback + ) -}} + {{- end -}} +{{- end -}} +{{- return $options -}} diff --git a/layouts/_partials/version-target.html b/layouts/_partials/version-target.html new file mode 100644 index 000000000..63b0c7a95 --- /dev/null +++ b/layouts/_partials/version-target.html @@ -0,0 +1,63 @@ +{{- $p := .page -}} +{{- $version := .version -}} +{{- $versionID := $version.version | default ($version.name | urlize) -}} +{{- $rawURL := $version.url | default "" -}} +{{- $versionURL := "" -}} +{{- if $rawURL }}{{ $versionURL = printf "%s/" (strings.TrimSuffix "/" $rawURL) }}{{ end -}} +{{- $target := $versionURL -}} +{{- $equivalent := false -}} +{{- $fallback := false -}} +{{- $relative := strings.TrimPrefix "/" $p.RelPermalink -}} +{{- $locale := $p.Site.Language.Lang -}} +{{- $docsPrefix := cond (eq $locale "cn") "cn/docs/" "docs/" -}} +{{- $docsSuffix := cond (eq $locale "cn") "/cn/docs" "/docs" -}} +{{- $root := strings.TrimSuffix $docsSuffix (strings.TrimSuffix "/" $versionURL) -}} +{{- if and $rawURL (strings.HasPrefix $relative $docsPrefix) -}} + {{- $pages := hugo.Data.version_routes.pages | default dict -}} + {{- $currentVersion := $p.Site.Params.version | default "latest" -}} + {{- $logicalID := "" -}} + {{- range $candidateID, $candidateRoutes := $pages -}} + {{- if eq (index $candidateRoutes $currentVersion) $relative -}} + {{- $logicalID = $candidateID -}} + {{- end -}} + {{- end -}} + {{- $routes := cond (ne $logicalID "") (index $pages $logicalID) nil -}} + {{- $path := false -}} + {{- with $routes -}} + {{- $path = index . $versionID -}} + {{- end -}} + {{- if and (not $path) (ne $logicalID "") -}} + {{- $alternates := slice -}} + {{- range hugo.Data.version_routes.equivalents | default (slice) -}} + {{- if in . $logicalID -}} + {{- range . -}} + {{- if ne . $logicalID -}} + {{- $alternateRoutes := index $pages . -}} + {{- with $alternateRoutes -}} + {{- $alternatePath := index . $versionID -}} + {{- with $alternatePath -}} + {{- $alternates = $alternates | append . -}} + {{- end -}} + {{- end -}} + {{- end -}} + {{- end -}} + {{- end -}} + {{- end -}} + {{- if eq (len $alternates) 1 -}} + {{- $path = index $alternates 0 -}} + {{- end -}} + {{- end -}} + {{- if $path -}} + {{- $target = printf "%s/%s" $root $path -}} + {{- $equivalent = true -}} + {{- else -}} + {{- $docsRoot := cond (eq $locale "cn") "cn/docs/" "docs/" -}} + {{- $target = printf "%s/%s#hg-version-fallback" $root $docsRoot -}} + {{- $fallback = true -}} + {{- end -}} +{{- end -}} +{{- return (dict + "url" $target + "equivalent" $equivalent + "fallback" $fallback +) -}} diff --git a/layouts/landing.html b/layouts/landing.html new file mode 100644 index 000000000..87dec7159 --- /dev/null +++ b/layouts/landing.html @@ -0,0 +1,6 @@ +{{ define "main" -}} + +{{- $landing := partial "landing/data.html" . -}} +{{- partial "landing/render.html" (dict "page" . "data" $landing) -}} + +{{- end }} diff --git a/layouts/landing.print.html b/layouts/landing.print.html new file mode 100644 index 000000000..87dec7159 --- /dev/null +++ b/layouts/landing.print.html @@ -0,0 +1,6 @@ +{{ define "main" -}} + +{{- $landing := partial "landing/data.html" . -}} +{{- partial "landing/render.html" (dict "page" . "data" $landing) -}} + +{{- end }} diff --git a/scripts/hugo.sh b/scripts/hugo.sh new file mode 100755 index 000000000..caaa8adfd --- /dev/null +++ b/scripts/hugo.sh @@ -0,0 +1,162 @@ +#!/bin/sh +set -eu + +usage() { + printf '%s\n' \ + "Usage: scripts/hugo.sh server [Hugo arguments...]" \ + " scripts/hugo.sh build [Hugo arguments...]" +} + +if [ "$#" -eq 0 ]; then + usage >&2 + exit 2 +fi + +mode=$1 +shift +case "$mode" in + server|build) ;; + *) + usage >&2 + exit 2 + ;; +esac + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +repo_dir=$(dirname "$script_dir") +cd "$repo_dir" + +reject_argument() { + printf 'scripts/hugo.sh: argument is owned by the wrapper: %s\n' "$1" >&2 + exit 2 +} + +port= +base_url= +port_set=false +base_url_set=false +expect_value= +for argument in "$@"; do + if [ -n "$expect_value" ]; then + case "$argument" in + -*) printf 'scripts/hugo.sh: missing value for %s\n' "$expect_value" >&2; exit 2 ;; + esac + case "$expect_value" in + baseURL) base_url=$argument; base_url_set=true ;; + port) port=$argument; port_set=true ;; + esac + expect_value= + continue + fi + case "$argument" in + --config|--config=*|-c|-c?*|\ + --configDir|--configDir=*|\ + --environment|--environment=*|-e|-e?*|\ + --cleanDestinationDir|--cleanDestinationDir=*|\ + --gc|--gc=*|\ + --minify|--minify=*|\ + --panicOnWarning|--panicOnWarning=*|\ + --printPathWarnings|--printPathWarnings=*|\ + --printI18nWarnings|--printI18nWarnings=*|\ + --logLevel|--logLevel=*|\ + --appendPort|--appendPort=*) + reject_argument "$argument" + ;; + --baseURL|-b) expect_value=baseURL ;; + --baseURL=*) base_url=${argument#*=}; base_url_set=true ;; + -b=*) base_url=${argument#-b=}; base_url_set=true ;; + -b?*) base_url=${argument#-b}; base_url_set=true ;; + --port|-p) expect_value=port ;; + --port=*) port=${argument#*=}; port_set=true ;; + -p=*) port=${argument#-p=}; port_set=true ;; + -p?*) port=${argument#-p}; port_set=true ;; + esac +done +if [ -n "$expect_value" ]; then + printf 'scripts/hugo.sh: missing value for %s\n' "$expect_value" >&2 + exit 2 +fi +if [ "$base_url_set" = true ] && [ -z "$base_url" ]; then + printf '%s\n' "scripts/hugo.sh: --baseURL cannot be empty" >&2 + exit 2 +fi +if [ "$port_set" = true ] && [ -z "$port" ]; then + printf '%s\n' "scripts/hugo.sh: --port cannot be empty" >&2 + exit 2 +fi +if [ "$base_url_set" = true ] && [ -n "${HG_DOC_SITE_ORIGIN:-}" ]; then + printf '%s\n' \ + "scripts/hugo.sh: use either --baseURL or HG_DOC_SITE_ORIGIN, not both" >&2 + exit 2 +fi +if [ "$mode" = "build" ] && [ "$port_set" = true ]; then + printf '%s\n' "scripts/hugo.sh: --port is valid only in server mode" >&2 + exit 2 +fi +if [ -n "$port" ]; then + case "$port" in + *[!0-9]*) printf 'scripts/hugo.sh: invalid port: %s\n' "$port" >&2; exit 2 ;; + esac +fi + +if [ -n "${HG_DOC_SITE_ORIGIN:-}" ]; then + site_origin=$HG_DOC_SITE_ORIGIN +elif [ -n "$base_url" ]; then + site_origin=$base_url +elif [ "$mode" = "server" ]; then + site_origin="http://localhost:${port:-1313}/" +else + site_origin="https://hugegraph.apache.org/" +fi +case "$site_origin" in + http://*|https://*) ;; + *) printf 'scripts/hugo.sh: invalid site origin: %s\n' "$site_origin" >&2; exit 2 ;; +esac + +temp_dir=$(mktemp -d "${TMPDIR:-/tmp}/hugegraph-hugo.XXXXXX") +config_file=$temp_dir/version-config.json +cleanup() { + if [ -d "$temp_dir" ]; then + rm -f -- "$config_file" + rmdir -- "$temp_dir" + fi +} +trap cleanup 0 HUP INT TERM + +python_bin=${PYTHON_BIN:-python3} +hugo_bin=${HUGO_BIN:-hugo} +( + set -- scripts/versioning.py config \ + --site-origin "$site_origin" \ + --output "$config_file" + if [ -n "${HG_DOC_VERSION:-}" ]; then + set -- "$@" --version "$HG_DOC_VERSION" + fi + if [ -n "${HG_DOC_HISTORICAL_ORIGIN:-}" ]; then + set -- "$@" --historical-origin "$HG_DOC_HISTORICAL_ORIGIN" + fi + exec "$python_bin" "$@" +) + +if [ "$mode" = "server" ]; then + if [ -n "$base_url" ] || [ -n "${HG_DOC_SITE_ORIGIN:-}" ]; then + "$hugo_bin" server \ + --config "hugo.yaml,$config_file" \ + --appendPort=false \ + "$@" + else + "$hugo_bin" server --config "hugo.yaml,$config_file" "$@" + fi +else + "$hugo_bin" \ + --config "hugo.yaml,$config_file" \ + --cleanDestinationDir \ + --gc \ + --minify \ + --environment production \ + --printPathWarnings \ + --printI18nWarnings \ + --panicOnWarning \ + --logLevel info \ + "$@" +fi diff --git a/scripts/test_hugo_wrapper.py b/scripts/test_hugo_wrapper.py new file mode 100644 index 000000000..55fa664a0 --- /dev/null +++ b/scripts/test_hugo_wrapper.py @@ -0,0 +1,219 @@ +import json +import os +import pathlib +import stat +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +WRAPPER = ROOT / "scripts" / "hugo.sh" + + +class HugoWrapperTest(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory() + self.root = pathlib.Path(self.temp.name) + self.bin = self.root / "bin" + self.bin.mkdir() + self.log = self.root / "calls.jsonl" + self._write_executable( + "python3", + """#!/bin/sh +printf '{"tool":"python3","args":[' >> "$HG_WRAPPER_TEST_LOG" +first=1 +output= +previous= +for arg in "$@"; do + if [ "$first" -eq 0 ]; then printf ',' >> "$HG_WRAPPER_TEST_LOG"; fi + first=0 + printf '"%s"' "$arg" >> "$HG_WRAPPER_TEST_LOG" + if [ "$previous" = "--output" ]; then output=$arg; fi + previous=$arg +done +printf ']}\\n' >> "$HG_WRAPPER_TEST_LOG" +printf '%s\\n' '{"params":{"versions":[1,2,3,4,5]}}' > "$output" +""", + ) + self._write_executable( + "hugo", + """#!/bin/sh +printf '{"tool":"hugo","args":[' >> "$HG_WRAPPER_TEST_LOG" +first=1 +config= +previous= +for arg in "$@"; do + if [ "$first" -eq 0 ]; then printf ',' >> "$HG_WRAPPER_TEST_LOG"; fi + first=0 + printf '"%s"' "$arg" >> "$HG_WRAPPER_TEST_LOG" + if [ "$previous" = "--config" ]; then config=$arg; fi + previous=$arg +done +printf ']}\\n' >> "$HG_WRAPPER_TEST_LOG" +generated=${config#*,} +test -f "$generated" +grep -q '"versions":\\[1,2,3,4,5\\]' "$generated" +""", + ) + + def tearDown(self) -> None: + self.temp.cleanup() + + def _write_executable(self, name: str, source: str) -> None: + path = self.bin / name + path.write_text(source, encoding="utf-8") + path.chmod(path.stat().st_mode | stat.S_IXUSR) + + def invoke_wrapper( + self, *args: str, environment_overrides: dict[str, str] | None = None + ) -> subprocess.CompletedProcess[str]: + environment = os.environ.copy() + environment["PATH"] = f"{self.bin}{os.pathsep}{environment['PATH']}" + environment["HG_WRAPPER_TEST_LOG"] = str(self.log) + environment.pop("HG_DOC_VERSION", None) + environment.pop("HG_DOC_SITE_ORIGIN", None) + if environment_overrides: + environment.update(environment_overrides) + return subprocess.run( + [str(WRAPPER), *args], + cwd=ROOT, + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + def calls(self) -> list[dict]: + if not self.log.exists(): + return [] + return [ + json.loads(line) + for line in self.log.read_text(encoding="utf-8").splitlines() + ] + + def run_wrapper( + self, *args: str, environment_overrides: dict[str, str] | None = None + ) -> list[dict]: + result = self.invoke_wrapper( + *args, environment_overrides=environment_overrides + ) + self.assertEqual(result.returncode, 0, result.stderr) + return self.calls() + + def test_server_derives_manifest_config_and_safely_forwards_arguments(self) -> None: + calls = self.run_wrapper("server", "--port", "1414", "--bind", "127.0.0.1") + self.assertEqual(calls[0]["tool"], "python3") + self.assertEqual( + calls[0]["args"][0:2], ["scripts/versioning.py", "config"] + ) + self.assertNotIn("--version", calls[0]["args"]) + self.assertIn("--site-origin", calls[0]["args"]) + origin_index = calls[0]["args"].index("--site-origin") + 1 + self.assertEqual(calls[0]["args"][origin_index], "http://localhost:1414/") + self.assertEqual(calls[1]["args"][0], "server") + self.assertEqual( + calls[1]["args"][-4:], + ["--port", "1414", "--bind", "127.0.0.1"], + ) + + def test_explicit_version_is_forwarded_only_when_requested(self) -> None: + calls = self.run_wrapper( + "build", environment_overrides={"HG_DOC_VERSION": "1.7"} + ) + args = calls[0]["args"] + self.assertEqual(args[args.index("--version") + 1], "1.7") + + def test_base_url_and_port_keep_generated_and_hugo_origins_aligned(self) -> None: + calls = self.run_wrapper( + "server", + "--baseURL=https://preview.example/docs/", + "-p=1414", + ) + config_args = calls[0]["args"] + self.assertEqual( + config_args[config_args.index("--site-origin") + 1], + "https://preview.example/docs/", + ) + hugo_args = calls[1]["args"] + self.assertIn("--appendPort=false", hugo_args) + self.assertEqual( + hugo_args[-2:], + ["--baseURL=https://preview.example/docs/", "-p=1414"], + ) + + def test_owned_hugo_arguments_are_rejected_before_any_tool_runs(self) -> None: + cases = ( + ("build", "--config", "other.yaml"), + ("build", "--config=other.yaml"), + ("build", "-c", "other.yaml"), + ("build", "-cother.yaml"), + ("build", "--configDir", "config"), + ("build", "--environment", "development"), + ("build", "--environment=development"), + ("build", "-e", "development"), + ("build", "-edevelopment"), + ("build", "--panicOnWarning=false"), + ("build", "--cleanDestinationDir=false"), + ("build", "--gc=false"), + ("build", "--minify=false"), + ("build", "--printPathWarnings=false"), + ("build", "--printI18nWarnings=false"), + ("build", "--logLevel", "error"), + ("build", "--port", "1414"), + ("server", "--config", "other.yaml"), + ("server", "--appendPort=true"), + ("server", "--baseURL="), + ("server", "--port="), + ("server", "--baseURL", "file:///tmp/site"), + ) + for args in cases: + with self.subTest(args=args): + self.log.unlink(missing_ok=True) + result = self.invoke_wrapper(*args) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(self.calls(), []) + + def test_site_origin_environment_cannot_conflict_with_base_url(self) -> None: + result = self.invoke_wrapper( + "server", + "--baseURL", + "https://preview.example/", + environment_overrides={ + "HG_DOC_SITE_ORIGIN": "https://other.example/" + }, + ) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(self.calls(), []) + + def test_build_enforces_the_warning_strict_production_contract(self) -> None: + calls = self.run_wrapper("build", "--destination", "custom-public") + args = calls[1]["args"] + self.assertNotIn("build", args) + for required in ( + "--cleanDestinationDir", + "--gc", + "--minify", + "--environment", + "production", + "--printPathWarnings", + "--printI18nWarnings", + "--panicOnWarning", + ): + self.assertIn(required, args) + self.assertEqual(args[-2:], ["--destination", "custom-public"]) + + def test_documented_preview_and_build_use_the_wrapper(self) -> None: + for relative in ("README.md", "contribution.md"): + text = (ROOT / relative).read_text(encoding="utf-8") + self.assertNotIn("hugo server", text) + readme = (ROOT / "README.md").read_text(encoding="utf-8") + contribution = (ROOT / "contribution.md").read_text(encoding="utf-8") + self.assertGreaterEqual(readme.count("scripts/hugo.sh server"), 4) + self.assertGreaterEqual(readme.count("scripts/hugo.sh build"), 2) + self.assertIn("scripts/hugo.sh server", contribution) + self.assertIn("scripts/hugo.sh build", contribution) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_validate_site_output.py b/scripts/test_validate_site_output.py index 55348d369..7a71ae696 100644 --- a/scripts/test_validate_site_output.py +++ b/scripts/test_validate_site_output.py @@ -8,6 +8,7 @@ from __future__ import annotations import importlib.util +import json import pathlib import subprocess import sys @@ -150,6 +151,98 @@ def test_error_document_seo_rejects_indexing_and_url_claims(self) -> None: ], ) + def test_error_document_paths_follow_all_manifest_versions(self) -> None: + with tempfile.TemporaryDirectory() as temp_name: + root = pathlib.Path(temp_name) + manifest = { + "versions": [ + {"id": "latest", "publishPath": ""}, + {"id": "1.7", "publishPath": "versions/1.7"}, + {"id": "1.5", "publishPath": "versions/1.5"}, + {"id": "1.3", "publishPath": "versions/1.3"}, + {"id": "1.0", "publishPath": "versions/1.0"}, + ] + } + metadata = root / "build-metadata" + metadata.mkdir() + (metadata / "versions.json").write_text( + json.dumps(manifest), encoding="utf-8" + ) + + self.assertEqual( + VALIDATOR.error_document_paths(root), + { + "404.html", + "cn/404.html", + "versions/1.7/404.html", + "versions/1.7/cn/404.html", + "versions/1.5/404.html", + "versions/1.5/cn/404.html", + "versions/1.3/404.html", + "versions/1.3/cn/404.html", + "versions/1.0/404.html", + "versions/1.0/cn/404.html", + }, + ) + + def test_all_modes_reject_old_version_error_page_seo(self) -> None: + with tempfile.TemporaryDirectory() as temp_name: + root = pathlib.Path(temp_name) + manifest = { + "versions": [ + {"id": "latest", "publishPath": ""}, + {"id": "1.3", "publishPath": "versions/1.3"}, + {"id": "1.0", "publishPath": "versions/1.0"}, + ] + } + metadata = root / "build-metadata" + metadata.mkdir() + (metadata / "versions.json").write_text( + json.dumps(manifest), encoding="utf-8" + ) + unsafe = ( + '' + '' + '' + ) + for version in ("1.3", "1.0"): + for language in ("", "cn/"): + page = root / f"versions/{version}/{language}404.html" + page.parent.mkdir(parents=True, exist_ok=True) + page.write_text(unsafe, encoding="utf-8") + + for extra_args in (["--security-only"], []): + result = subprocess.run( + [ + sys.executable, + str(VALIDATOR_PATH), + str(root), + "https://hugegraph.apache.org/", + *extra_args, + ], + check=False, + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + for version in ("1.3", "1.0"): + for language in ("", "cn/"): + page_name = f"versions/{version}/{language}404.html" + with self.subTest(mode=extra_args, page_name=page_name): + self.assertIn( + f"{page_name}: expected one robots noindex,nofollow", + result.stdout, + ) + self.assertIn( + f"{page_name}: error document must not declare canonical", + result.stdout, + ) + self.assertIn( + f"{page_name}: error document must not declare hreflang", + result.stdout, + ) + def test_nested_content_404_is_not_an_error_document_exception(self) -> None: parser = parse( '' @@ -264,6 +357,30 @@ def test_active_markup_and_event_handlers_inside_content_are_rejected(self) -> N ], ) + def test_duplicate_attributes_are_rejected_case_insensitively(self) -> None: + parser = parse( + '+ ++' + '" + ) + self.assertEqual( + parser.authored_violations, + [ + "duplicate src attribute on
", + "duplicate xlink:href attribute on