#!/usr/bin/env python3 """ deno-doc-npm: Browse TypeScript API docs for any npm package. Requires: deno, fzf (for interactive mode) Usage: deno-doc-npm.py [@version] # full deno doc output deno-doc-npm.py -i [@version] # interactive fuzzy search with fzf deno-doc-npm.py -i react deno-doc-npm.py -i ufo@1.5.0 deno-doc-npm.py --preview \\t # (internal: used by fzf) """ import argparse import json import os import subprocess import sys import tempfile import urllib.request # --------------------------------------------------------------------------- # Type renderer # --------------------------------------------------------------------------- def _render_fn_param(p: dict) -> str: """Render a single function parameter, handling rest/identifier/assign shapes.""" pk = p.get("kind", "") if pk == "rest": inner = p.get("arg", {}) name = inner.get("name", "") ts = f": {render_type(p.get('tsType'))}" if p.get("tsType") else "" return f"...{name}{ts}" if pk == "assign": inner = p.get("left", {}) name = inner.get("name", p.get("name", "")) opt = "?" ts = f": {render_type(p.get('tsType'))}" if p.get("tsType") else "" return f"{name}{opt}{ts}" # kind == "identifier" or anything else with a top-level name name = p.get("name", "") opt = "?" if p.get("optional") else "" ts = f": {render_type(p.get('tsType'))}" if p.get("tsType") else "" return f"{name}{opt}{ts}" def render_type(t: dict | None) -> str: if t is None: return "unknown" kind = t.get("kind", "") if kind == "typeRef": ref = t["typeRef"] name = ref["typeName"] params = ref.get("typeParams") if params: return f"{name}<{', '.join(render_type(p) for p in params)}>" return name if kind == "keyword": return t["keyword"] if kind == "union": return " | ".join(render_type(u) for u in t["union"]) if kind == "intersection": return " & ".join(render_type(u) for u in t["intersection"]) if kind == "array": inner = render_type(t["array"]) if " " in inner: return f"({inner})[]" return f"{inner}[]" if kind == "parenthesized": return f"({render_type(t['parenthesized'])})" if kind == "fnOrConstructor": fn = t["fnOrConstructor"] params = ", ".join( _render_fn_param(p) for p in fn.get("params", []) ) ret = render_type(fn.get("tsType")) return f"({params}) => {ret}" if kind == "tuple": return "[" + ", ".join(render_type(u) for u in t["tuple"]) + "]" if kind == "indexed_access": ia = t["indexedAccess"] return f"{render_type(ia['objType'])}[{render_type(ia['indexType'])}]" if kind == "mapped": m = t["mappedType"] param = m["typeParam"] return ( f"{{ [{param['name']} in {render_type(param.get('constraint'))}]: " f"{render_type(m.get('tsType'))} }}" ) if kind == "typeLiteral": props = t["typeLiteral"].get("properties", []) parts = [] for p in props: opt = "?" if p.get("optional") else "" parts.append(f"{p['name']}{opt}: {render_type(p.get('tsType'))}") return "{ " + "; ".join(parts) + " }" if kind == "conditional": c = t["conditionalType"] return ( f"{render_type(c['checkType'])} extends {render_type(c['extendsType'])} " f"? {render_type(c['trueType'])} : {render_type(c['falseType'])}" ) if kind == "literal": lit = t["literal"] lk = lit.get("kind", "") if lk == "string": return f'"{lit["string"]}"' if lk == "number": return str(lit["number"]) if lk == "boolean": return str(lit["boolean"]).lower() return t.get("repr", kind) if kind == "rest": return f"...{render_type(t['rest'])}" if kind == "optional": return f"{render_type(t['optional'])}?" if kind == "typeOperator": op = t["typeOperator"] return f"{op['operator']} {render_type(op['tsType'])}" if kind == "typePredicate": tp = t["typePredicate"] prefix = "asserts " if tp.get("asserts") else "" suffix = f" is {render_type(tp['type'])}" if tp.get("type") else "" return f"{prefix}{tp['param']['name']}{suffix}" if kind == "importType": return t.get("repr", "import(...)") if kind == "infer": return f"infer {t['infer']['typeParam']['name']}" if t.get("repr"): return t["repr"] return kind def _render_type_params(type_params: list[dict]) -> str: if not type_params: return "" parts = [] for tp in type_params: s = tp["name"] if tp.get("constraint"): s += f" extends {render_type(tp['constraint'])}" if tp.get("default"): s += f" = {render_type(tp['default'])}" parts.append(s) return "<" + ", ".join(parts) + ">" def _render_params(params: list[dict]) -> str: return ", ".join(_render_fn_param(p) for p in params) # --------------------------------------------------------------------------- # Node renderer # --------------------------------------------------------------------------- def render_node(node: dict) -> str: kind = node.get("kind", "") name = node.get("name", "") lines: list[str] = [] if kind == "function": fd = node["functionDef"] tp = _render_type_params(fd.get("typeParams", [])) params = _render_params(fd.get("params", [])) ret = f": {render_type(fd['returnType'])}" if fd.get("returnType") else "" lines.append(f"function {name}{tp}({params}){ret}") elif kind == "interface": idef = node["interfaceDef"] tp = _render_type_params(idef.get("typeParams", [])) extends = "" if idef.get("extends"): extends = " extends " + ", ".join(e["repr"] for e in idef["extends"]) lines.append(f"interface {name}{tp}{extends} {{") for p in idef.get("properties", []): opt = "?" if p.get("optional") else "" ts = f": {render_type(p['tsType'])}" if p.get("tsType") else "" lines.append(f" {p['name']}{opt}{ts}") for m in idef.get("methods", []): params = _render_params(m.get("params", [])) ret = f": {render_type(m['returnType'])}" if m.get("returnType") else "" lines.append(f" {m['name']}({params}){ret}") for cs in idef.get("callSignatures", []): params = _render_params(cs.get("params", [])) ret = f": {render_type(cs['tsType'])}" if cs.get("tsType") else "" lines.append(f" ({params}){ret}") for ix in idef.get("indexSignatures", []): params = ", ".join( f"{p['name']}: {render_type(p['tsType'])}" for p in ix.get("params", []) ) lines.append(f" [{params}]: {render_type(ix.get('tsType'))}") lines.append("}") elif kind == "typeAlias": td = node["typeAliasDef"] tp = _render_type_params(td.get("typeParams", [])) lines.append(f"type {name}{tp} = {render_type(td['tsType'])}") elif kind == "variable": vd = node["variableDef"] ts = f": {render_type(vd['tsType'])}" if vd.get("tsType") else "" lines.append(f"const {name}{ts}") elif kind == "class": cd = node["classDef"] tp = _render_type_params(cd.get("typeParams", [])) extends = f" extends {cd['superClass']}" if cd.get("superClass") else "" implements = "" if cd.get("implements"): implements = " implements " + ", ".join(i["repr"] for i in cd["implements"]) lines.append(f"class {name}{tp}{extends}{implements} {{") for p in cd.get("properties", []): mods = "" if p.get("isStatic"): mods += "static " if p.get("accessibility"): mods += p["accessibility"] + " " if p.get("readonly"): mods += "readonly " opt = "?" if p.get("optional") else "" ts = f": {render_type(p['tsType'])}" if p.get("tsType") else "" lines.append(f" {mods}{p['name']}{opt}{ts}") for m in cd.get("methods", []): static = "static " if m.get("isStatic") else "" fd = m.get("functionDef", {}) params = _render_params(fd.get("params", [])) ret = f": {render_type(fd['returnType'])}" if fd.get("returnType") else "" lines.append(f" {static}{m['name']}({params}){ret}") lines.append("}") elif kind == "enum": lines.append(f"enum {name} {{") for member in node.get("enumDef", {}).get("members", []): init = f" = {render_type(member['init'])}" if member.get("init") else "" lines.append(f" {member['name']}{init},") lines.append("}") elif kind == "namespace": lines.append(f"namespace {name}") else: lines.append(f"{kind} {name}") doc = node.get("jsDoc", {}).get("doc") if doc: lines.append("") lines.append(doc) return "\n".join(lines) # --------------------------------------------------------------------------- # Symbol collection # --------------------------------------------------------------------------- def collect_symbols(data: dict) -> list[tuple[str, str]]: """Return sorted list of (kind, name) for all symbols.""" symbols: set[tuple[str, str]] = set() for node in data.get("nodes", []): kind = node.get("kind", "") if kind == "import": continue if kind == "namespace": for elem in node.get("namespaceDef", {}).get("elements", []): symbols.add((elem["kind"], elem["name"])) else: symbols.add((kind, node["name"])) return sorted(symbols, key=lambda x: x[1]) def find_nodes(data: dict, kind: str, name: str) -> list[dict]: """Find all nodes matching kind and name (including inside namespaces).""" results = [] for node in data.get("nodes", []): nk = node.get("kind", "") if nk == "namespace": for elem in node.get("namespaceDef", {}).get("elements", []): if elem.get("kind") == kind and elem.get("name") == name: results.append(elem) elif nk == kind and node.get("name") == name: results.append(node) return results def render_for_preview(json_path: str, selection: str): """Render a symbol from the JSON file (used by fzf preview).""" with open(json_path) as f: data = json.load(f) parts = selection.split("\t", 1) if len(parts) == 2: kind, name = parts for node in find_nodes(data, kind, name): print(render_node(node)) print() # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- def get_types_url(package: str) -> str | None: """Get the TypeScript types URL from esm.sh's x-typescript-types header.""" url = f"https://esm.sh/{package}" req = urllib.request.Request(url, method="HEAD") try: with urllib.request.urlopen(req) as resp: return resp.headers.get("x-typescript-types") except Exception as e: print(f"Error fetching {url}: {e}", file=sys.stderr) return None def main(): parser = argparse.ArgumentParser( description="Browse TypeScript API docs for any npm package.", ) parser.add_argument("package", nargs="?", help="npm package name[@version]") parser.add_argument( "-i", "--interactive", action="store_true", help="interactive mode (fuzzy search symbols with fzf)", ) parser.add_argument( "--preview", nargs=2, metavar=("JSON_PATH", "SELECTION"), help=argparse.SUPPRESS, # internal: used by fzf preview ) args = parser.parse_args() # Internal preview mode for fzf if args.preview: render_for_preview(args.preview[0], args.preview[1]) return if not args.package: parser.print_help() sys.exit(1) types_url = get_types_url(args.package) if not types_url: print(f"No TypeScript types found for {args.package}", file=sys.stderr) sys.exit(1) if not args.interactive: subprocess.run(["deno", "doc", types_url]) return # Interactive mode: get JSON, pick with fzf result = subprocess.run( ["deno", "doc", "--json", types_url], capture_output=True, text=True, ) if result.returncode != 0: print(f"deno doc failed: {result.stderr}", file=sys.stderr) sys.exit(1) data = json.loads(result.stdout) symbols = collect_symbols(data) if not symbols: print("No symbols found.", file=sys.stderr) sys.exit(1) # Write JSON to temp file for fzf preview to read json_fd, json_path = tempfile.mkstemp(suffix=".json") try: with os.fdopen(json_fd, "w") as f: f.write(result.stdout) script_path = os.path.abspath(__file__) fzf_input = "\n".join(f"{kind}\t{name}" for kind, name in symbols) fzf = subprocess.run( [ "fzf", f"--prompt={args.package}> ", "--delimiter=\t", "--with-nth=1..", f"--preview=python3 {script_path} --preview {json_path} {{}}", "--preview-window=wrap", ], input=fzf_input, capture_output=True, text=True, ) if fzf.returncode != 0 or not fzf.stdout.strip(): sys.exit(0) selection = fzf.stdout.strip() kind, name = selection.split("\t", 1) for node in find_nodes(data, kind, name): print(render_node(node)) print() finally: os.unlink(json_path) if __name__ == "__main__": main()