| 1 | import * as fs from "node:fs/promises"; |
| 2 | import * as path from "node:path"; |
| 3 | |
| 4 | import * as ts from "typescript-api/unstable/ast"; |
| 5 | import type { Node, SourceFile } from "typescript-api/unstable/ast"; |
| 6 | import { |
| 7 | docCommentLines, |
| 8 | jsdocCopyId, |
| 9 | LINK_TAG_RE, |
| 10 | markDocLines, |
| 11 | normalizeLinkTarget, |
| 12 | parseLinkTag, |
| 13 | } from "./jsdoc-blocks.ts"; |
| 14 | import { createSyntaxProject } from "./typescript-source.ts"; |
| 15 | |
| 16 | const websiteRoot = path.join(import.meta.dir, "../website"); |
| 17 | |
| 18 | interface 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 | |
| 31 | const repoRoot = path.join(import.meta.dir, ".."); |
| 32 | |
| 33 | const 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 | |
| 57 | interface 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 | |
| 65 | interface ExampleBlock { |
| 66 | title: string; |
| 67 | body: string; |
| 68 | } |
| 69 | |
| 70 | interface ExampleSection { |
| 71 | title: string; |
| 72 | description: string; |
| 73 | examples: ExampleBlock[]; |
| 74 | } |
| 75 | |
| 76 | interface 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 | |
| 90 | const normalizeSlashes = (value: string) => value.split(path.sep).join("/"); |
| 91 | |
| 92 | const isSourceFile = (baseName: string) => |
| 93 | (baseName.endsWith(".ts") || baseName.endsWith(".tsx")) && |
| 94 | !baseName.endsWith(".d.ts") && |
| 95 | baseName !== "index.ts"; |
| 96 | |
| 97 | async 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 | |
| 150 | function getJsDocText(node: Node): string { |
| 151 | return node.jsDoc?.map((doc) => doc.getText()).join("\n") ?? ""; |
| 152 | } |
| 153 | |
| 154 | function 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 | |
| 163 | interface 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. */ |
| 179 | interface 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 | |
| 186 | function 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 | |
| 204 | function 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 | |
| 379 | function 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 | |
| 393 | interface Primary { |
| 394 | name: string; |
| 395 | doc: ParsedJSDoc; |
| 396 | category: string; |
| 397 | product: string; |
| 398 | } |
| 399 | |
| 400 | const 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 | */ |
| 409 | export 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 | |
| 464 | function 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 | */ |
| 490 | export 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. */ |
| 555 | type 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 | */ |
| 567 | function 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 | |
| 637 | const 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. */ |
| 645 | const unresolvedLinkTargets = new Map<string, number>(); |
| 646 | |
| 647 | function 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 | |
| 697 | function yamlString(value: string): string { |
| 698 | if (/[\n:"{}[\],&*?|>!%@`#]/.test(value) || value.trim() !== value) { |
| 699 | return JSON.stringify(value); |
| 700 | } |
| 701 | return value; |
| 702 | } |
| 703 | |
| 704 | function 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 | |
| 728 | function 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 | |
| 748 | function 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. */ |
| 776 | function 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. */ |
| 809 | function 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. */ |
| 832 | const PROVIDER_ORDER = ["AWS", "Cloudflare"]; |
| 833 | |
| 834 | interface SidebarLeaf { |
| 835 | label: string; |
| 836 | link: string; |
| 837 | } |
| 838 | interface SidebarGroup { |
| 839 | label: string; |
| 840 | collapsed: true; |
| 841 | items: SidebarItem[]; |
| 842 | } |
| 843 | type SidebarItem = SidebarLeaf | SidebarGroup; |
| 844 | |
| 845 | interface 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 | |
| 858 | const byLabel = (a: { label: string }, b: { label: string }) => |
| 859 | a.label.localeCompare(b.label); |
| 860 | |
| 861 | function 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. */ |
| 869 | function 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 | |
| 878 | function 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 | */ |
| 917 | function 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 | */ |
| 941 | async 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. */ |
| 950 | async 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 | |
| 966 | async 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 | |
| 1161 | if (import.meta.main) await main(); |