Files
api/scripts/generate_writerside_openapi_docs.py

840 lines
36 KiB
Python

#!/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 = "<!-- AUTO-GENERATED API TOC START -->"
TOC_END_MARKER = "<!-- AUTO-GENERATED API TOC END -->"
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 = [
" <chapter title=\"Parameters\" id=\"parameters\">",
" <table>",
" <tr><td>Name</td><td>In</td><td>Required</td><td>Type</td><td>Description</td></tr>",
]
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" <tr><td>{name}</td><td>{loc}</td><td>{required}</td><td>{ptype}</td><td>{desc}</td></tr>"
)
rows.append(" </table>")
rows.append(" </chapter>")
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 = [" <chapter title=\"Request Body\" id=\"request-body\">"]
lines.append(f" <p>Required: {'yes' if request_body.get('required') else 'no'}.</p>")
content = request_body.get("content", {})
if isinstance(content, dict) and content:
for content_type, media in content.items():
lines.append(f" <p>Content type: <code>{xml_escape(str(content_type))}</code></p>")
schema = media.get("schema") if isinstance(media, dict) else None
if isinstance(schema, dict):
lines.append(" <code-block lang=\"json\">")
lines.append(pretty_json(schema))
lines.append(" </code-block>")
lines.append(" </chapter>")
return "\n".join(lines) + "\n"
def render_responses(responses: dict) -> str:
if not isinstance(responses, dict) or not responses:
return ""
lines = [
" <chapter title=\"Responses\" id=\"responses\">",
" <table>",
" <tr><td>Status</td><td>Description</td><td>Content Types</td></tr>",
]
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" <tr><td>{xml_escape(str(status_code))}</td><td>{description}</td><td>{xml_escape(content_types)}</td></tr>"
)
lines.append(" </table>")
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" <p>Schema for response <code>{xml_escape(str(status_code))}</code> (<code>{xml_escape(str(content_type))}</code>):</p>"
)
lines.append(" <code-block lang=\"json\">")
lines.append(pretty_json(schema))
lines.append(" </code-block>")
lines.append(" </chapter>")
return "\n".join(lines) + "\n"
def render_security(security: List[dict]) -> str:
if not security:
return " <chapter title=\"Authentication\" id=\"authentication\"><p>No authentication required.</p></chapter>\n"
lines = [
" <chapter title=\"Authentication\" id=\"authentication\">",
" <p>Security requirements:</p>",
" <table>",
" <tr><td>Scheme</td><td>Scopes</td></tr>",
]
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" <tr><td>{xml_escape(str(scheme))}</td><td>{xml_escape(scope_text)}</td></tr>"
)
lines.extend([" </table>", " </chapter>"])
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 (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<!DOCTYPE topic\n'
' SYSTEM "https://resources.jetbrains.com/writerside/1.0/xhtml-entities.dtd">\n'
'<topic xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"\n'
' xsi:noNamespaceSchemaLocation="https://resources.jetbrains.com/writerside/1.0/topic.v2.xsd"\n'
f' title="{title}" id="{topic_id}">\n'
f"\n <!-- {AUTOGEN_NOTE} -->\n"
" <p>This endpoint documentation is generated directly from <code>openapi.yaml</code>.</p>\n"
" <chapter title=\"Endpoint\" id=\"endpoint\">\n"
f" <code-block lang=\"http\">{method} {endpoint}</code-block>\n"
" </chapter>\n"
" <chapter title=\"Operation\" id=\"operation\">\n"
f" <p>Operation ID: <code>{operation_id}</code></p>\n"
f" <p>{description}</p>\n"
" </chapter>\n"
f"{security_block}"
f"{parameter_block}"
f"{request_block}"
f"{response_block}"
"</topic>\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 (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<!DOCTYPE topic\n'
' SYSTEM "https://resources.jetbrains.com/writerside/1.0/xhtml-entities.dtd">\n'
'<topic xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"\n'
' xsi:noNamespaceSchemaLocation="https://resources.jetbrains.com/writerside/1.0/topic.v2.xsd"\n'
f' title="{title}" id="{safe_id}">\n'
f"\n <!-- {AUTOGEN_NOTE} -->\n"
" <p>Endpoints in this section are generated from <code>openapi.yaml</code>.</p>\n"
"</topic>\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 (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<!DOCTYPE topic\n'
' SYSTEM "https://resources.jetbrains.com/writerside/1.0/xhtml-entities.dtd">\n'
'<topic xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"\n'
' xsi:noNamespaceSchemaLocation="https://resources.jetbrains.com/writerside/1.0/topic.v2.xsd"\n'
f' title="{title}" id="{safe_id}">\n'
f"\n <!-- {AUTOGEN_NOTE} -->\n"
" <p>This page groups endpoint topics for this object type.</p>\n"
"</topic>\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 (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<!DOCTYPE topic\n'
' SYSTEM "https://resources.jetbrains.com/writerside/1.0/xhtml-entities.dtd">\n'
'<topic xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"\n'
' xsi:noNamespaceSchemaLocation="https://resources.jetbrains.com/writerside/1.0/topic.v2.xsd"\n'
f' title="{title}" id="{safe_id}">\n'
f"\n <!-- {AUTOGEN_NOTE} -->\n"
f" <p>{description}</p>\n"
"</topic>\n"
)
def render_modules_index_topic(topic_id: str, modules: List[Tuple[str, str]]) -> str:
safe_id = html.escape(topic_id, quote=True)
rows = [
'<?xml version="1.0" encoding="UTF-8"?>',
"<!DOCTYPE topic",
' SYSTEM "https://resources.jetbrains.com/writerside/1.0/xhtml-entities.dtd">',
'<topic xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"',
' xsi:noNamespaceSchemaLocation="https://resources.jetbrains.com/writerside/1.0/topic.v2.xsd"',
f' title="Modules" id="{safe_id}">',
"",
f" <!-- {AUTOGEN_NOTE} -->",
" <p>Module integrations sorted by module name.</p>",
" <chapter title=\"Module Catalog\" id=\"module-catalog\">",
" <table>",
" <tr><td>Module</td><td>Description</td></tr>",
]
for module_name, module_description in modules:
rows.append(
f" <tr><td>{xml_escape(module_name)}</td><td>{xml_escape(module_description)}</td></tr>"
)
rows.extend([" </table>", " </chapter>", "</topic>", ""])
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 = [
'<?xml version="1.0" encoding="UTF-8"?>',
"<!DOCTYPE topic",
' SYSTEM "https://resources.jetbrains.com/writerside/1.0/xhtml-entities.dtd">',
'<topic xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"',
' xsi:noNamespaceSchemaLocation="https://resources.jetbrains.com/writerside/1.0/topic.v2.xsd"',
f' title="Config" id="{safe_id}">',
"",
f" <!-- {AUTOGEN_NOTE} -->",
" <p>Configuration endpoints grouped by module name.</p>",
" <chapter title=\"Configuration Module Catalog\" id=\"config-module-catalog\">",
" <table>",
" <tr><td>Module</td><td>Description</td></tr>",
]
for module_name, module_description in modules:
rows.append(
f" <tr><td>{xml_escape(module_name)}</td><td>{xml_escape(module_description)}</td></tr>"
)
rows.extend([" </table>", " </chapter>", "</topic>", ""])
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 (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<!DOCTYPE topic\n'
' SYSTEM "https://resources.jetbrains.com/writerside/1.0/xhtml-entities.dtd">\n'
'<topic xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"\n'
' xsi:noNamespaceSchemaLocation="https://resources.jetbrains.com/writerside/1.0/topic.v2.xsd"\n'
' title="API Reference" id="API-Reference">\n'
f"\n <!-- {AUTOGEN_NOTE} -->\n"
" <p>Comprehensive API reference generated from the repository root <code>openapi.yaml</code>.</p>\n"
"</topic>\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(' <toc-element topic="API-Reference.topic">')
for tag in ordered_tags:
tag_topic_file = f"Tag_{slugify(tag)}.topic"
lines.append(f' <toc-element topic="{tag_topic_file}">')
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' <toc-element topic="{module_topic}">')
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' <toc-element topic="{page_topic}">')
for op in page_ops:
lines.append(f' <toc-element topic="{op.topic_file}"/>')
lines.append(" </toc-element>")
lines.append(" </toc-element>")
lines.append(" </toc-element>")
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' <toc-element topic="{config_topic}">')
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' <toc-element topic="{page_topic}">')
for op in page_ops:
lines.append(f' <toc-element topic="{op.topic_file}"/>')
lines.append(" </toc-element>")
lines.append(" </toc-element>")
lines.append(" </toc-element>")
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' <toc-element topic="{page_topic}">')
for op in page_ops:
lines.append(f' <toc-element topic="{op.topic_file}"/>')
lines.append(" </toc-element>")
lines.append(" </toc-element>")
lines.append(" </toc-element>")
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 = "</instance-profile>"
close_index = ctw_tree_content.rfind(close_tag)
if close_index == -1:
raise ValueError("Unable to update ctw.tree: missing </instance-profile>.")
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()