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