Files
playbook/tools/tsl-codegen/scripts/generate.py
T
csh c69278283f feat(tsl-codegen): add decoupled documentation toolkit
Generate TSL API Markdown from YAML or JSON into a configurable project scope.\nAdd file and directory lint modes, tags-aware indexing, and keyword search across tags and descriptions.\nBundle the toolkit through the playbook build and sync workflows.
2026-07-20 09:08:38 +08:00

247 lines
8.0 KiB
Python

#!/usr/bin/env python3
"""Generate compliant TSL codegen markdown from a YAML/JSON entry file.
The recording format is one leaf page: a `module` title, a relative `path`, and
a `functions` list. This script renders it to the markdown the codegen tree
stores, matching tools/tsl-codegen/STANDARD.md.
Tables are emitted as valid Markdown with single-space padding. Prettier may be
used optionally to align columns.
Input dispatch is by extension: .json parses with the stdlib (keeping the
toolchain dependency-free); .yml/.yaml needs pyyaml. If pyyaml is missing the
script says so and points at the JSON path.
Entry schema (per function):
signature required verbatim, underscores/case untouched
desc required description; may contain multiple lines
tags optional list of Chinese keywords -> `<!-- tags: ... -->`
params required when the signature takes args; omit for nullary
returns required return type
example optional fenced tsl block, pasted verbatim
Each param: name/type/desc required; optional (bool) -> `可选。` prefix;
values (list of {value, desc}) -> a `**name 取值**` enum section.
Usage (run from repo root):
python tools/tsl-codegen/scripts/generate.py entry.yml
python tools/tsl-codegen/scripts/generate.py entry.json \
--scope my-project
"""
import argparse
import json
import sys
from pathlib import Path
def die(msg):
print(f"ERROR: {msg}", file=sys.stderr)
raise SystemExit(1)
def scope_name(value):
"""Validate a user-defined single directory name."""
if not value or value in {".", ".."} or "/" in value or "\\" in value:
raise argparse.ArgumentTypeError("scope 必须是单级目录名")
return value
def resolve_format(path, fmt):
"""Pick the parser: explicit --format wins, else derive from extension."""
if fmt:
return fmt
suffix = path.suffix.lower()
if suffix == ".json":
return "json"
if suffix in (".yml", ".yaml"):
return "yaml"
die(
f"cannot infer format from extension '{suffix}'; "
f"pass --format json|yaml"
)
def load_entries(path, fmt=None):
"""Parse a recording file as JSON (stdlib) or YAML (pyyaml).
Format is chosen by --format when given, else by file extension.
"""
text = path.read_text(encoding="utf-8")
fmt = resolve_format(path, fmt)
if fmt == "json":
try:
return json.loads(text)
except json.JSONDecodeError as exc:
die(f"invalid JSON in {path}: {exc}")
if fmt == "yaml":
try:
import yaml
except ImportError:
die(
"pyyaml is not installed; either `pip install pyyaml` or "
"convert the input to .json (json parses with the stdlib)"
)
try:
return yaml.safe_load(text)
except yaml.YAMLError as exc:
die(f"invalid YAML in {path}: {exc}")
die(f"unknown format '{fmt}'; use json or yaml")
def escape_cell(text):
"""Escape `|` so a value stays inside one markdown table cell."""
return str(text).replace("|", "\\|")
def require(cond, msg):
if not cond:
die(msg)
def param_desc(param, where):
"""Description column text: prepend `可选。` for optional params."""
desc = param.get("desc")
require(desc, f"{where}: param '{param.get('name', '?')}' missing desc")
if param.get("optional") and not desc.startswith("可选。"):
return "可选。" + desc
return desc
def render_param_table(params, where):
"""Three-column 参数/类型/说明 table with single-space padding."""
lines = ["| 参数 | 类型 | 说明 |", "| --- | --- | --- |"]
for param in params:
name = param.get("name")
ptype = param.get("type")
require(name, f"{where}: a param is missing 'name'")
require(ptype, f"{where}: param '{name}' missing 'type'")
desc = param_desc(param, where)
lines.append(
f"| `{escape_cell(name)}` | {escape_cell(ptype)} | {escape_cell(desc)} |"
)
return lines
def render_enum_sections(params):
"""`**name 取值**` sections for every param carrying a `values` list."""
lines = []
for param in params:
values = param.get("values")
if not values:
continue
lines.append(f"**{param['name']} 取值**")
lines.append("")
for item in values:
lines.append(f"- `{item['value']}` — {item['desc']}")
lines.append("")
return lines
def render_function(fn, index):
"""Render one function entry to a list of lines (no trailing blank)."""
where = f"functions[{index}]"
sig = fn.get("signature")
require(sig, f"{where}: missing 'signature'")
desc = fn.get("desc")
require(desc, f"{where} ({sig}): missing 'desc'")
returns = fn.get("returns")
require(returns, f"{where} ({sig}): missing 'returns'")
lines = [f"## `{sig}`"]
tags = fn.get("tags")
if tags:
lines.append(f"<!-- tags: {' '.join(str(t) for t in tags)} -->")
lines.append("")
lines.append(desc)
lines.append("")
params = fn.get("params") or []
if params:
lines.extend(render_param_table(params, f"{where} ({sig})"))
lines.append("")
lines.extend(render_enum_sections(params))
lines.append(f"返回:{returns}")
example = fn.get("example")
if example:
lines.append("")
lines.append("### 示例")
lines.append("")
lines.append("```tsl")
lines.extend(example.rstrip("\n").split("\n"))
lines.append("```")
return lines
def render_page(data):
"""Render a whole leaf page: H1 + every function entry."""
require(isinstance(data, dict), "input root must be a mapping")
module = data.get("module")
require(module, "input missing 'module'")
functions = data.get("functions")
require(functions, "input missing non-empty 'functions'")
out = [f"# {module}", ""]
for index, fn in enumerate(functions):
out.extend(render_function(fn, index))
out.append("")
return "\n".join(out).rstrip("\n") + "\n"
def output_path(data, scope):
"""Build the leaf-page destination from the recording file's relative path."""
relative = data.get("path")
require(relative, "input missing 'path'")
require(isinstance(relative, str), "'path' must be a string")
require("\\" not in relative, "'path' must use '/' as the separator")
relative_path = Path(relative)
require(not relative_path.is_absolute(), "'path' must be relative")
require(".." not in relative_path.parts, "'path' must not contain '..'")
require(relative_path.suffix == "", "'path' must not include a file extension")
return (
Path("skills/tsl-api-reference/references/codegen")
/ scope
/ relative_path.with_suffix(".md")
)
def main(argv=None):
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
parser = argparse.ArgumentParser(description="从 YAML/JSON 录入文件生成 TSL 函数文档")
parser.add_argument(
"input",
metavar="INPUT_FILE",
help="YAML/JSON 录入文件路径,例如 tmp/my-functions.yaml",
)
parser.add_argument(
"--scope",
type=scope_name,
default="project",
help="codegen 下的一级目录,可自定义(默认:project)",
)
parser.add_argument(
"--format",
choices=["json", "yaml"],
help="录入文件格式;默认根据文件扩展名判断",
)
args = parser.parse_args(argv)
in_path = Path(args.input)
if not in_path.is_file():
die(f"input not found: {in_path}")
data = load_entries(in_path, args.format)
text = render_page(data)
out_path = output_path(data, args.scope)
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(text, encoding="utf-8", newline="\n")
print(
f"wrote {out_path}",
file=sys.stderr,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())