Skip to content
Source
Sign inSign up

generate-api-reference.ts

1,247 lines41.9 KB

1// Generates the provider API reference under website/src/content/docs/providers
2// (plus the providers sidebar and reference redirects) from source JSDoc.
3//
4// Usage: node scripts/generate-api-reference.ts [--copy-markers]
5// --copy-markers (or API_REFERENCE_COPY_MARKERS=1) emits the website's
6// dev-time copy-editor markers.
7import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
8import * as NodeServices from "@effect/platform-node/NodeServices";
9import { Command, Flag } from "effect/cli";
10import * as Config from "effect/Config";
11import * as Console from "effect/Console";
12import * as Data from "effect/Data";
13import * as Effect from "effect/Effect";
14import * as FileSystem from "effect/FileSystem";
15import * as Path from "effect/Path";
16import type * as PlatformError from "effect/PlatformError";
17import * as ts from "tsrs/unstable/ast";
18import type { Node, SourceFile } from "tsrs/unstable/ast";
19import {
20 docCommentLines,
21 jsdocCopyId,
22 LINK_TAG_RE,
23 markDocLines,
24 normalizeLinkTarget,
25 parseLinkTag,
26} from "./jsdoc-blocks.ts";
27import { createSyntaxProject } from "./typescript-source.ts";
28​
29interface SourceRoot {
30 /** Directory scanned for documented source files (repo-relative). */
31 srcRoot: string;
32 /**
33 * Synthetic provider name for flat single-provider packages (their files
34 * sit directly at `srcRoot`); empty for the alchemy package, whose
35 * top-level directories ARE the providers.
36 */
37 providerPrefix: string;
38 /** Display prefix for the page's `Source:` line. */
39 sourceDisplayPrefix: string;
41​
42/** Source roots, relative to the repo root (resolved in `main`). */
43const sourceRoots: SourceRoot[] = [
44 { srcRoot: "packages/alchemy/src", providerPrefix: "", sourceDisplayPrefix: "src" },
45 {
46 srcRoot: "packages/better-auth/src",
47 providerPrefix: "BetterAuth",
48 sourceDisplayPrefix: "packages/better-auth/src",
49 },
50 { srcRoot: "packages/git/src", providerPrefix: "Git", sourceDisplayPrefix: "packages/git/src" },
51 {
52 srcRoot: "packages/drizzle/src",
53 providerPrefix: "Drizzle",
54 sourceDisplayPrefix: "packages/drizzle/src",
55 },
56 {
57 srcRoot: "packages/acme/src",
58 providerPrefix: "ACME",
59 sourceDisplayPrefix: "packages/acme/src",
60 },
61 {
62 srcRoot: "packages/axiom/src",
63 providerPrefix: "Axiom",
64 sourceDisplayPrefix: "packages/axiom/src",
65 },
66 {
67 srcRoot: "packages/doppler/src",
68 providerPrefix: "Doppler",
69 sourceDisplayPrefix: "packages/doppler/src",
70 },
71 {
72 srcRoot: "packages/infisical/src",
73 providerPrefix: "Infisical",
74 sourceDisplayPrefix: "packages/infisical/src",
75 },
76 {
77 srcRoot: "packages/turso/src",
78 providerPrefix: "Turso",
79 sourceDisplayPrefix: "packages/turso/src",
80 },
81 {
82 srcRoot: "packages/planetscale/src",
83 providerPrefix: "Planetscale",
84 sourceDisplayPrefix: "packages/planetscale/src",
85 },
86 {
87 srcRoot: "packages/neon/src",
88 providerPrefix: "Neon",
89 sourceDisplayPrefix: "packages/neon/src",
90 },
91 {
92 srcRoot: "packages/prisma/src",
93 providerPrefix: "Prisma",
94 sourceDisplayPrefix: "packages/prisma/src",
95 },
96 {
97 srcRoot: "packages/hetzner/src",
98 providerPrefix: "Hetzner",
99 sourceDisplayPrefix: "packages/hetzner/src",
100 },
101 {
102 srcRoot: "packages/railway/src",
103 providerPrefix: "Railway",
104 sourceDisplayPrefix: "packages/railway/src",
105 },
106 { srcRoot: "packages/fly/src", providerPrefix: "Fly", sourceDisplayPrefix: "packages/fly/src" },
107 {
108 srcRoot: "packages/github/src",
109 providerPrefix: "GitHub",
110 sourceDisplayPrefix: "packages/github/src",
111 },
112 {
113 srcRoot: "packages/stripe/src",
114 providerPrefix: "Stripe",
115 sourceDisplayPrefix: "packages/stripe/src",
116 },
117 {
118 srcRoot: "packages/cloudflare/src",
119 providerPrefix: "Cloudflare",
120 sourceDisplayPrefix: "packages/cloudflare/src",
121 },
122 { srcRoot: "packages/gcp/src", providerPrefix: "GCP", sourceDisplayPrefix: "packages/gcp/src" },
123 { srcRoot: "packages/aws/src", providerPrefix: "AWS", sourceDisplayPrefix: "packages/aws/src" },
124 {
125 srcRoot: "packages/domain/src",
126 providerPrefix: "Domain",
127 sourceDisplayPrefix: "packages/domain/src",
128 },
129 {
130 srcRoot: "packages/kubernetes/src",
131 providerPrefix: "Kubernetes",
132 sourceDisplayPrefix: "packages/kubernetes/src",
133 },
134 {
135 srcRoot: "packages/docker/src",
136 providerPrefix: "Docker",
137 sourceDisplayPrefix: "packages/docker/src",
138 },
139 { srcRoot: "packages/sql/src", providerPrefix: "SQL", sourceDisplayPrefix: "packages/sql/src" },
140];
141​
142interface FileEntry {
143 /** Output-relative path (provider-prefixed for flat packages). */
144 relativePath: string;
145 absolutePath: string;
146 /** Display path for the page's `Source:` blockquote. */
147 sourceDisplay: string;
149​
150interface ExampleBlock {
151 title: string;
152 body: string;
154​
155interface ExampleSection {
156 title: string;
157 description: string;
158 examples: ExampleBlock[];
160​
161interface PageDoc {
162 title: string;
163 /** Display path for the `Source:` blockquote. */
164 sourceDisplay: string;
165 summary: string;
166 sections: ExampleSection[];
167 /** True for `@layer` pages — renders the Layer metadata line. */
168 isLayer: boolean;
169 /** Service tags the Layer provides (`@provides`). */
170 provides: string[];
171 /** Optional peer dependencies (`@peer`). */
172 peers: string[];
174​
175// Paths are POSIX on every platform we run on; also fold Windows separators.
176const normalizeSlashes = (value: string) => value.replaceAll("\\", "/");
177​
178const isSourceFile = (baseName: string) =>
179 (baseName.endsWith(".ts") || baseName.endsWith(".tsx")) &&
180 !baseName.endsWith(".d.ts") &&
181 baseName !== "index.ts";
182​
183const discoverFiles = Effect.fn(function* (repoRoot: string, root: SourceRoot) {
184 const fs = yield* FileSystem.FileSystem;
185 const path = yield* Path.Path;
186 const srcRoot = path.join(repoRoot, root.srcRoot);
187 const entries: FileEntry[] = [];
188​
189 // Flat single-provider packages: every file under srcRoot belongs to the
190 // synthetic provider directory.
191 if (root.providerPrefix) {
192 const files = yield* fs.readDirectory(srcRoot, { recursive: true });
193 for (const file of files) {
194 if (!isSourceFile(path.basename(file))) continue;
195 entries.push({
196 relativePath: path.join(root.providerPrefix, file),
197 absolutePath: path.join(srcRoot, file),
198 sourceDisplay: `${root.sourceDisplayPrefix}/${normalizeSlashes(file)}`,
199 });
200 }
201 entries.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
202 return entries;
203 }
204​
205 for (const dir of yield* fs.readDirectory(srcRoot)) {
206 const dirPath = path.join(srcRoot, dir);
207 const info = yield* fs.stat(dirPath);
208 if (info.type !== "Directory") continue;
209 const files = yield* fs
210 .readDirectory(dirPath, { recursive: true })
211 .pipe(Effect.orElseSucceed((): string[] => []));
212​
213 for (const file of files) {
214 if (!isSourceFile(path.basename(file))) continue;
215​
216 const relativePath = path.join(dir, file);
217 entries.push({
218 relativePath,
219 absolutePath: path.join(srcRoot, relativePath),
220 sourceDisplay: `${root.sourceDisplayPrefix}/${normalizeSlashes(relativePath)}`,
221 });
222 }
223 }
224​
225 entries.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
226 return entries;
227});
228​
229function getJsDocText(node: Node): string {
230 return node.jsDoc?.map((doc) => doc.getText()).join("\n") ?? "";
232​
233function cleanDocComment(raw: string): string {
234 return raw
235 .replace(/^\/\*\*?/, "")
236 .replace(/\*\/$/, "")
237 .split("\n")
238 .map((line) => line.replace(/^\s*\*\s?/, ""))
239 .join("\n");
241​
242interface ParsedJSDoc {
243 summary: string;
244 sections: ExampleSection[];
245 hasResourceTag: boolean;
246 hasBindingTag: boolean;
247 /** `@layer` — a Layer factory providing a Context service. */
248 hasLayerTag: boolean;
249 /**
250 * `@primitive` — a building block exported as a module's `make` (e.g.
251 * `Domain.Event`). Its page is named after the file, not the export.
252 */
253 hasPrimitiveTag: boolean;
254 /** `@provides <Service.Tag>` — service tags the Layer satisfies. */
255 provides: string[];
256 /** `@peer <package>` — optional peer dependencies the Layer needs. */
257 peers: string[];
258 category: string;
259 product: string;
260 /** `@title <Heading>` — overrides the section heading (the export name). */
261 title: string;
263​
264/** Where a declaration's JSDoc lives, for copy markers. */
265interface MarkerContext {
266 /** Repo-relative path of the source file. */
267 file: string;
268 /** Full source text (JSDoc offsets index into it). */
269 text: string;
271​
272function docLines(node: Node, raw: string, markers?: MarkerContext): string[] {
273 const docs = node.jsDoc ?? [];
274 // Markers need one comment per declaration to map blocks to source.
275 const start = docs[0]?.getStart();
276 // `getStart()` can land on leading `//` trivia; only map clean comments.
277 if (
278 markers &&
279 docs.length === 1 &&
280 start !== undefined &&
281 markers.text.startsWith("/**", start)
282 ) {
283 return markDocLines(docCommentLines(markers.text, start), (block) =>
284 jsdocCopyId(markers.file, start, block),
285 );
286 }
287 return cleanDocComment(raw).split("\n");
289​
290function parseJSDoc(node: Node, markers?: MarkerContext): ParsedJSDoc {
291 const raw = getJsDocText(node);
292 if (!raw) {
293 return {
294 summary: "",
295 sections: [],
296 hasResourceTag: false,
297 hasBindingTag: false,
298 hasLayerTag: false,
299 hasPrimitiveTag: false,
300 provides: [],
301 peers: [],
302 category: "",
303 product: "",
304 title: "",
305 };
306 }
307​
308 const lines = docLines(node, raw, markers);
309​
310 const summaryLines: string[] = [];
311 const sections: ExampleSection[] = [];
312 const provides: string[] = [];
313 const peers: string[] = [];
314 let hasResourceTag = false;
315 let hasBindingTag = false;
316 let hasLayerTag = false;
317 let hasPrimitiveTag = false;
318 let category = "";
319 let product = "";
320 let title = "";
321 let sawTag = false;
322 let currentSection: ExampleSection | undefined;
323 let currentExample: ExampleBlock | undefined;
324​
325 let sectionDescLines: string[] = [];
326 let collectingSectionDesc = false;
327 let insideFence = false;
328​
329 const flushExample = () => {
330 if (!currentExample) return;
331 currentExample.body = currentExample.body.trim();
332 if (!currentSection) {
333 currentSection = { title: "Examples", description: "", examples: [] };
334 sections.push(currentSection);
335 }
336 currentSection.examples.push(currentExample);
337 currentExample = undefined;
338 };
339​
340 const flushSectionDesc = () => {
341 if (currentSection && sectionDescLines.length > 0) {
342 currentSection.description = sectionDescLines.join("\n").trim();
343 }
344 sectionDescLines = [];
345 collectingSectionDesc = false;
346 };
347​
348 for (const line of lines) {
349 // Track fenced code blocks so an `@`-prefixed line inside an example
350 // (e.g. a decorator) is never mistaken for a JSDoc tag.
351 if (line.trim().startsWith("```")) {
352 insideFence = !insideFence;
353 }
354​
355 const proseHeading = insideFence
356 ? null
357 : line.trim().match(/^###\s+(.+?)\s+<!-- api-prose -->$/);
358 if (proseHeading) {
359 if (!sawTag) summaryLines.push(`### ${proseHeading[1]!.trim()}`);
360 continue;
361 }
362​
363 const section = insideFence ? null : line.trim().match(/^###\s+(.+)$/);
364 if (section) {
365 sawTag = true;
366 flushExample();
367 flushSectionDesc();
368 currentSection = { title: section[1]!.trim(), description: "", examples: [] };
369 sections.push(currentSection);
370 collectingSectionDesc = true;
371 continue;
372 }
373​
374 const example = insideFence ? null : line.trim().match(/^\*\*Example:\*\*\s*(.*)$/);
375 if (example) {
376 sawTag = true;
377 flushSectionDesc();
378 flushExample();
379 currentExample = { title: example[1]!.trim() || "Example", body: "" };
380 continue;
381 }
382​
383 const tag = insideFence ? null : line.trim().match(/^@(\w+)\s*(.*)$/);
384 if (tag) {
385 sawTag = true;
386 const [, name, rest] = tag;
387 const value = (rest ?? "").trim();
388 switch (name) {
389 case "resource":
390 hasResourceTag = true;
391 break;
392 case "binding":
393 hasBindingTag = true;
394 break;
395 case "layer":
396 hasLayerTag = true;
397 break;
398 case "primitive":
399 hasPrimitiveTag = true;
400 break;
401 case "provides":
402 if (value) provides.push(value);
403 break;
404 case "peer":
405 if (value) peers.push(value);
406 break;
407 case "category":
408 case "group":
409 if (value) category = value;
410 break;
411 case "product":
412 case "label":
413 if (value) product = value;
414 break;
415 case "title":
416 if (value) title = value;
417 break;
418 case "section":
419 flushExample();
420 flushSectionDesc();
421 currentSection = { title: value || "Examples", description: "", examples: [] };
422 sections.push(currentSection);
423 collectingSectionDesc = true;
424 break;
425 case "example":
426 flushSectionDesc();
427 flushExample();
428 currentExample = { title: value || "Example", body: "" };
429 break;
430 }
431 continue;
432 }
433​
434 if (!sawTag) {
435 summaryLines.push(line);
436 continue;
437 }
438​
439 if (currentExample) {
440 currentExample.body += `${line}\n`;
441 } else if (collectingSectionDesc) {
442 sectionDescLines.push(line);
443 }
444 }
445​
446 flushSectionDesc();
447 flushExample();
448​
449 return {
450 summary: summaryLines.join("\n").trim(),
451 sections,
452 hasResourceTag,
453 hasBindingTag,
454 hasLayerTag,
455 hasPrimitiveTag,
456 provides,
457 peers,
458 category,
459 product,
460 title,
461 };
463​
464function declName(node: Node): string {
465 if (ts.isVariableStatement(node)) {
466 return node.declarationList.declarations[0]?.name.getText() ?? "";
467 }
468 if (
469 ts.isClassDeclaration(node) ||
470 ts.isFunctionDeclaration(node) ||
471 ts.isInterfaceDeclaration(node) ||
472 ts.isTypeAliasDeclaration(node)
473 ) {
474 return node.name?.getText() ?? "";
475 }
476 return "";
478​
479interface Primary {
480 name: string;
481 doc: ParsedJSDoc;
482 category: string;
483 product: string;
485​
486const hasContent = (doc: ParsedJSDoc) => Boolean(doc.summary) || doc.sections.length > 0;
487​
488/**
489 * Map a public export name back to its local declaration name when a file
490 * re-exports under an alias, e.g. `export { VpcLinkResource as VpcLink }`
491 * lets us find the documented `VpcLinkResource` const from the tagged
492 * `VpcLink` interface.
493 */
494export function exportedNames(sourceFile: SourceFile): string[] {
495 const names = new Set<string>();
496 const addBinding = (name: ts.BindingName): void => {
497 if (ts.isIdentifier(name)) names.add(name.text);
498 else
499 for (const element of name.elements) {
500 if (ts.isBindingElement(element) && element.name) addBinding(element.name);
501 }
502 };
503 for (const statement of sourceFile.statements) {
504 if (ts.isExportDeclaration(statement)) {
505 if (statement.exportClause && ts.isNamedExports(statement.exportClause)) {
506 for (const spec of statement.exportClause.elements) names.add(spec.name.text);
507 } else if (statement.exportClause && ts.isNamespaceExport(statement.exportClause)) {
508 names.add(statement.exportClause.name.text);
509 }
510 } else if (ts.isExportAssignment(statement)) {
511 if (!statement.isExportEquals) names.add("default");
512 } else if (
513 (ts.isVariableStatement(statement) ||
514 ts.isClassDeclaration(statement) ||
515 ts.isFunctionDeclaration(statement) ||
516 ts.isInterfaceDeclaration(statement) ||
517 ts.isTypeAliasDeclaration(statement) ||
518 ts.isEnumDeclaration(statement) ||
519 ts.isModuleDeclaration(statement) ||
520 ts.isImportEqualsDeclaration(statement)) &&
521 statement.modifierFlags & ts.ModifierFlags.Export
522 ) {
523 if (statement.modifierFlags & ts.ModifierFlags.Default) names.add("default");
524 else if (ts.isVariableStatement(statement)) {
525 for (const declaration of statement.declarationList.declarations)
526 addBinding(declaration.name);
527 } else if (
528 ts.isClassDeclaration(statement) ||
529 ts.isFunctionDeclaration(statement) ||
530 ts.isInterfaceDeclaration(statement) ||
531 ts.isTypeAliasDeclaration(statement) ||
532 ts.isEnumDeclaration(statement) ||
533 ts.isModuleDeclaration(statement) ||
534 ts.isImportEqualsDeclaration(statement)
535 ) {
536 if (statement.name) names.add(statement.name.text);
537 }
538 }
539 }
540 return [...names];
542​
543function localNameForExport(sourceFile: SourceFile, publicName: string): string | undefined {
544 for (const ed of sourceFile.statements.filter(ts.isExportDeclaration)) {
545 if (ed.moduleSpecifier) continue;
546 for (const spec of ed.exportClause && ts.isNamedExports(ed.exportClause)
547 ? ed.exportClause.elements
548 : []) {
549 if (spec.name.text === publicName) {
550 return (spec.propertyName ?? spec.name).text;
551 }
552 }
553 }
554 return undefined;
556​
557/**
558 * The page for a file is owned by the single exported declaration tagged
559 * `@resource` or `@binding`, and named after it. Authors sometimes write the
560 * docs (summary/@section/@example) on a sibling declaration of the same name
561 * (an `interface X` paired with `const X`) or on an internal const that is
562 * re-exported under the tagged name (the ApiGateway `XResource as X` pattern).
563 * When the tagged declaration itself has no content, pull it from that related
564 * declaration so the page isn't dropped as empty.
565 */
566export function findTaggedPrimary(
567 sourceFile: SourceFile,
568 markers?: MarkerContext,
569): Primary | undefined {
570 const candidates: Node[] = [
571 ...sourceFile.statements
572 .filter(ts.isVariableStatement)
573 .filter((s) => Boolean(s.modifierFlags & ts.ModifierFlags.Export)),
574 ...sourceFile.statements
575 .filter(ts.isClassDeclaration)
576 .filter((c) => Boolean(c.modifierFlags & ts.ModifierFlags.Export)),
577 ...sourceFile.statements
578 .filter(ts.isInterfaceDeclaration)
579 .filter((i) => Boolean(i.modifierFlags & ts.ModifierFlags.Export)),
580 // An overloaded function carries its JSDoc on the first signature.
581 ...sourceFile.statements
582 .filter(ts.isFunctionDeclaration)
583 .filter((f) => Boolean(f.modifierFlags & ts.ModifierFlags.Export)),
584 ];
585​
586 for (const node of candidates) {
587 const doc = parseJSDoc(node, markers);
588 if (!doc.hasResourceTag && !doc.hasBindingTag && !doc.hasLayerTag && !doc.hasPrimitiveTag) {
589 continue;
590 }
591 // A primitive is its module's `make`; the page is named after the module.
592 const exportName = doc.hasPrimitiveTag
593 ? normalizeSlashes(sourceFile.fileName)
594 .split("/")
595 .pop()!
596 .replace(/\.tsx?$/, "")
597 : declName(node);
598 if (!exportName) continue;
599 const name = doc.title || exportName;
600 const category = doc.category;
601 const product = doc.product;
602 if (hasContent(doc)) return { name, doc, category, product };
603​
604 // Tagged declaration has no prose — look for the related declaration that
605 // carries the docs (same name, or re-exported under this name).
606 const localName = localNameForExport(sourceFile, exportName);
607 const related: Node[] = [
608 ...sourceFile.statements.filter(ts.isVariableStatement),
609 ...sourceFile.statements.filter(ts.isClassDeclaration),
610 ...sourceFile.statements.filter(ts.isInterfaceDeclaration),
611 ...sourceFile.statements.filter(ts.isTypeAliasDeclaration),
612 ].filter((d) => {
613 if (d === node) return false;
614 const dn = declName(d);
615 return dn === exportName || (localName !== undefined && dn === localName);
616 });
617​
618 let best: ParsedJSDoc | undefined;
619 for (const d of related) {
620 const pd = parseJSDoc(d, markers);
621 if (pd.sections.length > 0) {
622 best = pd;
623 break;
624 }
625 if (!best && pd.summary) best = pd;
626 }
627 // Borrowed prose keeps the TAGGED declaration's kind + metadata.
628 const merged: ParsedJSDoc = {
629 ...(best ?? doc),
630 hasResourceTag: doc.hasResourceTag,
631 hasBindingTag: doc.hasBindingTag,
632 hasLayerTag: doc.hasLayerTag,
633 hasPrimitiveTag: doc.hasPrimitiveTag,
634 provides: doc.provides,
635 peers: doc.peers,
636 };
637 return { name, doc: merged, category, product };
638 }
639 return undefined;
641​
642/** Resolves a `{@link}` symbol target to a generated page URL, if any. */
643type LinkResolver = (target: string) => string | undefined;
644​
645/**
646 * Build a per-page resolver factory. A symbol resolves to another generated
647 * page preferring (in order): a same-directory page named after it, a
648 * same-directory page whose source file exports it (an access-level service
649 * like `ReadNamespace` documented on its `ReadWriteNamespace` page), a unique
650 * name match within the same provider, a unique exporter within the same
651 * provider, then a unique global name match. Member suffixes
652 * (`Cluster#capacityProviders`), provider-qualified names (`Cloudflare.Worker`)
653 * and `FooProps` interfaces resolve to the base resource's page.
654 */
655function makeLinkResolverFactory(pages: PageEntry[]): (fromDir: string) => LinkResolver {
656 const byName = new Map<string, PageEntry[]>();
657 const byExport = new Map<string, PageEntry[]>();
658 for (const p of pages) {
659 if (!byName.has(p.resource)) byName.set(p.resource, []);
660 byName.get(p.resource)!.push(p);
661 for (const name of p.exports) {
662 if (!byExport.has(name)) byExport.set(name, []);
663 byExport.get(name)!.push(p);
664 }
665 }
666 const providers = new Set(pages.map((p) => p.provider));
667​
668 return (fromDir: string) => {
669 const fromProvider = fromDir.split("/")[0] ?? "";
670​
671 const lookup = (name: string, provider?: string): PageEntry | undefined => {
672 const scope = (list: PageEntry[] | undefined) =>
673 (list ?? []).filter((c) => !provider || c.provider === provider);
674 const named = scope(byName.get(name));
675 const exporting = scope(byExport.get(name));
676​
677 const sameDirNamed = named.filter((c) => c.dir === fromDir);
678 if (sameDirNamed.length === 1) return sameDirNamed[0];
679 const sameDirExporting = exporting.filter((c) => c.dir === fromDir);
680 if (sameDirExporting.length === 1) return sameDirExporting[0];
681 const providerNamed = named.filter((c) => c.provider === fromProvider);
682 if (providerNamed.length === 1) return providerNamed[0];
683 const providerExporting = exporting.filter((c) => c.provider === fromProvider);
684 if (providerExporting.length === 1) return providerExporting[0];
685 if (named.length === 1) return named[0];
686 return undefined;
687 };
688​
689 return (target: string) => {
690 // Strip a `#member` suffix — the link lands on the owning resource page.
691 const base = target.split("#")[0];
692​
693 const attempts: { name: string; provider?: string }[] = [{ name: base }];
694 if (base.includes(".")) {
695 const segments = base.split(".");
696 const first = segments[0];
697 const last = segments[segments.length - 1];
698 if (providers.has(first)) {
699 // Provider-qualified: `Cloudflare.Worker`, `Cloudflare.KV.Namespace`.
700 attempts.push({ name: last, provider: first });
701 } else {
702 // Member access on a symbol: `KeyPairProps.publicKeyMaterial`.
703 attempts.push({ name: first });
704 if (first.endsWith("Props")) {
705 attempts.push({ name: first.slice(0, -"Props".length) });
706 }
707 }
708 } else if (base.endsWith("Props")) {
709 attempts.push({ name: base.slice(0, -"Props".length) });
710 }
711​
712 for (const attempt of attempts) {
713 const found = lookup(attempt.name, attempt.provider);
714 if (found) return found.link;
715 }
716 return undefined;
717 };
718 };
720​
721const isUrl = (target: string) => /^https?:\/\//.test(target);
722​
723/**
724 * Replace `{@link ...}` tags with markdown links, skipping fenced code
725 * blocks. Symbols that don't resolve to a generated page render as inline
726 * code (or their label) instead of leaking the raw tag.
727 */
728/** Symbol targets that didn't resolve to a page, for the end-of-run summary. */
729const unresolvedLinkTargets = new Map<string, number>();
730​
731function linkifyMarkdown(markdown: string, resolve: LinkResolver): string {
732 const replaceTags = (chunk: string) =>
733 chunk.replace(LINK_TAG_RE, (_, inner: string) => {
734 // A tag wrapped across source lines carries the line break in its
735 // label — collapse it so the markdown link stays intact.
736 const parsed = parseLinkTag(inner.replace(/\s+/g, " "));
737 const label = parsed.label;
738 if (isUrl(parsed.target)) {
739 return `[${label ?? parsed.target}](${parsed.target})`;
740 }
741 const target = normalizeLinkTarget(parsed.target);
742 const url = resolve(target);
743 if (!url) {
744 unresolvedLinkTargets.set(target, (unresolvedLinkTargets.get(target) ?? 0) + 1);
745 }
746 const text = label ?? `\`${target}\``;
747 return url ? `[${text}](${url})` : text;
748 });
749​
750 // Apply across contiguous non-fence chunks (a `{@link}` may span lines);
751 // leave fenced code blocks untouched.
752 const out: string[] = [];
753 let chunk: string[] = [];
754 let insideFence = false;
755 const flushChunk = () => {
756 if (chunk.length > 0) {
757 out.push(replaceTags(chunk.join("\n")));
758 chunk = [];
759 }
760 };
761 for (const line of markdown.split("\n")) {
762 if (line.trim().startsWith("```")) {
763 if (!insideFence) flushChunk();
764 insideFence = !insideFence;
765 out.push(line);
766 continue;
767 }
768 if (insideFence) {
769 out.push(line);
770 } else {
771 chunk.push(line);
772 }
773 }
774 flushChunk();
775 return out.join("\n");
777​
778function yamlString(value: string): string {
779 if (/[\n:"{}[\],&*?|>!%@`#]/.test(value) || value.trim() !== value) {
780 return JSON.stringify(value);
781 }
782 return value;
784​
785function renderPageBody(doc: PageDoc): string {
786 const parts: string[] = [];
787​
788 if (doc.summary) {
789 parts.push(doc.summary);
790 }
791​
792 for (const section of doc.sections) {
793 const secParts = [`## ${section.title}`];
794 if (section.description) {
795 secParts.push(section.description);
796 }
797 for (const example of section.examples) {
798 if (section.examples.length > 1) {
799 secParts.push(`**${example.title}**`);
800 }
801 secParts.push(example.body);
802 }
803 parts.push(secParts.join("\n\n"));
804 }
805​
806 return parts.join("\n\n");
808​
809function renderReferenceFrontmatter(title: string): string {
810 return [
811 "---",
812 `title: ${yamlString(`${title} reference`)}`,
813 `description: ${yamlString(`Resources and capabilities for ${title}.`)}`,
814 // Keep the existing search-engine indexing policy during this experiment.
815 "head:",
816 " - tag: meta",
817 " attrs:",
818 " name: robots",
819 ' content: "noindex, follow"',
820 "prev: false",
821 "next: false",
822 "tableOfContents:",
823 " minHeadingLevel: 2",
824 " maxHeadingLevel: 2",
825 "---",
826 ].join("\n");
828​
829function renderResource(doc: PageDoc, resolve: LinkResolver): string {
830 const headerLines = [`> **Source:** \`${doc.sourceDisplay}\``];
831 if (doc.isLayer) {
832 const meta = ["**Kind:** Layer"];
833 if (doc.provides.length > 0) {
834 meta.push(`**Provides:** ${doc.provides.map((tag) => `\`${tag}\``).join(", ")}`);
835 }
836 if (doc.peers.length > 0) {
837 meta.push(`**Peer dependencies:** ${doc.peers.map((peer) => `\`${peer}\``).join(", ")}`);
838 }
839 headerLines.push(`> ${meta.join(" · ")}`);
840 }
841 const sourceBlock = headerLines.join("\n");
842 const body = linkifyMarkdown(renderPageBody(doc).trim(), resolve);
843​
844 if (body) {
845 return `${sourceBlock}\n\n${body}\n`;
846 }
847 return `${sourceBlock}\n`;
849​
850/** Change this grouping to experiment with larger or smaller reference pages. */
851function referenceLocation(outputRelative: string, product: string) {
852 const parts = normalizeSlashes(outputRelative).replace(/\.md$/, "").split("/");
853 // Flat providers declare service-sized pages with @product instead of
854 // folders; a resource without one gets its own page.
855 const group =
856 parts.length > 2
857 ? parts.slice(0, 2)
858 : product
859 ? [
860 parts[0],
861 "reference",
862 product
863 .toLowerCase()
864 .replace(/[^a-z0-9]+/g, "-")
865 .replace(/^-|-$/g, ""),
866 ]
867 : parts;
868 const title = parts.slice(parts.length > 2 ? 2 : 1).join("-");
869 return {
870 outputRelative: `${group.join("/")}.md`,
871 title:
872 parts.length > 2 ? group.join(".") : product ? `${parts[0]}.${product}` : parts.join("."),
873 resourceTitle: title,
874 link: `/providers/${group.join("/").toLowerCase()}#${title.toLowerCase()}`,
875 };
877​
878/** Keep example headings below their resource, with resource-scoped slugs. */
879function nestResourceHeadings(markdown: string, resource: string): string {
880 let fence: string | undefined;
881 return markdown
882 .split("\n")
883 .map((line) => {
884 const marker = line.match(/^\s{0,3}(`{3,}|~{3,})/);
885 if (marker) {
886 if (!fence) fence = marker[1];
887 else if (marker[1][0] === fence[0] && marker[1].length >= fence.length) fence = undefined;
888 return line;
889 }
890 if (fence) return line;
891 return line.replace(
892 /^(#{1,6})\s+(.+)$/,
893 (_, hashes: string, title: string) =>
894 `${"#".repeat(Math.min(6, Math.max(3, hashes.length + 1)))} ${resource}: ${title}`,
895 );
896 })
897 .join("\n");
899​
900/** Providers shown first in the sidebar; the rest follow alphabetically. */
901const PROVIDER_ORDER = ["AWS", "Cloudflare"];
902​
903interface SidebarLeaf {
904 label: string;
905 link: string;
907interface SidebarGroup {
908 label: string;
909 collapsed: true;
910 items: SidebarItem[];
912type SidebarItem = SidebarLeaf | SidebarGroup;
913​
914interface PageEntry {
915 provider: string;
916 service: string;
917 resource: string;
918 category: string;
919 product: string;
920 link: string;
921 /** Source directory relative to srcRoot (slash-normalized), e.g. `Cloudflare/KV`. */
922 dir: string;
923 /** All names the page's source file exports — `{@link}` targets documented on this page. */
924 exports: string[];
926​
927const byLabel = (a: { label: string }, b: { label: string }) => a.label.localeCompare(b.label);
928​
929function orderedKeys(keys: string[], order: string[]): string[] {
930 const ranked = keys.filter((k) => order.includes(k));
931 ranked.sort((a, b) => order.indexOf(a) - order.indexOf(b));
932 const rest = keys.filter((k) => !order.includes(k)).sort();
933 return [...ranked, ...rest];
935​
936/** The sidebar lists documents; resource anchors live in the page's TOC. */
937function buildServiceItems(pages: PageEntry[]): SidebarItem[] {
938 return pages
939 .map((page) => ({ label: page.product || page.service || page.resource, link: page.link }))
940 .sort(byLabel);
942​
943function buildProvidersSidebar(entries: PageEntry[]): SidebarItem[] {
944 const byProvider = new Map<string, PageEntry[]>();
945 for (const e of entries) {
946 if (!byProvider.has(e.provider)) byProvider.set(e.provider, []);
947 byProvider.get(e.provider)!.push(e);
948 }
949​
950 const providers: SidebarGroup[] = [];
951 for (const provider of orderedKeys([...byProvider.keys()], PROVIDER_ORDER)) {
952 const byPage = new Map<string, PageEntry[]>();
953 for (const entry of byProvider.get(provider)!) {
954 const link = entry.link.split("#")[0];
955 if (!byPage.has(link)) byPage.set(link, []);
956 byPage.get(link)!.push(entry);
957 }
958 const pages = [...byPage].map(([link, resources]) => ({
959 ...resources[0],
960 link,
961 product: resources.every((resource) => resource.product === resources[0].product)
962 ? resources[0].product
963 : "",
964 }));
965​
966 const items = buildServiceItems(pages);
967​
968 providers.push({ label: provider, collapsed: true, items });
969 }
970​
971 assertNoDuplicateSiblings(providers, []);
972 return providers;
974​
975/**
976 * Duplicate sibling labels are always a tagging bug (e.g. two products
977 * resolving to the same name in one category) and render as confusing
978 * twin sections — fail the generation instead of shipping them.
979 */
980function assertNoDuplicateSiblings(items: SidebarItem[], path: string[]) {
981 const seen = new Map<string, number>();
982 for (const item of items) {
983 seen.set(item.label, (seen.get(item.label) ?? 0) + 1);
984 }
985 const dups = [...seen.entries()].filter(([, n]) => n > 1);
986 if (dups.length > 0) {
987 throw new Error(
988 `Duplicate sidebar sibling label(s) under "${path.join(" > ") || "(root)"}": ${dups
989 .map(([label, n]) => `"${label}" ×${n}`)
990 .join(", ")} — fix the @product/@category tags on the offending files.`,
991 );
992 }
993 for (const item of items) {
994 if ("items" in item) {
995 assertNoDuplicateSiblings(item.items, [...path, item.label]);
996 }
997 }
999​
1000/**
1001 * Writes `content` only when it differs from what's on disk, so a dev server
1002 * watching the output reloads just the pages that changed.
1003 */
1004const writeIfChanged = Effect.fn(function* (file: string, content: string) {
1005 const fs = yield* FileSystem.FileSystem;
1006 const path = yield* Path.Path;
1007 const current = yield* fs
1008 .readFileString(file)
1009 .pipe(Effect.orElseSucceed((): string | undefined => undefined));
1010 if (current === content) return false;
1011 yield* fs.makeDirectory(path.dirname(file), { recursive: true });
1012 yield* fs.writeFileString(file, content);
1013 return true;
1014});
1015​
1016/** Deletes files under `dir` not in `keep`, then any directories left empty. */
1017const pruneOutput = (
1018 dir: string,
1019 keep: Set<string>,
1020): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem | Path.Path> =>
1021 Effect.gen(function* () {
1022 const fs = yield* FileSystem.FileSystem;
1023 const path = yield* Path.Path;
1024 const entries = yield* fs.readDirectory(dir).pipe(Effect.orElseSucceed((): string[] => []));
1025 for (const name of entries) {
1026 const full = path.join(dir, name);
1027 const info = yield* fs.stat(full);
1028 if (info.type === "Directory") {
1029 yield* pruneOutput(full, keep);
1030 const left = yield* fs.readDirectory(full).pipe(Effect.orElseSucceed((): string[] => []));
1031 if (left.length === 0) yield* fs.remove(full, { recursive: true }).pipe(Effect.ignore);
1032 } else if (!keep.has(full)) {
1033 yield* fs.remove(full, { force: true });
1036 });
1037​
1038class DuplicateReferenceAnchor extends Data.TaggedError("DuplicateReferenceAnchor")<{
1039 readonly message: string;
1040}> {}
1041​
1042const command = Command.make(
1043 "generate-api-reference",
1045 copyMarkers: Flag.Boolean("copy-markers").pipe(
1046 Flag.withDefault(false),
1047 Flag.withDescription(
1048 "Emit copy-editor markers for the website's dev server (also API_REFERENCE_COPY_MARKERS=1)",
1049 ),
1050 ),
1051 },
1052 Effect.fn(function* (flags) {
1053 const path = yield* Path.Path;
1054 const fs = yield* FileSystem.FileSystem;
1055 const repoRoot = path.resolve(import.meta.dirname, "..");
1056 const websiteRoot = path.join(repoRoot, "website");
1057 const envCopyMarkers = yield* Config.String("API_REFERENCE_COPY_MARKERS").pipe(
1058 Config.withDefault(""),
1059 );
1060 const config = {
1061 outRoot: path.join(websiteRoot, "src/content/docs/providers"),
1062 /**
1063 * Emit `<!--copy:jsdoc:...-->` markers after each prose block so the
1064 * website's dev-time copy editor can map rendered text back to the JSDoc
1065 * it came from. Dev only — production builds never set this.
1066 */
1067 copyMarkers: envCopyMarkers === "1" || flags.copyMarkers,
1068 };
1069​
1070 yield* fs.makeDirectory(config.outRoot, { recursive: true });
1071 const outputs = new Set<string>();
1072​
1073 const seen = new Map<string, string>();
1074 const pageEntries: PageEntry[] = [];
1075 const pending: { outputRelative: string; product: string; doc: PageDoc }[] = [];
1076 let written = 0;
1077 const redirects: Record<string, string> = {};
1078 const anchors = new Set<string>();
1079 let skipped = 0;
1080​
1081 for (const root of sourceRoots) {
1082 const entries = yield* discoverFiles(repoRoot, root);
1083 yield* Console.log(`Discovered ${entries.length} source files in ${root.srcRoot}.`);
1084​
1085 yield* Effect.scoped(
1086 Effect.gen(function* () {
1087 const syntax = yield* createSyntaxProject(entries.map((entry) => entry.absolutePath));
1088​
1089 for (const entry of entries) {
1090 const sourceFile = yield* syntax.getSourceFile(entry.absolutePath);
1091​
1092 const primary = findTaggedPrimary(
1093 sourceFile,
1094 config.copyMarkers
1095 ? {
1096 file: normalizeSlashes(path.relative(repoRoot, entry.absolutePath)),
1097 text: sourceFile.text,
1099 : undefined,
1100 );
1101 if (!primary) {
1102 skipped++;
1103 continue;
1105​
1106 // Only emit a page when there's actual documented content; a bare
1107 // frontmatter + source link stub is noise.
1108 if (!primary.doc.summary && primary.doc.sections.length === 0) {
1109 skipped++;
1110 continue;
1112​
1113 // Mirror the source directory structure; name the page after the
1114 // tagged declaration (e.g. Cloudflare.AI.Search/AiSearchInstance.md).
1115 const relDir = path.dirname(entry.relativePath);
1116 const outputRelative = path.join(relDir, `${primary.name}.md`);
1117​
1118 const existing = seen.get(outputRelative);
1119 if (existing) {
1120 yield* Console.warn(
1121 ` collision: ${outputRelative} from ${entry.relativePath} (already from ${existing})`,
1122 );
1124 seen.set(outputRelative, entry.relativePath);
1125​
1126 const doc: PageDoc = {
1127 title: primary.name,
1128 sourceDisplay: entry.sourceDisplay,
1129 summary: primary.doc.summary,
1130 sections: primary.doc.sections,
1131 isLayer: primary.doc.hasLayerTag,
1132 provides: primary.doc.provides,
1133 peers: primary.doc.peers,
1134 };
1135 pending.push({ outputRelative, product: primary.product, doc });
1136​
1137 const exportNames = exportedNames(sourceFile);
1138​
1139 const segments = normalizeSlashes(outputRelative).split("/");
1140 const location = referenceLocation(outputRelative, primary.product);
1141 const oldLink = `/providers/${normalizeSlashes(outputRelative).replace(/\.md$/, "").toLowerCase()}`;
1142 if (anchors.has(location.link)) {
1143 return yield* new DuplicateReferenceAnchor({
1144 message: `Duplicate reference anchor: ${location.link}`,
1145 });
1147 anchors.add(location.link);
1148 // A resource whose page kept its old URL needs no redirect.
1149 if (oldLink !== location.link.split("#")[0]) redirects[oldLink] = location.link;
1150 pageEntries.push({
1151 provider: segments[0] ?? "",
1152 service: segments.length > 2 ? segments[1] : "",
1153 resource: primary.name,
1154 category: primary.category,
1155 product: primary.product,
1156 link: location.link,
1157 dir: normalizeSlashes(relDir),
1158 exports: exportNames,
1159 });
1161 }),
1162 );
1164​
1165 // Second pass: render with `{@link}` resolution — the full page set must be
1166 // known before symbol targets can resolve to their pages.
1167 const resolverFor = makeLinkResolverFactory(pageEntries);
1168 const groups = new Map<string, { title: string; sections: string[] }>();
1169 for (const page of pending) {
1170 const location = referenceLocation(page.outputRelative, page.product);
1171 const resolve = resolverFor(normalizeSlashes(path.dirname(page.outputRelative)));
1172 const group = groups.get(location.outputRelative) ?? { title: location.title, sections: [] };
1173 group.sections.push(
1174 `## ${location.resourceTitle}\n\n${nestResourceHeadings(renderResource(page.doc, resolve), location.resourceTitle)}`,
1175 );
1176 groups.set(location.outputRelative, group);
1178 for (const [relative, group] of groups) {
1179 const outputPath = path.join(config.outRoot, relative);
1180 outputs.add(outputPath);
1181 if (
1182 yield* writeIfChanged(
1183 outputPath,
1184 `${renderReferenceFrontmatter(group.title)}\n\n${group.sections.join("\n\n")}\n`,
1186 ) {
1187 written++;
1190​
1191 const sidebar = buildProvidersSidebar(pageEntries);
1192​
1193 // Landing page for the Reference tab (/providers): a provider directory.
1194 // Each provider's reference belongs to its docs hub — the directory just
1195 // routes there (or into the tree, for providers without a hub). The
1196 // ProviderDirectory component reads the generated sidebar for counts.
1197 // Regenerated with the rest of the tree on every run.
1198 const referenceIndex = [
1199 "---",
1200 "title: API Reference",
1201 "description: Every provider alchemy can manage — pick a provider to open its docs hub and resource reference.",
1202 "---",
1203 "",
1204 'import ProviderDirectory from "../../../components/ProviderDirectory.astro";',
1205 "",
1206 "Every resource alchemy can manage, documented from the source JSDoc and",
1207 "organized by provider. A provider's reference lives in its docs hub —",
1208 "pick one below, or search with `⌘K`.",
1209 "",
1210 "<ProviderDirectory />",
1211 "",
1212 ].join("\n");
1213 const indexPath = path.join(config.outRoot, "index.mdx");
1214 outputs.add(indexPath);
1215 yield* writeIfChanged(indexPath, referenceIndex);
1216 yield* pruneOutput(config.outRoot, outputs);
1217​
1218 const sidebarPath = path.join(websiteRoot, "src/generated/providers-sidebar.json");
1219 yield* writeIfChanged(
1220 path.join(websiteRoot, "src/generated/reference-redirects.json"),
1221 `${JSON.stringify(redirects, null, 2)}\n`,
1222 );
1223 // astro.config imports the sidebar, so rewriting it restarts a dev server.
1224 yield* writeIfChanged(sidebarPath, `${JSON.stringify(sidebar, null, 2)}\n`);
1225​
1226 yield* Console.log(
1227 `Done. Wrote ${written} changed reference pages containing ${pending.length} resources (skipped ${skipped} untagged${
1228 config.copyMarkers ? ", with copy markers" : ""
1229 }) to ${normalizeSlashes(path.relative(repoRoot, config.outRoot))}.`,
1230 );
1231 if (unresolvedLinkTargets.size > 0) {
1232 const list = [...unresolvedLinkTargets.entries()]
1233 .sort((a, b) => b[1] - a[1])
1234 .map(([target, n]) => (n > 1 ? `${target} ×${n}` : target))
1235 .join(", ");
1236 yield* Console.log(`{@link} targets without a page (rendered as inline code): ${list}`);
1238 yield* Console.log(
1239 `Wrote provider sidebar to ${normalizeSlashes(path.relative(repoRoot, sidebarPath))}.`,
1240 );
1241 }),
1242).pipe(Command.withDescription("Generate the provider API reference from source JSDoc"));
1243​
1244Command.run(command, { version: "0.0.0" }).pipe(
1245 Effect.provide(NodeServices.layer),
1246 NodeRuntime.runMain,