#!/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 an ordered `declarations` list containing function, class, or unit entries. This script renders it to the markdown the codegen tree stores, matching tools/tsl-codegen/STANDARD.md. Rendered Markdown is passed through the repository-pinned Prettier before it is written, keeping generated pages consistent with the existing codegen tree. 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. Function declaration fields: kind required `function` name required must match the signature name case-insensitively signature required verbatim, underscores/case untouched desc required description; may contain multiple lines tags optional list of Chinese keywords -> `` 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 os import shutil import subprocess import sys import tempfile from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[3] PRETTIER_CONFIG = REPO_ROOT / ".prettierrc.json" 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 require_mapping(value, where): require(isinstance(value, dict), f"{where}: must be a mapping") def reject_unknown(mapping, allowed, where): unknown = sorted(set(mapping) - set(allowed)) require(not unknown, f"{where}: unknown field(s): {', '.join(unknown)}") def non_empty_string(value, where): require(isinstance(value, str) and value.strip(), f"{where}: must be non-empty") def optional_draft_string(value, where): if value == "": return non_empty_string(value, where) def validate_tags(tags, where): if tags is None: return require(isinstance(tags, list) and tags, f"{where}: tags must be a non-empty list") for index, tag in enumerate(tags): non_empty_string(tag, f"{where}: tags[{index}]") def signature_names(signature, where): non_empty_string(signature, f"{where}: signature") left = signature.find("(") right = signature.rfind(")") require(left > 0 and right == len(signature) - 1, f"{where}: invalid signature") name = signature[:left] non_empty_string(name, f"{where}: signature name") raw = signature[left + 1 : right].strip() if not raw: return name, [] names = [item.strip() for item in raw.split(",")] require(all(names), f"{where}: signature contains an empty parameter") require(len({item.casefold() for item in names}) == len(names), f"{where}: duplicate parameter name") return name, names def validate_values(values, where): require(isinstance(values, list) and values, f"{where}: values must be a non-empty list") for index, item in enumerate(values): item_where = f"{where}[{index}]" require_mapping(item, item_where) reject_unknown(item, {"value", "desc"}, item_where) require("value" in item, f"{item_where}: missing 'value'") non_empty_string(item.get("desc"), f"{item_where}: desc") def validate_params(params, expected_names, where): if not expected_names: require(not params, f"{where}: nullary signature must not have params") return require(isinstance(params, list), f"{where}: params must be a list") require(len(params) == len(expected_names), f"{where}: params do not match signature") actual_names = [] for index, param in enumerate(params): param_where = f"{where}: params[{index}]" require_mapping(param, param_where) reject_unknown(param, {"name", "type", "desc", "optional", "values"}, param_where) name = param.get("name") non_empty_string(name, f"{param_where}: name") non_empty_string(param.get("type"), f"{param_where}: type") non_empty_string(param.get("desc"), f"{param_where}: desc") if "optional" in param: require(isinstance(param["optional"], bool), f"{param_where}: optional must be boolean") if "values" in param: validate_values(param["values"], f"{param_where}: values") actual_names.append(name) require( [name.casefold() for name in actual_names] == [name.casefold() for name in expected_names], f"{where}: params must follow signature order", ) def validate_examples(examples, where): require(isinstance(examples, list) and examples, f"{where}: examples must be a non-empty list") for index, example in enumerate(examples): example_where = f"{where}: examples[{index}]" require_mapping(example, example_where) reject_unknown(example, {"desc", "code", "output"}, example_where) non_empty_string(example.get("desc"), f"{example_where}: desc") non_empty_string(example.get("code"), f"{example_where}: code") if "output" in example: non_empty_string(example["output"], f"{example_where}: output") def validate_function(fn, where, *, returns_required, extra_fields=()): require_mapping(fn, where) allowed = { "signature", "desc", "tags", "params", "returns", "example", "examples", *extra_fields, } reject_unknown(fn, allowed, where) name, names = signature_names(fn.get("signature"), where) non_empty_string(fn.get("desc"), f"{where}: desc") validate_tags(fn.get("tags"), where) validate_params(fn.get("params"), names, where) if returns_required: non_empty_string(fn.get("returns"), f"{where}: missing 'returns'") elif "returns" in fn: optional_draft_string(fn["returns"], f"{where}: returns") require(not ("example" in fn and "examples" in fn), f"{where}: use example or examples, not both") if "example" in fn: non_empty_string(fn["example"], f"{where}: example") if "examples" in fn: validate_examples(fn["examples"], where) return name def validate_class_member(member, where): require_mapping(member, where) kind = member.get("kind") require(kind in {"method", "property", "field", "constant"}, f"{where}: unknown kind '{kind}'") non_empty_string(member.get("name"), f"{where}: name") visibility = member.get("visibility") require(visibility in {"public", "protected"}, f"{where}: visibility must be public or protected") non_empty_string(member.get("desc"), f"{where}: desc") validate_tags(member.get("tags"), where) if kind == "method": reject_unknown( member, { "kind", "name", "visibility", "binding", "signature", "desc", "tags", "params", "returns", "modifiers", "example", "examples", }, where, ) require(member.get("binding") in {"instance", "class"}, f"{where}: invalid binding") parsed_name = validate_function( member, where, returns_required=False, extra_fields={"kind", "name", "visibility", "binding", "modifiers"}, ) require(parsed_name.casefold() == member["name"].casefold(), f"{where}: name and signature differ") if "modifiers" in member: modifiers = member["modifiers"] require(isinstance(modifiers, list), f"{where}: modifiers must be a list") allowed = {"overload", "virtual", "override"} require(all(item in allowed for item in modifiers), f"{where}: invalid modifier") require(len(set(modifiers)) == len(modifiers), f"{where}: duplicate modifier") return common = {"kind", "name", "visibility", "desc", "tags"} if kind == "property": reject_unknown(member, common | {"type", "params", "access"}, where) if "type" in member: optional_draft_string(member["type"], f"{where}: type") require(member.get("access") in {"read", "write", "readwrite"}, f"{where}: invalid access") params = member.get("params") if params: expected = [param.get("name") for param in params] validate_params(params, expected, where) return if kind == "field": reject_unknown(member, common | {"type", "static"}, where) non_empty_string(member.get("type"), f"{where}: type") if "static" in member: require(isinstance(member["static"], bool), f"{where}: static must be boolean") return reject_unknown(member, common | {"type", "value", "static"}, where) require("value" in member and member["value"] is not None, f"{where}: missing 'value'") if isinstance(member["value"], str): non_empty_string(member["value"], f"{where}: value") if "type" in member: optional_draft_string(member["type"], f"{where}: type") if "static" in member: require(isinstance(member["static"], bool), f"{where}: static must be boolean") def validate_class(cls, where): require_mapping(cls, where) reject_unknown(cls, {"kind", "name", "desc", "tags", "bases", "members"}, where) require(cls.get("kind") == "class", f"{where}: kind must be class") non_empty_string(cls.get("name"), f"{where}: name") non_empty_string(cls.get("desc"), f"{where}: desc") validate_tags(cls.get("tags"), where) if "bases" in cls: require(isinstance(cls["bases"], list), f"{where}: bases must be a list") for index, base in enumerate(cls["bases"]): non_empty_string(base, f"{where}: bases[{index}]") require(isinstance(cls.get("members"), list), f"{where}: members must be a list") for index, member in enumerate(cls["members"]): validate_class_member(member, f"{where}: members[{index}]") def validate_unit_member(member, where): require_mapping(member, where) kind = member.get("kind") require(kind in {"function", "variable", "constant", "class"}, f"{where}: unknown kind '{kind}'") if kind == "class": validate_class(member, where) return non_empty_string(member.get("name"), f"{where}: name") non_empty_string(member.get("desc"), f"{where}: desc") validate_tags(member.get("tags"), where) if kind == "function": parsed_name = validate_function( member, where, returns_required=True, extra_fields={"kind", "name"}, ) require(parsed_name.casefold() == member["name"].casefold(), f"{where}: name and signature differ") return common = {"kind", "name", "desc", "tags", "type"} if kind == "variable": reject_unknown(member, common, where) non_empty_string(member.get("type"), f"{where}: type") return reject_unknown(member, common | {"value"}, where) require("value" in member and member["value"] is not None, f"{where}: missing 'value'") if isinstance(member["value"], str): non_empty_string(member["value"], f"{where}: value") if "type" in member: optional_draft_string(member["type"], f"{where}: type") def validate_unit(unit, where): require_mapping(unit, where) reject_unknown(unit, {"kind", "name", "desc", "tags", "members"}, where) require(unit.get("kind") == "unit", f"{where}: kind must be unit") non_empty_string(unit.get("name"), f"{where}: name") non_empty_string(unit.get("desc"), f"{where}: desc") validate_tags(unit.get("tags"), where) require( isinstance(unit.get("members"), list), f"{where}: members must be a list", ) for index, member in enumerate(unit["members"]): validate_unit_member(member, f"{where}: members[{index}]") def validate_top_level_function(declaration, where): require( declaration.get("kind") == "function", f"{where}: kind must be function", ) non_empty_string(declaration.get("name"), f"{where}: name") parsed_name = validate_function( declaration, where, returns_required=True, extra_fields={"kind", "name"}, ) require( parsed_name.casefold() == declaration["name"].casefold(), f"{where}: name and signature differ", ) def validate_declaration(declaration, where): require_mapping(declaration, where) kind = declaration.get("kind") require( kind in {"function", "class", "unit"}, f"{where}: unknown kind '{kind}'", ) if kind == "function": validate_top_level_function(declaration, where) elif kind == "class": validate_class(declaration, where) else: validate_unit(declaration, where) def validate_declaration_uniqueness(declarations): function_signatures = set() class_names = set() unit_names = set() for index, declaration in enumerate(declarations): where = f"declarations[{index}]" kind = declaration["kind"] if kind == "function": key = declaration["signature"].casefold() require( key not in function_signatures, f"{where}: duplicate function signature", ) function_signatures.add(key) continue names = class_names if kind == "class" else unit_names key = declaration["name"].casefold() require(key not in names, f"{where}: duplicate {kind} name") names.add(key) def validate_page(data): require_mapping(data, "input root") reject_unknown(data, {"module", "path", "declarations"}, "input root") non_empty_string(data.get("module"), "input root: module") non_empty_string(data.get("path"), "input root: path") declarations = data.get("declarations") require( isinstance(declarations, list) and declarations, "input root: declarations must be a non-empty list", ) for index, declaration in enumerate(declarations): validate_declaration(declaration, f"declarations[{index}]") validate_declaration_uniqueness(declarations) return declarations 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, heading_level=None): """Render value sections, preserving legacy function-page headings.""" lines = [] for param in params: values = param.get("values") if not values: continue if heading_level is None: lines.append(f"**{param['name']} 取值**") else: lines.append(f"{'#' * heading_level} `{param['name']}` 取值") lines.append("") for item in values: lines.append(f"- `{item['value']}` — {item['desc']}") lines.append("") return lines def render_examples(fn, heading_level): lines = [] if "examples" in fn: lines.extend([f"{'#' * heading_level} 示例", ""]) for index, example in enumerate(fn["examples"], start=1): lines.append(f"范例{index:02d}:{example['desc']}") lines.append("") lines.append("```tsl") lines.extend(example["code"].rstrip("\n").split("\n")) output = example.get("output") if output is not None: output_lines = output.split("\n") if len(output_lines) == 1: lines.append(f"// 输出:{output_lines[0]}") else: lines.append("// 输出:") lines.extend(f"// {line}" if line else "//" for line in output_lines) lines.append("```") lines.append("") while lines and not lines[-1]: lines.pop() return lines if fn.get("example"): return [ f"{'#' * heading_level} 示例", "", "```tsl", *fn["example"].rstrip("\n").split("\n"), "```", ] return [] def render_intro(heading, declaration, item): lines = [heading, "", f"声明:{declaration}", "", item["desc"], ""] tags = item.get("tags") if tags: lines.extend([f"", ""]) return lines def render_callable( fn, signature, declaration, where, *, level, returns_required, show_visibility, ): validate_function( fn, where, returns_required=returns_required, extra_fields={"kind", "name", "visibility", "binding", "modifiers"}, ) lines = render_intro( f"{'#' * level} `{signature}`", declaration, fn ) if show_visibility: lines.extend([f"可见性:`{fn['visibility']}`", ""]) if fn.get("modifiers"): rendered = "、".join(f"`{item}`" for item in fn["modifiers"]) lines.extend([f"修饰符:{rendered}", ""]) params = fn.get("params") or [] if params: lines.extend(render_param_table(params, where)) lines.append("") lines.extend(render_enum_sections(params, level + 1)) if fn.get("returns"): lines.append(f"返回:{fn['returns']}") example_lines = render_examples(fn, level + 1) if example_lines: lines.extend(["", *example_lines]) return lines def render_top_level_function(fn, index): """Render one function entry to a list of lines (no trailing blank).""" where = f"declarations[{index}]" sig = fn["signature"] lines = render_intro(f"## `{sig}`", "function", fn) 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"返回:{fn['returns']}") examples = render_examples(fn, 3) if examples: lines.extend(["", *examples]) return lines def render_class_member(member, level, where): kind = member["kind"] if kind == "method": declaration = ( "class function" if member["binding"] == "class" else "function" ) return render_callable( member, member["signature"], declaration, where, level=level, returns_required=False, show_visibility=True, ) static_prefix = "static " if member.get("static") else "" declaration = { "property": "property", "field": f"{static_prefix}field", "constant": f"{static_prefix}const", }[kind] signature = member["name"] if kind == "property" and member.get("params"): signature += "(" + ", ".join(param["name"] for param in member["params"]) + ")" lines = render_intro( f"{'#' * level} `{signature}`", declaration, member ) lines.extend([f"可见性:`{member['visibility']}`", ""]) if kind == "property": if member.get("type"): lines.extend([f"类型:{member['type']}", ""]) access = {"read": "read", "write": "write", "readwrite": "read / write"}[member["access"]] lines.append(f"访问:{access}") params = member.get("params") or [] if params: lines.extend(["", *render_param_table(params, where), ""]) lines.extend(render_enum_sections(params, level + 1)) elif kind == "field": lines.append(f"类型:{member['type']}") else: if member.get("type"): lines.extend([f"类型:{member['type']}", ""]) lines.append(f"值:`{render_scalar(member['value'])}`") return lines def render_class(cls, level, where, *, page_root): lines = render_intro( f"{'#' * level} `{cls['name']}`", "class", cls ) if cls.get("bases"): lines.extend(["父类:" + "、".join(f"`{base}`" for base in cls["bases"]), ""]) for index, member in enumerate(cls["members"]): lines.extend(render_class_member(member, level + 1, f"{where}: members[{index}]")) lines.append("") while lines and not lines[-1]: lines.pop() return lines def render_unit_member(member, index, unit_where): where = f"{unit_where}: members[{index}]" kind = member["kind"] if kind == "function": return render_callable( member, member["signature"], "function", where, level=3, returns_required=True, show_visibility=False, ) if kind == "class": return render_class(member, 3, where, page_root=False) declaration = "var" if kind == "variable" else "const" lines = render_intro( f"### `{member['name']}`", declaration, member ) if kind == "variable": lines.append(f"类型:{member['type']}") else: if member.get("type"): lines.extend([f"类型:{member['type']}", ""]) lines.append(f"值:`{render_scalar(member['value'])}`") return lines def render_unit(unit, where): lines = render_intro(f"## `{unit['name']}`", "unit", unit) for index, member in enumerate(unit["members"]): lines.extend(render_unit_member(member, index, where)) lines.append("") while lines and not lines[-1]: lines.pop() return lines def render_scalar(value): if isinstance(value, str): return value return json.dumps(value, ensure_ascii=False) def render_declaration(declaration, index): kind = declaration["kind"] where = f"declarations[{index}]" if kind == "function": return render_top_level_function(declaration, index) if kind == "class": return render_class(declaration, 2, where, page_root=True) return render_unit(declaration, where) def render_page(data): """Validate and render a whole leaf page.""" declarations = validate_page(data) out = [f"# {data['module']}", ""] for index, declaration in enumerate(declarations): out.extend(render_declaration(declaration, index)) out.append("") return "\n".join(out).rstrip("\n") + "\n" def format_markdown(text): """Format generated Markdown with the repository-pinned Prettier.""" npx = shutil.which("npx") if not npx: die("未找到 Prettier;请先在仓库根目录运行 `npm install`") result = subprocess.run( [ npx, "--no-install", "prettier", "--config", str(PRETTIER_CONFIG), "--parser", "markdown", ], input=text, capture_output=True, text=True, encoding="utf-8", cwd=REPO_ROOT, check=False, ) if result.returncode != 0: detail = result.stderr.strip() or "未知错误" die(f"Prettier 格式化失败:{detail}") return result.stdout 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 atomic_write(path, text): """Atomically replace path and remove the temporary file on failure.""" path.parent.mkdir(parents=True, exist_ok=True) descriptor, temporary_name = tempfile.mkstemp( prefix=f".{path.name}.", suffix=".tmp", dir=path.parent ) temporary_path = Path(temporary_name) try: with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle: handle.write(text) handle.flush() os.fsync(handle.fileno()) os.replace(temporary_path, path) finally: if temporary_path.exists(): temporary_path.unlink() def main(argv=None): if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") parser = argparse.ArgumentParser( description="从 YAML/JSON 录入文件生成 TSL API 文档", add_help=False, allow_abbrev=False, ) parser.add_argument( "--help", action="help", help="显示本帮助并退出(不提供 -h 短选项)", ) 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 = format_markdown(render_page(data)) out_path = output_path(data, args.scope) atomic_write(out_path, text) print( f"wrote {out_path}", file=sys.stderr, ) return 0 if __name__ == "__main__": raise SystemExit(main())