#!/usr/bin/env python3 import argparse import hashlib import html import json import re import sys from dataclasses import dataclass from pathlib import Path from typing import Dict, List, Tuple import yaml HTTP_METHODS = ["get", "post", "put", "patch", "delete", "options", "head", "trace"] AUTOGEN_NOTE = "AUTO-GENERATED, DO NOT EDIT" TOC_START_MARKER = "" TOC_END_MARKER = "" OPS_PER_PAGE = 20 MODULE_DESCRIPTIONS: Dict[str, Tuple[str, str]] = { "action-logs": ("Action Logs", "Audit and operational logs for integration module activity."), "backup": ("Backup", "Backup integrations and backup module orchestration."), "cvr": ("CVR", "Danish company registry (CVR) lookup and search integration."), "economic": ("e-conomic", "Accounting and invoicing integration with e-conomic."), "entra": ("Entra", "Microsoft Entra directory integration endpoints."), "fxratesapi": ("FXRatesAPI", "Currency exchange-rate lookup integration."), "motorapi": ("MotorAPI", "Vehicle lookup integration via MotorAPI."), "self-serve": ("Self-Serve", "Self-serve lane control and machine command endpoints."), "stripe": ("Stripe", "Stripe payments, invoices, terminals, products, and customers."), "virkdata": ("VirkData", "VirkData company information integration."), "washcertificates": ("Wash Certificates", "Wash certificate retrieval and listing integration."), "weatherapi": ("WeatherAPI", "Weather provider integration for current, forecast, and search."), "xlvask": ("XLVask", "XLVask synchronization, usage logs, vehicles, and customers."), } CONFIG_DESCRIPTIONS: Dict[str, Tuple[str, str]] = { "backups": ("Backups", "Backup configuration for backup module behavior."), "bird": ("Bird", "Bird communication integration configuration."), "economic": ("e-conomic", "e-conomic accounting integration configuration."), "email": ("Email", "Email provider and SMTP/MailerSend configuration."), "entra": ("Entra", "Microsoft Entra identity integration configuration."), "fxratesapi": ("FXRatesAPI", "FXRatesAPI exchange-rate integration configuration."), "gatewayapi": ("GatewayAPI", "GatewayAPI integration configuration."), "licenseplaterecognizer": ("LicensePlateRecognizer", "License plate recognizer integration configuration."), "limble": ("Limble", "Limble integration configuration."), "motorapi": ("MotorAPI", "MotorAPI vehicle lookup integration configuration."), "ocrspace": ("OcrSpace", "OCR Space integration configuration."), "openai": ("OpenAI", "OpenAI integration configuration."), "reCAPTCHA": ("reCAPTCHA", "reCAPTCHA protection configuration."), "selfserve": ("Self-Serve", "Self-serve module runtime configuration."), "shelly": ("Shelly", "Shelly integration configuration."), "stripe": ("Stripe", "Stripe integration configuration."), "virkdata": ("VirkData", "VirkData integration configuration."), "weatherapi": ("WeatherAPI", "WeatherAPI integration configuration."), "xlvask": ("XLVask", "XLVask integration configuration."), } @dataclass(frozen=True) class Operation: method: str path: str title: str topic_id: str topic_file: str primary_tag: str operation_id: str description: str parameters: List[dict] request_body: dict responses: dict security: List[dict] module_key: str module_name: str module_description: str config_key: str config_name: str config_description: str def slugify(value: str) -> str: slug = re.sub(r"[^A-Za-z0-9_]+", "_", value.strip()) slug = re.sub(r"_+", "_", slug).strip("_") if not slug: slug = "unnamed" if not re.match(r"^[A-Za-z_]", slug): slug = f"id_{slug}" return slug def safe_token(value: str) -> str: return slugify(value).lower() def build_operation_topic_id(operation_id: str, method: str, path: str) -> str: base = slugify(operation_id) if len(base) > 110: digest = hashlib.sha1(f"{method}:{path}:{operation_id}".encode("utf-8")).hexdigest()[:8] base = f"{base[:100]}_{digest}" return base def schema_type_name(schema: dict) -> str: if not isinstance(schema, dict): return "unknown" if "$ref" in schema: ref = str(schema["$ref"]) return ref.rsplit("/", 1)[-1] if "type" in schema: t = str(schema["type"]) if t == "array" and isinstance(schema.get("items"), dict): return f"array<{schema_type_name(schema['items'])}>" return t if "oneOf" in schema: return "oneOf" if "anyOf" in schema: return "anyOf" if "allOf" in schema: return "allOf" return "object" def xml_escape(value: str) -> str: return html.escape(value or "", quote=True) def pretty_json(value: dict) -> str: return html.escape(json.dumps(value, indent=2, ensure_ascii=False, sort_keys=True)) def render_parameters_table(parameters: List[dict]) -> str: if not parameters: return "" rows = [ " ", " ", " ", ] for param in parameters: name = xml_escape(str(param.get("name", ""))) loc = xml_escape(str(param.get("in", ""))) required = "yes" if param.get("required") else "no" ptype = xml_escape(schema_type_name(param.get("schema", {}))) desc = xml_escape(str(param.get("description", ""))) rows.append( f" " ) rows.append("
NameInRequiredTypeDescription
{name}{loc}{required}{ptype}{desc}
") rows.append("
") return "\n".join(rows) + "\n" def render_request_body(request_body: dict) -> str: if not isinstance(request_body, dict) or not request_body: return "" lines = [" "] lines.append(f"

Required: {'yes' if request_body.get('required') else 'no'}.

") content = request_body.get("content", {}) if isinstance(content, dict) and content: for content_type, media in content.items(): lines.append(f"

Content type: {xml_escape(str(content_type))}

") schema = media.get("schema") if isinstance(media, dict) else None if isinstance(schema, dict): lines.append(" ") lines.append(pretty_json(schema)) lines.append(" ") lines.append("
") return "\n".join(lines) + "\n" def render_responses(responses: dict) -> str: if not isinstance(responses, dict) or not responses: return "" lines = [ " ", " ", " ", ] for status_code, response in responses.items(): if not isinstance(response, dict): continue description = xml_escape(str(response.get("description", ""))) content = response.get("content", {}) if isinstance(content, dict): content_types = ", ".join(sorted(str(k) for k in content.keys())) else: content_types = "" lines.append( f" " ) lines.append("
StatusDescriptionContent Types
{xml_escape(str(status_code))}{description}{xml_escape(content_types)}
") for status_code, response in responses.items(): if not isinstance(response, dict): continue content = response.get("content", {}) if not isinstance(content, dict): continue for content_type, media in content.items(): schema = media.get("schema") if isinstance(media, dict) else None if not isinstance(schema, dict): continue lines.append( f"

Schema for response {xml_escape(str(status_code))} ({xml_escape(str(content_type))}):

" ) lines.append(" ") lines.append(pretty_json(schema)) lines.append(" ") lines.append("
") return "\n".join(lines) + "\n" def render_security(security: List[dict]) -> str: if not security: return "

No authentication required.

\n" lines = [ " ", "

Security requirements:

", " ", " ", ] for req in security: if not isinstance(req, dict): continue for scheme, scopes in req.items(): if isinstance(scopes, list): scope_text = ", ".join(str(s) for s in scopes) if scopes else "-" else: scope_text = "-" lines.append( f" " ) lines.extend(["
SchemeScopes
{xml_escape(str(scheme))}{xml_escape(scope_text)}
", "
"]) return "\n".join(lines) + "\n" def render_operation_topic(operation: Operation) -> str: title = html.escape(operation.title, quote=True) endpoint = xml_escape(operation.path) method = operation.method.upper() topic_id = html.escape(operation.topic_id, quote=True) description = xml_escape(operation.description) operation_id = xml_escape(operation.operation_id) parameter_block = render_parameters_table(operation.parameters) request_block = render_request_body(operation.request_body) response_block = render_responses(operation.responses) security_block = render_security(operation.security) return ( '\n' '\n' '\n' f"\n \n" "

This endpoint documentation is generated directly from openapi.yaml.

\n" " \n" f" {method} {endpoint}\n" " \n" " \n" f"

Operation ID: {operation_id}

\n" f"

{description}

\n" "
\n" f"{security_block}" f"{parameter_block}" f"{request_block}" f"{response_block}" "
\n" ) def render_tag_topic(tag: str, topic_id: str) -> str: title = html.escape(tag, quote=True) safe_id = html.escape(topic_id, quote=True) return ( '\n' '\n' '\n' f"\n \n" "

Endpoints in this section are generated from openapi.yaml.

\n" "
\n" ) def render_tag_page_topic(tag: str, topic_id: str, page_number: int, total_pages: int) -> str: title = html.escape(f"{tag} - Page {page_number} of {total_pages}", quote=True) safe_id = html.escape(topic_id, quote=True) return ( '\n' '\n' '\n' f"\n \n" "

This page groups endpoint topics for this object type.

\n" "
\n" ) def render_module_topic(module_name: str, module_description: str, topic_id: str) -> str: title = html.escape(module_name, quote=True) safe_id = html.escape(topic_id, quote=True) description = xml_escape(module_description) return ( '\n' '\n' '\n' f"\n \n" f"

{description}

\n" "
\n" ) def render_modules_index_topic(topic_id: str, modules: List[Tuple[str, str]]) -> str: safe_id = html.escape(topic_id, quote=True) rows = [ '', "', '', "", f" ", "

Module integrations sorted by module name.

", " ", " ", " ", ] for module_name, module_description in modules: rows.append( f" " ) rows.extend(["
ModuleDescription
{xml_escape(module_name)}{xml_escape(module_description)}
", "
", "
", ""]) return "\n".join(rows) def render_config_index_topic(topic_id: str, modules: List[Tuple[str, str]]) -> str: safe_id = html.escape(topic_id, quote=True) rows = [ '', "', '', "", f" ", "

Configuration endpoints grouped by module name.

", " ", " ", " ", ] for module_name, module_description in modules: rows.append( f" " ) rows.extend(["
ModuleDescription
{xml_escape(module_name)}{xml_escape(module_description)}
", "
", "
", ""]) return "\n".join(rows) def infer_module(path: str) -> Tuple[str, str, str]: segments = [segment for segment in path.split("/") if segment] module_key = "misc" if segments: if segments[0] == "modules" and len(segments) > 1: module_key = segments[1] elif segments[0] in {"economic", "cvr"}: module_key = segments[0] if module_key in MODULE_DESCRIPTIONS: module_name, module_description = MODULE_DESCRIPTIONS[module_key] else: module_name = module_key.replace("-", " ").title() module_description = "Integration module endpoints." return module_key, module_name, module_description def infer_config_module(path: str) -> Tuple[str, str, str]: segments = [segment for segment in path.split("/") if segment] config_key = segments[0] if segments else "misc" if config_key in CONFIG_DESCRIPTIONS: config_name, config_description = CONFIG_DESCRIPTIONS[config_key] else: config_name = config_key.replace("-", " ").title() config_description = "Configuration endpoints for this module." return config_key, config_name, config_description def render_api_reference_topic() -> str: return ( '\n' '\n' '\n' f"\n \n" "

Comprehensive API reference generated from the repository root openapi.yaml.

\n" "
\n" ) def write_if_changed(path: Path, content: str) -> bool: if path.exists() and path.read_text(encoding="utf-8") == content: return False path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content, encoding="utf-8") return True def parse_openapi(openapi_path: Path) -> Tuple[List[str], List[Operation], Dict[str, List[Operation]]]: doc = yaml.safe_load(openapi_path.read_text(encoding="utf-8")) global_security = doc.get("security", []) tags_section = doc.get("tags", []) declared_tags = [] for tag_item in tags_section: if isinstance(tag_item, dict) and isinstance(tag_item.get("name"), str): declared_tags.append(tag_item["name"]) declared_tag_set = set(declared_tags) paths = doc.get("paths", {}) if not isinstance(paths, dict): raise ValueError("Invalid OpenAPI: top-level 'paths' must be an object.") operations: List[Operation] = [] grouped: Dict[str, List[Operation]] = {} seen_topic_ids: Dict[str, str] = {} unknown_tags: List[str] = [] for api_path, path_item in paths.items(): if not isinstance(path_item, dict): continue path_parameters = path_item.get("parameters", []) for method in HTTP_METHODS: operation = path_item.get(method) if not isinstance(operation, dict): continue operation_tags = operation.get("tags") if isinstance(operation_tags, list) and operation_tags: tags = [str(tag) for tag in operation_tags] else: tags = ["Misc"] for tag in tags: if tag != "Misc" and tag not in declared_tag_set: unknown_tags.append(f"{method.upper()} {api_path} => '{tag}'") primary_tag = tags[0] operation_id = operation.get("operationId") if not isinstance(operation_id, str) or not operation_id.strip(): operation_id = f"{method}_{api_path}" summary = operation.get("summary") if not isinstance(summary, str) or not summary.strip(): summary = f"{method.upper()} {api_path}" description = operation.get("description") if not isinstance(description, str) or not description.strip(): description = summary topic_id = build_operation_topic_id(operation_id, method, api_path) existing = seen_topic_ids.get(topic_id) operation_ref = f"{method.upper()} {api_path}" if existing and existing != operation_ref: raise ValueError( f"Duplicate topic id '{topic_id}' generated for '{existing}' and '{operation_ref}'." ) seen_topic_ids[topic_id] = operation_ref topic_file = f"{topic_id}.topic" operation_parameters = operation.get("parameters", []) merged_parameters: List[dict] = [] seen_params = set() for param_source in [path_parameters, operation_parameters]: if not isinstance(param_source, list): continue for param in param_source: if not isinstance(param, dict): continue key = (str(param.get("in", "")), str(param.get("name", ""))) if key in seen_params: merged_parameters = [ p for p in merged_parameters if (str(p.get("in", "")), str(p.get("name", ""))) != key ] seen_params.add(key) merged_parameters.append(param) module_key, module_name, module_description = infer_module(api_path) config_key, config_name, config_description = infer_config_module(api_path) op = Operation( method=method, path=api_path, title=summary, topic_id=topic_id, topic_file=topic_file, primary_tag=primary_tag, operation_id=operation_id, description=description, parameters=merged_parameters, request_body=operation.get("requestBody", {}), responses=operation.get("responses", {}), security=operation.get("security", global_security), module_key=module_key, module_name=module_name, module_description=module_description, config_key=config_key, config_name=config_name, config_description=config_description, ) operations.append(op) grouped.setdefault(primary_tag, []).append(op) if unknown_tags: raise ValueError( "Invalid tag mapping: operation tags not declared in top-level OpenAPI tags:\n" + "\n".join(sorted(unknown_tags)) ) for tag_ops in grouped.values(): tag_ops.sort(key=lambda op: (op.path, op.method)) ordered_tags = [tag for tag in declared_tags if tag in grouped] if "Misc" in grouped and "Misc" not in ordered_tags: ordered_tags.append("Misc") for extra in sorted(tag for tag in grouped if tag not in ordered_tags): ordered_tags.append(extra) return ordered_tags, operations, grouped def render_generated_toc(ordered_tags: List[str], grouped: Dict[str, List[Operation]]) -> str: lines: List[str] = [] lines.append(' ') for tag in ordered_tags: tag_topic_file = f"Tag_{slugify(tag)}.topic" lines.append(f' ') tag_ops = grouped.get(tag, []) if tag == "Modules": module_groups: Dict[str, List[Operation]] = {} module_meta: Dict[str, Tuple[str, str]] = {} for op in tag_ops: module_groups.setdefault(op.module_key, []).append(op) module_meta[op.module_key] = (op.module_name, op.module_description) for module_key in sorted(module_groups, key=lambda key: module_meta[key][0].lower()): module_name, _ = module_meta[module_key] module_slug = safe_token(module_name) module_topic = f"modules_module_{module_slug}.topic" lines.append(f' ') module_ops = sorted(module_groups[module_key], key=lambda op: (op.module_name.lower(), op.title.lower(), op.path, op.method)) total_pages = max(1, (len(module_ops) + OPS_PER_PAGE - 1) // OPS_PER_PAGE) for page_index in range(total_pages): start = page_index * OPS_PER_PAGE end = start + OPS_PER_PAGE page_ops = module_ops[start:end] page_number = page_index + 1 page_topic = f"modules_module_{module_slug}_page_{page_number}.topic" lines.append(f' ') for op in page_ops: lines.append(f' ') lines.append(" ") lines.append(" ") lines.append(" ") continue if tag == "Config": config_groups: Dict[str, List[Operation]] = {} config_meta: Dict[str, Tuple[str, str]] = {} for op in tag_ops: config_groups.setdefault(op.config_key, []).append(op) config_meta[op.config_key] = (op.config_name, op.config_description) for config_key in sorted(config_groups, key=lambda key: config_meta[key][0].lower()): config_name, _ = config_meta[config_key] config_slug = safe_token(config_name) config_topic = f"config_module_{config_slug}.topic" lines.append(f' ') config_ops = sorted(config_groups[config_key], key=lambda op: (op.title.lower(), op.path, op.method)) total_pages = max(1, (len(config_ops) + OPS_PER_PAGE - 1) // OPS_PER_PAGE) for page_index in range(total_pages): start = page_index * OPS_PER_PAGE end = start + OPS_PER_PAGE page_ops = config_ops[start:end] page_number = page_index + 1 page_topic = f"config_module_{config_slug}_page_{page_number}.topic" lines.append(f' ') for op in page_ops: lines.append(f' ') lines.append(" ") lines.append(" ") lines.append(" ") continue total_pages = max(1, (len(tag_ops) + OPS_PER_PAGE - 1) // OPS_PER_PAGE) for page_index in range(total_pages): start = page_index * OPS_PER_PAGE end = start + OPS_PER_PAGE page_ops = tag_ops[start:end] page_number = page_index + 1 page_topic = f"Tag_{slugify(tag)}_Page_{page_number}.topic" lines.append(f' ') for op in page_ops: lines.append(f' ') lines.append(" ") lines.append(" ") lines.append(" ") return "\n".join(lines) + "\n" def apply_toc_block(ctw_tree_content: str, generated_toc: str) -> str: block = f" {TOC_START_MARKER}\n{generated_toc.rstrip()}\n {TOC_END_MARKER}\n" marker_pattern = re.compile( rf"(?ms)^[ \t]*{re.escape(TOC_START_MARKER)}\r?\n.*?^[ \t]*{re.escape(TOC_END_MARKER)}\r?\n?" ) if marker_pattern.search(ctw_tree_content): return marker_pattern.sub(block, ctw_tree_content, count=1) close_tag = "" close_index = ctw_tree_content.rfind(close_tag) if close_index == -1: raise ValueError("Unable to update ctw.tree: missing .") left = ctw_tree_content[:close_index].rstrip() + "\n\n" right = ctw_tree_content[close_index:] return left + block + right def build_expected_outputs(root: Path) -> Dict[Path, str]: documentation_dir = root / "documentation" openapi_path = root / "openapi.yaml" ctw_tree_path = documentation_dir / "ctw.tree" api_reference_topic_path = documentation_dir / "topics" / "API-Reference.topic" generated_dir = documentation_dir / "topics" / "generated" generated_toc_path = documentation_dir / "generated" / "api-reference.toc.xml" openapi_doc = yaml.safe_load(openapi_path.read_text(encoding="utf-8")) ordered_tags, operations, grouped = parse_openapi(openapi_path) generated_toc = render_generated_toc(ordered_tags, grouped) expected: Dict[Path, str] = {} expected[generated_toc_path] = generated_toc expected[documentation_dir / "generated" / "openapi.json"] = json.dumps( openapi_doc, indent=2, ensure_ascii=False, sort_keys=False ) + "\n" expected[api_reference_topic_path] = render_api_reference_topic() existing_tree = ctw_tree_path.read_text(encoding="utf-8") expected[ctw_tree_path] = apply_toc_block(existing_tree, generated_toc) for tag in ordered_tags: tag_slug = slugify(tag) tag_topic_file = generated_dir / f"Tag_{tag_slug}.topic" tag_ops = grouped[tag] if tag == "Modules": module_groups: Dict[str, List[Operation]] = {} module_meta: Dict[str, Tuple[str, str]] = {} for op in tag_ops: module_groups.setdefault(op.module_key, []).append(op) module_meta[op.module_key] = (op.module_name, op.module_description) module_list = [ module_meta[module_key] for module_key in sorted(module_groups, key=lambda key: module_meta[key][0].lower()) ] expected[tag_topic_file] = render_modules_index_topic(f"Tag_{tag_slug}", module_list) for module_key in sorted(module_groups, key=lambda key: module_meta[key][0].lower()): module_name, module_description = module_meta[module_key] module_slug = safe_token(module_name) module_topic_file = generated_dir / f"modules_module_{module_slug}.topic" expected[module_topic_file] = render_module_topic( module_name=module_name, module_description=module_description, topic_id=f"modules_module_{module_slug}", ) module_ops = sorted(module_groups[module_key], key=lambda op: (op.module_name.lower(), op.title.lower(), op.path, op.method)) total_pages = max(1, (len(module_ops) + OPS_PER_PAGE - 1) // OPS_PER_PAGE) for page_index in range(total_pages): page_number = page_index + 1 page_topic_file = generated_dir / f"modules_module_{module_slug}_page_{page_number}.topic" expected[page_topic_file] = render_tag_page_topic( tag=module_name, topic_id=f"modules_module_{module_slug}_page_{page_number}", page_number=page_number, total_pages=total_pages, ) for op in module_ops: expected[generated_dir / op.topic_file] = render_operation_topic(op) continue if tag == "Config": config_groups: Dict[str, List[Operation]] = {} config_meta: Dict[str, Tuple[str, str]] = {} for op in tag_ops: config_groups.setdefault(op.config_key, []).append(op) config_meta[op.config_key] = (op.config_name, op.config_description) config_list = [ config_meta[config_key] for config_key in sorted(config_groups, key=lambda key: config_meta[key][0].lower()) ] expected[tag_topic_file] = render_config_index_topic(f"Tag_{tag_slug}", config_list) for config_key in sorted(config_groups, key=lambda key: config_meta[key][0].lower()): config_name, config_description = config_meta[config_key] config_slug = safe_token(config_name) config_topic_file = generated_dir / f"config_module_{config_slug}.topic" expected[config_topic_file] = render_module_topic( module_name=config_name, module_description=config_description, topic_id=f"config_module_{config_slug}", ) config_ops = sorted(config_groups[config_key], key=lambda op: (op.title.lower(), op.path, op.method)) total_pages = max(1, (len(config_ops) + OPS_PER_PAGE - 1) // OPS_PER_PAGE) for page_index in range(total_pages): page_number = page_index + 1 page_topic_file = generated_dir / f"config_module_{config_slug}_page_{page_number}.topic" expected[page_topic_file] = render_tag_page_topic( tag=config_name, topic_id=f"config_module_{config_slug}_page_{page_number}", page_number=page_number, total_pages=total_pages, ) for op in config_ops: expected[generated_dir / op.topic_file] = render_operation_topic(op) continue expected[tag_topic_file] = render_tag_topic(tag, f"Tag_{tag_slug}") total_pages = max(1, (len(tag_ops) + OPS_PER_PAGE - 1) // OPS_PER_PAGE) for page_index in range(total_pages): page_number = page_index + 1 page_topic_file = generated_dir / f"Tag_{tag_slug}_Page_{page_number}.topic" expected[page_topic_file] = render_tag_page_topic( tag=tag, topic_id=f"Tag_{tag_slug}_Page_{page_number}", page_number=page_number, total_pages=total_pages, ) for op in tag_ops: expected[generated_dir / op.topic_file] = render_operation_topic(op) operation_topic_names = {f"{op.topic_id}.topic" for op in operations} operation_topics = [ path for path in expected if path.parent == generated_dir and path.name in operation_topic_names ] if len(operation_topics) != len(operations): raise ValueError( f"Coverage mismatch: expected {len(operations)} operation topics, built {len(operation_topics)} files." ) return expected def generate(root: Path) -> None: expected = build_expected_outputs(root) generated_dir = root / "documentation" / "topics" / "generated" generated_dir.mkdir(parents=True, exist_ok=True) expected_generated_paths = { path for path in expected if path.parent == generated_dir and path.suffix == ".topic" } for existing_file in generated_dir.glob("*.topic"): if existing_file not in expected_generated_paths: existing_file.unlink() changed_files = 0 for path, content in expected.items(): if write_if_changed(path, content): changed_files += 1 print(f"Generated documentation artifacts. Updated files: {changed_files}") def check(root: Path) -> None: expected = build_expected_outputs(root) generated_dir = root / "documentation" / "topics" / "generated" failures: List[str] = [] for path, content in expected.items(): if not path.exists(): failures.append(f"Missing file: {path}") continue current = path.read_text(encoding="utf-8") if current != content: failures.append(f"Outdated file: {path}") if generated_dir.exists(): expected_generated_paths = { path for path in expected if path.parent == generated_dir and path.suffix == ".topic" } for existing_file in generated_dir.glob("*.topic"): if existing_file not in expected_generated_paths: failures.append(f"Stale generated topic: {existing_file}") if failures: print("Documentation check failed:") for failure in failures: print(f"- {failure}") print("Run: python scripts/generate_writerside_openapi_docs.py generate") raise SystemExit(1) print("Documentation check passed.") def main() -> None: parser = argparse.ArgumentParser(description="Generate Writerside API docs from openapi.yaml.") parser.add_argument("command", choices=["generate", "check"], help="Generation mode") args = parser.parse_args() root = Path(__file__).resolve().parents[1] try: if args.command == "generate": generate(root) else: check(root) except ValueError as exc: print(str(exc), file=sys.stderr) raise SystemExit(1) if __name__ == "__main__": main()