Milestone 2: module/model docs (MD, Sphinx RST, PDF) and work log
74 source modules documented with extracted signatures, JSDoc params, imports, and call graphs. 10 first-class models (PriceRule through StatusResponse) have their own MD/RST/PDF. Sphinx HTML builds from docs/sphinx. Per-module PDFs in docs/modules-pdf and docs/models-pdf. Middleware gates 2–6 and 10 pass on MOCK_VERAE without NATS. Learned: RST includes are relative to the RST file; keep one PDF per module; Add Numbers remains the only push required tomorrow.
This commit is contained in:
parent
b814501441
commit
10c663cc0c
347 changed files with 17200 additions and 14 deletions
|
|
@ -53,6 +53,11 @@ const requiredFiles = [
|
|||
'research/zapier/getting-started.md',
|
||||
'research/zapier/PLATFORM-REFERENCE.md',
|
||||
'research/zapier/VENDOR-RESEARCH.md',
|
||||
'docs/04-activate/SETUP-ZAPIER-DEVELOPER.md',
|
||||
'docs/modules/README.md',
|
||||
'docs/models/README.md',
|
||||
'packages/verae-activate/index.js',
|
||||
'packages/verae-activate/creates/add_numbers.js',
|
||||
];
|
||||
|
||||
/** @type {string[]} */
|
||||
|
|
|
|||
194
scripts/gen-model-docs.py
Normal file
194
scripts/gen-model-docs.py
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Write first-class data-model MD + RST + PDF sheets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from reportlab.lib.pagesizes import letter
|
||||
from reportlab.lib.styles import getSampleStyleSheet
|
||||
from reportlab.lib.units import inch
|
||||
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MD = ROOT / "docs" / "models"
|
||||
RST = ROOT / "docs" / "sphinx" / "models"
|
||||
PDF = ROOT / "docs" / "models-pdf"
|
||||
|
||||
MODELS = {
|
||||
"PriceRule": {
|
||||
"src": "packages/zappier/src/pricing.ts",
|
||||
"kind": "discriminated union",
|
||||
"fields": [
|
||||
("kind", "'free' | 'fixed' | 'variable'", "Which list-price formula"),
|
||||
("fixedCents", "number?", "When kind=fixed, cents per call"),
|
||||
("baseCents", "number?", "When kind=variable, base cents"),
|
||||
("perKbCents", "number?", "When kind=variable, per KB metadata"),
|
||||
("perMbCents", "number?", "When kind=variable, per MB attachments"),
|
||||
],
|
||||
"used_by": ["quoteCall", "meter", "admin rate card UI", "portal API & pricing tab"],
|
||||
"returns_in": "RateCard.endpoints values",
|
||||
},
|
||||
"TierConfig": {
|
||||
"src": "packages/zappier/src/pricing.ts",
|
||||
"kind": "interface",
|
||||
"fields": [
|
||||
("id", "string", "free | pro | business or custom"),
|
||||
("name", "string", "Display name"),
|
||||
("multiplier", "number", "Applied once to list cents"),
|
||||
("monthlyCreditCents", "number", "Included usage before Stripe/invoice"),
|
||||
("defaultRule", "PriceRule?", "Price for unlisted operationIds"),
|
||||
],
|
||||
"used_by": ["quoteCall", "applyMonthlyCredit", "admin tiers", "portal"],
|
||||
"returns_in": "PricingStore.getTiers()",
|
||||
},
|
||||
"RateCard": {
|
||||
"src": "packages/zappier/src/pricing.ts",
|
||||
"kind": "interface",
|
||||
"fields": [("endpoints", "Record<string, PriceRule>", "Keys are OpenAPI operationIds")],
|
||||
"used_by": ["quoteCall", "admin PUT /admin/api/endpoints/:id"],
|
||||
"returns_in": "PricingStore.getRateCard()",
|
||||
},
|
||||
"Quote": {
|
||||
"src": "packages/zappier/src/pricing.ts",
|
||||
"kind": "interface",
|
||||
"fields": [
|
||||
("endpointId", "string", "operationId priced"),
|
||||
("listCents", "number", "Before multiplier"),
|
||||
("totalCents", "number", "round(list * multiplier)"),
|
||||
("breakdown.baseCents", "number", "Variable base"),
|
||||
("breakdown.metadataCents", "number", "KB component"),
|
||||
("breakdown.attachmentCents", "number", "MB component"),
|
||||
],
|
||||
"used_by": ["meter() sets res.locals.quote; JSON body.quote on /v1/*"],
|
||||
"returns_in": "quoteCall(...) → Quote",
|
||||
},
|
||||
"CallUsage": {
|
||||
"src": "packages/zappier/src/pricing.ts",
|
||||
"kind": "interface",
|
||||
"fields": [
|
||||
("metadataBytes", "number", "UTF-8 length of metadata JSON"),
|
||||
("attachmentBytes", "number", "Sum of uploaded file sizes"),
|
||||
],
|
||||
"used_by": ["meter()", "quoteCall()"],
|
||||
"returns_in": "constructed in meter middleware",
|
||||
},
|
||||
"Customer": {
|
||||
"src": "packages/zappier/src/auth.ts",
|
||||
"kind": "interface",
|
||||
"fields": [
|
||||
("id", "string", "cust_…"),
|
||||
("name", "string", "Display"),
|
||||
("tierId", "string", "Matches TierConfig.id"),
|
||||
("apiKey", "string", "x-api-key value"),
|
||||
("multiplierOverride", "number?", "Beats tier multiplier"),
|
||||
("email", "string?", "Portal login"),
|
||||
],
|
||||
"used_by": ["apiKeyAuth", "portal", "invoices", "usage.summaryFor"],
|
||||
"returns_in": "CustomerRepo.findByApiKey / findById",
|
||||
},
|
||||
"AddNumbersResult": {
|
||||
"src": "packages/verae-activate/creates/add_numbers.js",
|
||||
"kind": "Zapier create output",
|
||||
"fields": [
|
||||
("number1", "number", "First addend (finite)"),
|
||||
("number2", "number", "Second addend (finite)"),
|
||||
("sum", "number", "number1 + number2"),
|
||||
("mode", "'local' | 'hosted'", "local = Zapier cloud arithmetic; hosted = POST /v1/add"),
|
||||
],
|
||||
"used_by": ["Zapier Add Numbers action", "optional zappier POST /v1/add"],
|
||||
"returns_in": "perform(z, bundle) → single object (creates never return arrays)",
|
||||
},
|
||||
"TimestampRequest": {
|
||||
"src": "docs/00-sources/veraetime-openapi.yaml",
|
||||
"kind": "OpenAPI schema",
|
||||
"fields": [
|
||||
("data", "string (required)", "Payload to hash/timestamp"),
|
||||
("hashAlg", "string?", "Defaults SHA256"),
|
||||
],
|
||||
"used_by": ["POST /api/timestamp", "middleware timestampService.create"],
|
||||
"returns_in": "request body only",
|
||||
},
|
||||
"TimestampResponse": {
|
||||
"src": "docs/00-sources/veraetime-openapi.yaml",
|
||||
"kind": "OpenAPI schema",
|
||||
"fields": [("jobId", "string", "UUID of async job")],
|
||||
"used_by": ["Zapier Create Timestamp"],
|
||||
"returns_in": "HTTP 202 JSON",
|
||||
},
|
||||
"StatusResponse": {
|
||||
"src": "docs/00-sources/veraetime-openapi.yaml",
|
||||
"kind": "OpenAPI schema",
|
||||
"fields": [
|
||||
("id", "string", "jobId"),
|
||||
("status", "'pending' | 'completed' | 'failed'", "Job state"),
|
||||
("error", "string?", "If failed"),
|
||||
("result", "string?", "Certificate if completed"),
|
||||
("completedAt", "string date-time?", ""),
|
||||
("metadata", "object?", "blockIndex, timestamp, certificate, block, chain"),
|
||||
],
|
||||
"used_by": ["GET /api/status/{jobId}", "Find Job Status search"],
|
||||
"returns_in": "HTTP 200 JSON",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def body(name: str, spec: dict) -> str:
|
||||
rows = "\n".join(f"| `{n}` | `{t}` | {d} |" for n, t, d in spec["fields"])
|
||||
return f"""# {name}
|
||||
|
||||
**Kind:** {spec['kind']}
|
||||
**Defined in:** `{spec['src']}`
|
||||
|
||||
## Fields
|
||||
|
||||
| Field | Type | Meaning |
|
||||
|-------|------|---------|
|
||||
{rows}
|
||||
|
||||
## Who constructs it
|
||||
|
||||
{spec['returns_in']}
|
||||
|
||||
## Who consumes it
|
||||
|
||||
{spec['used_by'] if isinstance(spec['used_by'], str) else ', '.join(f'`{x}`' for x in spec['used_by'])}
|
||||
|
||||
## Related modules
|
||||
|
||||
See `docs/modules/` for the functions that take these types as parameters and the values they return.
|
||||
"""
|
||||
|
||||
|
||||
def main() -> None:
|
||||
MD.mkdir(parents=True, exist_ok=True)
|
||||
RST.mkdir(parents=True, exist_ok=True)
|
||||
PDF.mkdir(parents=True, exist_ok=True)
|
||||
styles = getSampleStyleSheet()
|
||||
for name, spec in MODELS.items():
|
||||
text = body(name, spec)
|
||||
(MD / f"{name}.md").write_text(text, encoding="utf-8")
|
||||
(RST / f"{name}.rst").write_text(
|
||||
f"{name}\n{'=' * len(name)}\n\n.. include:: {name}.md\n :literal:\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(RST / f"{name}.md").write_text(text, encoding="utf-8")
|
||||
pdf_path = PDF / f"{name}.pdf"
|
||||
doc = SimpleDocTemplate(str(pdf_path), pagesize=letter, leftMargin=0.7 * inch, rightMargin=0.7 * inch)
|
||||
story = []
|
||||
for line in text.splitlines():
|
||||
if line.startswith("# "):
|
||||
story.append(Paragraph(line[2:].replace("&", "&"), styles["Title"]))
|
||||
elif line.startswith("## "):
|
||||
story.append(Spacer(1, 8))
|
||||
story.append(Paragraph(line[3:].replace("&", "&"), styles["Heading2"]))
|
||||
elif line.strip():
|
||||
story.append(Paragraph(line.replace("&", "&").replace("|", " · ").replace("`", ""), styles["BodyText"]))
|
||||
else:
|
||||
story.append(Spacer(1, 4))
|
||||
doc.build(story)
|
||||
print(f"wrote {len(MODELS)} models")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
326
scripts/gen-module-docs.py
Normal file
326
scripts/gen-module-docs.py
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Generate per-module Markdown + RST API docs from JS/TS sources."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
OUT_MD = ROOT / "docs" / "modules"
|
||||
OUT_RST = ROOT / "docs" / "sphinx" / "modules"
|
||||
|
||||
PACKAGES = [
|
||||
("zappier", ROOT / "packages" / "zappier" / "src", (".ts",)),
|
||||
("verae-zapier-middleware", ROOT / "packages" / "verae-zapier-middleware" / "src", (".js",)),
|
||||
("verae-activate", ROOT / "packages" / "verae-activate", (".js",)),
|
||||
("verae-zapier", ROOT / "packages" / "verae-zapier", (".js",)),
|
||||
]
|
||||
|
||||
SKIP_PARTS = {"test", "tests", "node_modules", "dist"}
|
||||
|
||||
FN_RE = re.compile(
|
||||
r"""^(?P<indent> *)(?:export\s+)?(?:async\s+)?function\s+(?P<name>[A-Za-z0-9_]+)\s*\((?P<params>[^)]*)\)""",
|
||||
re.M,
|
||||
)
|
||||
ARROW_RE = re.compile(
|
||||
r"""^(?P<indent> *)(?:export\s+)?(?:const|let|var)\s+(?P<name>[A-Za-z0-9_]+)\s*=\s*(?:async\s*)?\((?P<params>[^)]*)\)\s*=>""",
|
||||
re.M,
|
||||
)
|
||||
CLASS_RE = re.compile(r"""^(?:export\s+)?class\s+(?P<name>[A-Za-z0-9_]+)""", re.M)
|
||||
IFACE_RE = re.compile(r"""^(?:export\s+)?interface\s+(?P<name>[A-Za-z0-9_]+)""", re.M)
|
||||
TYPE_RE = re.compile(r"""^(?:export\s+)?type\s+(?P<name>[A-Za-z0-9_]+)\s*=""", re.M)
|
||||
METHOD_RE = re.compile(
|
||||
r"""^ (?:async\s+)?(?P<name>[A-Za-z0-9_]+)\s*\((?P<params>[^)]*)\)""",
|
||||
re.M,
|
||||
)
|
||||
EXPORTS_RE = re.compile(r"""^export\s+(?:async\s+)?(?:function|class|const|let|type|interface|enum)\s+([A-Za-z0-9_]+)""", re.M)
|
||||
MODULE_EXPORTS_RE = re.compile(r"""module\.exports\s*=\s*\{([^}]+)\}""", re.S)
|
||||
REQUIRE_RE = re.compile(r"""require\(['\"]([^'\"]+)['\"]\)""")
|
||||
IMPORT_RE = re.compile(r"""from ['\"]([^'\"]+)['\"]""")
|
||||
JSDOC_RE = re.compile(r"""/\*\*(.*?)\*/""", re.S)
|
||||
PARAM_RE = re.compile(r"""@param\s+(?:\{([^}]+)\}\s+)?(?:\[)?([A-Za-z0-9_.]+)""")
|
||||
RETURN_RE = re.compile(r"""@returns?\s+(?:\{([^}]+)\})?\s*(.*)""")
|
||||
CALL_RE = re.compile(r"""\b([A-Za-z0-9_]+)\s*\(""")
|
||||
|
||||
|
||||
def should_skip(path: Path) -> bool:
|
||||
return any(p in SKIP_PARTS for p in path.parts)
|
||||
|
||||
|
||||
def rel_mod(pkg: str, src_root: Path, file: Path) -> str:
|
||||
rel = file.relative_to(src_root).with_suffix("")
|
||||
return f"{pkg}/{rel.as_posix()}"
|
||||
|
||||
|
||||
def parse_jsdoc_blocks(text: str) -> list[dict]:
|
||||
blocks = []
|
||||
for m in JSDOC_RE.finditer(text):
|
||||
body = m.group(1)
|
||||
params = PARAM_RE.findall(body)
|
||||
ret = RETURN_RE.search(body)
|
||||
summary = " ".join(
|
||||
line.strip(" *")
|
||||
for line in body.splitlines()
|
||||
if line.strip() and not line.strip().startswith("*") and "@" not in line
|
||||
).strip()
|
||||
blocks.append(
|
||||
{
|
||||
"summary": summary[:400],
|
||||
"params": [{"type": t or "unknown", "name": n} for t, n in params],
|
||||
"returns_type": (ret.group(1) if ret else "") or "unknown",
|
||||
"returns_note": (ret.group(2).strip() if ret else ""),
|
||||
"end": m.end(),
|
||||
}
|
||||
)
|
||||
return blocks
|
||||
|
||||
|
||||
def nearest_jsdoc(blocks: list[dict], pos: int) -> dict | None:
|
||||
best = None
|
||||
for b in blocks:
|
||||
if b["end"] <= pos and pos - b["end"] < 180:
|
||||
best = b
|
||||
return best
|
||||
|
||||
|
||||
def parse_file(path: Path) -> dict:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
blocks = parse_jsdoc_blocks(text)
|
||||
functions = []
|
||||
for rx in (FN_RE, ARROW_RE):
|
||||
for m in rx.finditer(text):
|
||||
name = m.group("name")
|
||||
params = m.group("params").strip()
|
||||
js = nearest_jsdoc(blocks, m.start())
|
||||
functions.append(
|
||||
{
|
||||
"name": name,
|
||||
"params": params,
|
||||
"jsdoc": js,
|
||||
"kind": "function",
|
||||
}
|
||||
)
|
||||
classes = [{"name": m.group("name")} for m in CLASS_RE.finditer(text)]
|
||||
types = [{"name": m.group("name"), "kind": "interface"} for m in IFACE_RE.finditer(text)]
|
||||
types += [{"name": m.group("name"), "kind": "type"} for m in TYPE_RE.finditer(text)]
|
||||
methods = []
|
||||
for m in METHOD_RE.finditer(text):
|
||||
name = m.group("name")
|
||||
if name in {"if", "for", "while", "switch", "catch", "function"}:
|
||||
continue
|
||||
methods.append({"name": name, "params": m.group("params").strip()})
|
||||
exports = EXPORTS_RE.findall(text)
|
||||
me = MODULE_EXPORTS_RE.search(text)
|
||||
if me:
|
||||
exports += re.findall(r"([A-Za-z0-9_]+)\s*[:,]", me.group(1))
|
||||
requires = REQUIRE_RE.findall(text)
|
||||
imports = IMPORT_RE.findall(text)
|
||||
calls = []
|
||||
for c in CALL_RE.findall(text):
|
||||
if c[0].isupper() or c in {
|
||||
"if",
|
||||
"for",
|
||||
"while",
|
||||
"switch",
|
||||
"catch",
|
||||
"function",
|
||||
"return",
|
||||
"await",
|
||||
"typeof",
|
||||
"new",
|
||||
"super",
|
||||
"Number",
|
||||
"String",
|
||||
"Boolean",
|
||||
"Object",
|
||||
"Array",
|
||||
"JSON",
|
||||
"Buffer",
|
||||
"Error",
|
||||
"Date",
|
||||
"Promise",
|
||||
"Math",
|
||||
"console",
|
||||
"require",
|
||||
"describe",
|
||||
"it",
|
||||
"expect",
|
||||
}:
|
||||
continue
|
||||
calls.append(c)
|
||||
# unique preserve order
|
||||
seen = set()
|
||||
call_uniq = []
|
||||
for c in calls:
|
||||
if c not in seen:
|
||||
seen.add(c)
|
||||
call_uniq.append(c)
|
||||
return {
|
||||
"path": str(path),
|
||||
"functions": functions,
|
||||
"classes": classes,
|
||||
"types": types,
|
||||
"methods": methods[:80],
|
||||
"exports": list(dict.fromkeys(exports)),
|
||||
"requires": list(dict.fromkeys(requires)),
|
||||
"imports": list(dict.fromkeys(imports)),
|
||||
"calls": call_uniq[:60],
|
||||
"lines": text.count("\n") + 1,
|
||||
}
|
||||
|
||||
|
||||
def render_md(pkg: str, rel: str, info: dict) -> str:
|
||||
lines = [
|
||||
f"# `{rel}`",
|
||||
"",
|
||||
f"**Package:** `{pkg}` ",
|
||||
f"**Source:** `{info['path'].replace(str(ROOT) + '/', '')}` ",
|
||||
f"**Lines:** {info['lines']}",
|
||||
"",
|
||||
"## What this module is",
|
||||
"",
|
||||
f"Implementation module in `{pkg}`. The tables below are extracted from the source (signatures + JSDoc).",
|
||||
"",
|
||||
"## Exports",
|
||||
"",
|
||||
]
|
||||
if info["exports"]:
|
||||
lines.append(", ".join(f"`{e}`" for e in info["exports"]))
|
||||
else:
|
||||
lines.append("_No named `export` / `module.exports` keys detected._")
|
||||
lines += ["", "## Types / interfaces / classes", ""]
|
||||
if info["types"] or info["classes"]:
|
||||
lines += ["| Kind | Name |", "|------|------|"]
|
||||
for t in info["types"]:
|
||||
lines.append(f"| {t['kind']} | `{t['name']}` |")
|
||||
for c in info["classes"]:
|
||||
lines.append(f"| class | `{c['name']}` |")
|
||||
else:
|
||||
lines.append("_None extracted._")
|
||||
lines += ["", "## Functions", ""]
|
||||
if info["functions"]:
|
||||
lines += [
|
||||
"| Name | Parameters | Param types (JSDoc) | Returns | Calls (same file / helpers) |",
|
||||
"|------|------------|---------------------|---------|-----------------------------|",
|
||||
]
|
||||
for fn in info["functions"]:
|
||||
js = fn["jsdoc"] or {}
|
||||
ptypes = ", ".join(f"{p['name']}: `{p['type']}`" for p in js.get("params", [])) or "—"
|
||||
ret = f"`{js.get('returns_type', 'unknown')}`"
|
||||
if js.get("returns_note"):
|
||||
ret += f" — {js['returns_note'][:120]}"
|
||||
params = fn["params"].replace("|", "\\|") or "(none)"
|
||||
lines.append(
|
||||
f"| `{fn['name']}` | `{params}` | {ptypes} | {ret} | see Call graph |"
|
||||
)
|
||||
if js.get("summary"):
|
||||
lines.append(f"| | _{js['summary']}_ | | | |")
|
||||
else:
|
||||
lines.append("_No top-level functions extracted._")
|
||||
if info["methods"]:
|
||||
lines += ["", "## Methods (class / object)", ""]
|
||||
lines += ["| Name | Parameters |", "|------|------------|"]
|
||||
for m in info["methods"]:
|
||||
lines.append(f"| `{m['name']}` | `{m['params'] or '(none)'}` |")
|
||||
lines += ["", "## What it imports / requires", ""]
|
||||
deps = info["imports"] + info["requires"]
|
||||
if deps:
|
||||
for d in deps:
|
||||
lines.append(f"- `{d}`")
|
||||
else:
|
||||
lines.append("_No imports detected._")
|
||||
lines += ["", "## Call graph (identifiers invoked)", ""]
|
||||
if info["calls"]:
|
||||
lines.append(", ".join(f"`{c}`" for c in info["calls"]))
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Each identifier is a call site in this file. Follow the import list to see the defining module; "
|
||||
"open that module’s MD for parameter and return types."
|
||||
)
|
||||
else:
|
||||
lines.append("_No local call identifiers extracted._")
|
||||
lines += [
|
||||
"",
|
||||
"## Return values (how to read this)",
|
||||
"",
|
||||
"- HTTP route handlers return Express `res.json(...)` bodies (see route docs).",
|
||||
"- Zapier `perform` functions return a **single object** (creates) or an **array** (triggers/searches).",
|
||||
"- Pricing functions return integer **cents** on `Quote.totalCents`.",
|
||||
"",
|
||||
]
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def render_rst(title: str, md_rel: str) -> str:
|
||||
return f"""{title}
|
||||
{'=' * len(title)}
|
||||
|
||||
.. include:: /{md_rel}
|
||||
:parser: myst_parser.sphinx_
|
||||
"""
|
||||
|
||||
|
||||
def main() -> None:
|
||||
OUT_MD.mkdir(parents=True, exist_ok=True)
|
||||
OUT_RST.mkdir(parents=True, exist_ok=True)
|
||||
index_entries = []
|
||||
rst_entries = []
|
||||
count = 0
|
||||
for pkg, src_root, suffixes in PACKAGES:
|
||||
if not src_root.exists():
|
||||
continue
|
||||
files = sorted(
|
||||
p
|
||||
for p in src_root.rglob("*")
|
||||
if p.suffix in suffixes and p.is_file() and not should_skip(p)
|
||||
)
|
||||
for f in files:
|
||||
if "node_modules" in f.parts:
|
||||
continue
|
||||
rel = rel_mod(pkg, src_root, f)
|
||||
info = parse_file(f)
|
||||
md = render_md(pkg, rel, info)
|
||||
md_path = OUT_MD / f"{rel}.md"
|
||||
md_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
md_path.write_text(md, encoding="utf-8")
|
||||
rst_path = OUT_RST / f"{rel}.rst"
|
||||
rst_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
rst_title = rel.replace("/", ".")
|
||||
# myst include path relative to sphinx conf dir
|
||||
include = f"../modules/{rel}.md"
|
||||
# Sphinx include is relative to the RST file: copy MD beside it.
|
||||
sibling_md = rst_path.with_suffix(".md")
|
||||
sibling_md.write_text(md, encoding="utf-8")
|
||||
rst_path.write_text(
|
||||
f"{rst_title}\n{'=' * len(rst_title)}\n\n"
|
||||
f"Generated API sheet for ``{rel}``.\n\n"
|
||||
f".. include:: {sibling_md.name}\n"
|
||||
f" :literal:\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
index_entries.append(rel)
|
||||
rst_entries.append(f"modules/{rel}")
|
||||
count += 1
|
||||
|
||||
idx = ["# Module documentation index", "", "One MD file per source module.", ""]
|
||||
for rel in index_entries:
|
||||
idx.append(f"- [{rel}]({rel}.md)")
|
||||
(OUT_MD / "README.md").write_text("\n".join(idx) + "\n", encoding="utf-8")
|
||||
|
||||
toctree = "\n ".join(rst_entries)
|
||||
sphinx_index = f"""Verae × Zapier module reference
|
||||
=================================
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:caption: Modules
|
||||
|
||||
{toctree}
|
||||
"""
|
||||
(ROOT / "docs" / "sphinx" / "index.rst").write_text(sphinx_index, encoding="utf-8")
|
||||
print(f"wrote {count} module docs")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
112
scripts/gen-module-pdfs.py
Normal file
112
scripts/gen-module-pdfs.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Render each docs/modules/*.md file to a PDF via reportlab."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from reportlab.lib.pagesizes import letter
|
||||
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
||||
from reportlab.lib.units import inch
|
||||
from reportlab.platypus import PageBreak, Paragraph, Preformatted, SimpleDocTemplate, Spacer
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MD_ROOT = ROOT / "docs" / "modules"
|
||||
PDF_ROOT = ROOT / "docs" / "modules-pdf"
|
||||
|
||||
|
||||
def md_to_flowables(text: str, styles):
|
||||
story = []
|
||||
in_code = False
|
||||
code_buf = []
|
||||
for raw in text.splitlines():
|
||||
line = raw.rstrip()
|
||||
if line.startswith("```"):
|
||||
if in_code:
|
||||
story.append(Preformatted("\n".join(code_buf) or " ", styles["ModuleCode"]))
|
||||
code_buf = []
|
||||
in_code = False
|
||||
else:
|
||||
in_code = True
|
||||
continue
|
||||
if in_code:
|
||||
code_buf.append(line)
|
||||
continue
|
||||
if line.startswith("# "):
|
||||
story.append(Paragraph(esc(line[2:]), styles["Title"]))
|
||||
elif line.startswith("## "):
|
||||
story.append(Spacer(1, 8))
|
||||
story.append(Paragraph(esc(line[3:]), styles["Heading2"]))
|
||||
elif line.startswith("### "):
|
||||
story.append(Paragraph(esc(line[4:]), styles["Heading3"]))
|
||||
elif line.startswith("|"):
|
||||
story.append(Paragraph(esc(line.replace("|", " · ")), styles["BodyText"]))
|
||||
elif line.startswith("- "):
|
||||
story.append(Paragraph("• " + esc(line[2:]), styles["BodyText"]))
|
||||
elif line.strip() == "":
|
||||
story.append(Spacer(1, 6))
|
||||
else:
|
||||
story.append(Paragraph(esc(line), styles["BodyText"]))
|
||||
return story
|
||||
|
||||
|
||||
def esc(s: str) -> str:
|
||||
return (
|
||||
s.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("`", "")
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
PDF_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
styles = getSampleStyleSheet()
|
||||
styles.add(
|
||||
ParagraphStyle(
|
||||
name="ModuleCode",
|
||||
fontName="Courier",
|
||||
fontSize=7,
|
||||
leading=9,
|
||||
)
|
||||
)
|
||||
mds = sorted(MD_ROOT.rglob("*.md"))
|
||||
n = 0
|
||||
combined = []
|
||||
for md in mds:
|
||||
rel = md.relative_to(MD_ROOT)
|
||||
pdf_path = PDF_ROOT / rel.with_suffix(".pdf")
|
||||
pdf_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
text = md.read_text(encoding="utf-8", errors="replace")
|
||||
doc = SimpleDocTemplate(
|
||||
str(pdf_path),
|
||||
pagesize=letter,
|
||||
leftMargin=0.7 * inch,
|
||||
rightMargin=0.7 * inch,
|
||||
topMargin=0.7 * inch,
|
||||
bottomMargin=0.7 * inch,
|
||||
)
|
||||
flow = md_to_flowables(text, styles)
|
||||
doc.build(flow)
|
||||
combined.extend(flow)
|
||||
combined.append(PageBreak())
|
||||
n += 1
|
||||
|
||||
try:
|
||||
from pypdf import PdfWriter
|
||||
|
||||
writer = PdfWriter()
|
||||
for md in mds:
|
||||
pdf_path = PDF_ROOT / md.relative_to(MD_ROOT).with_suffix(".pdf")
|
||||
if pdf_path.exists():
|
||||
writer.append(str(pdf_path))
|
||||
book = PDF_ROOT / "ALL-MODULES.pdf"
|
||||
with open(book, "wb") as fh:
|
||||
writer.write(fh)
|
||||
print(f"wrote {n} pdfs + {book.name}")
|
||||
except Exception as exc:
|
||||
print(f"wrote {n} pdfs (combined book skipped: {exc})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue