1/**
2 * Coordinates source parsing, validation, example checking, and Markdown generation.
3 *
4 * @since 0.6.0
5 */
6
7import * as NodePath from "@effect/platform-node/NodePath"
8import chalk from "chalk"
9import * as Array from "effect/Array"
10import * as Effect from "effect/Effect"
11import * as Fiber from "effect/Fiber"
12import * as FileSystem from "effect/FileSystem"
13import { pipe } from "effect/Function"
14import * as Path from "effect/Path"
15import * as ChildProcess from "effect/process/ChildProcess"
16import * as Stream from "effect/Stream"
17import * as String from "effect/String"
18import * as Glob from "glob"
19import * as Checker from "./Checker.ts"
20import * as Configuration from "./Configuration.ts"
21import * as Domain from "./Domain.ts"
22import * as Parser from "./Parser.ts"
23import * as Printer from "./Printer.ts"
24/**
25 * Find all files matching the specified `glob` pattern, optionally excluding
26 * files matching the provided `exclude` patterns.
27 */
28const glob = (pattern: string, exclude: ReadonlyArray<string> = []) =>
29 Effect.tryPromise(() =>
30 Glob.glob(pattern, {
31 ignore: exclude.slice(),
32 withFileTypes: false
33 })
34 ).pipe(
35 Effect.mapError(() =>
36 new Domain.DocgenError({
37 message: `[Core.glob] Unable to execute glob pattern '${pattern}' ` +
38 `excluding files matching '${exclude}'`
39 })
40 ),
41 Effect.orDie
42 )
43
44/** @internal */
45export const runCommand = Effect.fnUntraced(function*(
46 executable: string,
47 args: ReadonlyArray<string>,
48 shell: boolean
49) {
50 const handle = yield* ChildProcess.make(executable, args, { shell })
51 const [stdout, stderr, exitCode] = yield* Effect.all([
52 Stream.mkString(Stream.decodeText(handle.stdout)),
53 Stream.mkString(Stream.decodeText(handle.stderr)),
54 handle.exitCode
55 ], { concurrency: "unbounded" })
56 return { stdout, stderr, exitCode } as const
57})
58
59/**
60 * Reads all TypeScript files in the source directory and returns an array of file objects.
61 * Each file object contains the file path and its content.
62 */
63const readSourceFiles = Effect.gen(function*() {
64 const config = yield* Configuration.Configuration
65 const fs = yield* FileSystem.FileSystem
66 const path = yield* Path.Path.pipe(Effect.provide(NodePath.layerPosix))
67 const pattern = path.normalize(path.join(config.srcDir, "**", "*.ts"))
68 const paths = yield* glob(pattern, config.exclude)
69 yield* Effect.logInfo(chalk.bold(`${paths.length} module(s) found`))
70 return yield* Effect.forEach(paths, (path) =>
71 Effect.map(
72 fs.readFileString(path),
73 (content) => new Domain.File(path, content, false)
74 ), { concurrency: "unbounded" })
75})
76
77/**
78 * Writes a file to the `config.outDir` directory, taking into account the configuration and existing files.
79 */
80const writeFileToOutDir = (file: Domain.File) =>
81 Effect.gen(function*() {
82 const config = yield* Configuration.Configuration
83 const fs = yield* FileSystem.FileSystem
84 const path = yield* Path.Path
85 const process = yield* Domain.Process
86 const cwd = yield* process.cwd
87 const fileName = path.relative(path.join(cwd, config.outDir), file.path)
88
89 const exists = yield* fs.exists(file.path)
90 if (exists) {
91 if (file.isOverwriteable) {
92 yield* Effect.logDebug(`Overwriting file ${chalk.black(fileName)}...`)
93 yield* fs.makeDirectory(path.dirname(file.path), { recursive: true })
94 yield* fs.writeFileString(file.path, file.content)
95 } else {
96 yield* Effect.logDebug(
97 `File ${chalk.black(fileName)} already exists, skipping creation.`
98 )
99 }
100 } else {
101 yield* fs.makeDirectory(path.dirname(file.path), { recursive: true })
102 yield* fs.writeFileString(file.path, file.content)
103 }
104 })
105
106const writeFilesToOutDir = (
107 files: ReadonlyArray<Domain.File>
108) => Effect.forEach(files, writeFileToOutDir, { discard: true })
109
110const parseModules = (files: ReadonlyArray<Domain.File>) =>
111 Parser.parseFiles(files).pipe(
112 Effect.mapError((errors) =>
113 new Domain.DocgenError({
114 message: "[Core.parseModules] The following error(s) occurred while " +
115 `parsing the TypeScript source files:\n${errors.map((errors) => errors.join("\n")).join("\n")}`
116 })
117 )
118 )
119
120/**
121 * Runs the example files for the given modules, type-checking them before execution.
122 */
123const typeCheckAndRunExamples = (modules: ReadonlyArray<Domain.Module>) =>
124 Effect.gen(function*() {
125 const config = yield* Configuration.Configuration
126 yield* cleanupExamples
127 const files = yield* getExampleFiles(modules)
128 const len = files.length
129 if (len > 0) {
130 yield* Effect.logInfo(`${len} example(s) found`)
131 yield* writeExamplesToOutDir(files)
132 yield* createExamplesTsConfigJson
133 yield* Effect.logInfo("Typechecking examples...")
134 yield* runTscOnExamples
135 if (config.runExamples) {
136 yield* Effect.logInfo("Running examples...")
137 yield* runTsxOnExamples
138 } else {
139 yield* Effect.logInfo(chalk.gray("Skipping running examples"))
140 }
141 } else {
142 yield* Effect.logInfo("No examples found.")
143 }
144 yield* cleanupExamples
145 })
146
147/**
148 * Joins an array of strings with a "-" after dropping all empty strings.
149 */
150const filterJoin = (self: Array<string>) =>
151 pipe(
152 self,
153 Array.filter(String.isNonEmpty),
154 Array.join("-")
155 )
156
157/**
158 * Extracts deeply nested namespaces with their corresponding namespace prefix
159 * from a given namespace.
160 */
161const extractPrefixedNestedNamespaces = (
162 doc: Domain.Namespace,
163 prefix: string
164): ReadonlyArray<[string, Domain.Namespace]> => {
165 const newPrefix = String.isEmpty(prefix) ? doc.name : `${prefix}-${doc.name}`
166 const namespaces = Array.flatMap(
167 doc.namespaces,
168 (namespace) => extractPrefixedNestedNamespaces(namespace, newPrefix)
169 )
170 return Array.prepend(namespaces, [prefix, doc])
171}
172
173/**
174 * Fence metadata that excludes an example from docgen type checking.
175 *
176 * @category constants
177 * @since 0.6.0
178 */
179export const SKIP_TYPE_CHECKING_FENCE_METADATA = "skip-type-checking"
180
181/**
182 * Extracts all fenced code blocks from markdown content.
183 * Handles both ``` and ~~~ fences, including any metadata like language, title, and other attributes.
184 *
185 * @internal
186 */
187export const extractFencedCode = (content: string): [examples: Array<string>, warnings: Array<string>] => {
188 // The regex now captures the closing fence (group 3) if present.
189 // If there's no closing fence, group 3 will be undefined.
190 const fenceRegex = /(?:```|~~~)(.*?)\n([\s\S]*?)(?:(```|~~~)|$)/g
191 const matches = Array.fromIterable(content.matchAll(fenceRegex))
192
193 const warnings: Array<string> = []
194
195 // Log a warning if a code fence is not properly closed.
196 for (const match of matches) {
197 if (match[3] === undefined) {
198 warnings.push(`Code block does not have a matching closing fence:\n${content}`)
199 }
200 }
201
202 return [
203 matches
204 .filter((match) => {
205 const meta = match[1].toLocaleLowerCase()
206 const isTypeScript = meta.startsWith("ts") || meta.startsWith("typescript")
207 const isSkipTypeChecking = meta.includes(SKIP_TYPE_CHECKING_FENCE_METADATA)
208 return isTypeScript && !isSkipTypeChecking
209 })
210 .map((match) => match[2].trim()),
211 warnings
212 ]
213}
214
215/**
216 * Generates example files for the given modules.
217 */
218const getExampleFiles = (modules: ReadonlyArray<Domain.Module>) =>
219 Effect.gen(function*() {
220 const config = yield* Configuration.Configuration
221 const path = yield* Path.Path
222 let warnings: Array<string> = []
223 // Flattened module paths can collide, so give every emitted example a unique prefix.
224 let fileIndex = 0
225 const files = Array.flatMap(modules, (module) => {
226 const prefix = module.path.join("-")
227
228 const getFiles =
229 (exampleId: string) =>
230 (namedDoc: { readonly name: string; readonly doc: Domain.Doc }): ReadonlyArray<Domain.File> => {
231 let descriptionExamples: Array<string> = []
232 if (namedDoc.doc.description !== undefined) {
233 const [es, ws] = extractFencedCode(namedDoc.doc.description)
234 warnings = warnings.concat(ws)
235 descriptionExamples = es
236 }
237 let exampleTagExamples: Array<string> = []
238 for (const example of namedDoc.doc.examples) {
239 const [es, ws] = extractFencedCode(example)
240 warnings = warnings.concat(ws)
241 exampleTagExamples = exampleTagExamples.concat(es)
242 }
243 const examples = descriptionExamples.concat(exampleTagExamples)
244 return Array.map(
245 examples,
246 (example, i) => {
247 return new Domain.File(
248 path.join(
249 config.outDir,
250 "examples",
251 `${fileIndex++}-${prefix}-${exampleId}-${namedDoc.name}-${i}.ts`
252 ),
253 example,
254 true // make the file overwritable
255 )
256 }
257 )
258 }
259
260 const allPrefixedNamespaces = Array.flatMap(module.namespaces, (namespace) =>
261 extractPrefixedNestedNamespaces(namespace, ""))
262
263 const moduleExamples = getFiles("module")(module)
264 const classExamples = Array.flatMap(module.classes, (c) =>
265 Array.flatten([
266 getFiles("class")(c),
267 Array.flatMap(c.properties, getFiles(`${c.name}-property`)),
268 Array.flatMap(
269 c.methods,
270 getFiles(`${c.name}-method`)
271 ),
272 Array.flatMap(
273 c.staticMethods,
274 getFiles(`${c.name}-staticmethod`)
275 )
276 ]))
277 const allPrefixedInterfaces = [
278 ...module.interfaces.map((iface) =>
279 ["" as string, iface] as const
280 ),
281 ...Array.flatMap(allPrefixedNamespaces, ([prefix, namespace]) =>
282 namespace.interfaces.map((iface) =>
283 [filterJoin([prefix, namespace.name]), iface] as const
284 ))
285 ]
286 const interfacesExamples = Array.flatMap(
287 allPrefixedInterfaces,
288 ([ns, doc]) => getFiles(filterJoin(["interface", ns]))(doc)
289 )
290 const allPrefixedTypeAliases = [
291 ...module.typeAliases.map((typeAlias) => ["" as string, typeAlias] as const),
292 ...Array.flatMap(allPrefixedNamespaces, ([prefix, namespace]) =>
293 namespace.typeAliases.map((typeAlias) =>
294 [filterJoin([prefix, namespace.name]), typeAlias] as const
295 ))
296 ]
297 const typeAliasesExamples = Array.flatMap(
298 allPrefixedTypeAliases,
299 ([ns, doc]) =>
300 getFiles(filterJoin(["typealias", ns]))(doc)
301 )
302 const constantsExamples = Array.flatMap(
303 module.constants,
304 getFiles("constant")
305 )
306 const functionsExamples = Array.flatMap(
307 module.functions,
308 getFiles("function")
309 )
310 const exportsExamples = Array.flatMap(
311 module.exports,
312 getFiles("export")
313 )
314 const namespacesExamples = Array.flatMap(
315 allPrefixedNamespaces,
316 ([ns, doc]) => getFiles(filterJoin(["namespace", ns]))(doc)
317 )
318
319 return Array.flatten([
320 moduleExamples,
321 classExamples,
322 interfacesExamples,
323 typeAliasesExamples,
324 constantsExamples,
325 functionsExamples,
326 namespacesExamples,
327 exportsExamples
328 ])
329 })
330
331 if (warnings.length > 0) {
332 yield* Effect.logWarning(warnings.join("\n"))
333 }
334
335 return files
336 })
337
338/**
339 * Generates an entry point file for the given examples.
340 */
341const getExamplesEntryPoint = (examples: ReadonlyArray<Domain.File>) =>
342 Effect.gen(function*() {
343 const config = yield* Configuration.Configuration
344 const path = yield* Path.Path
345 const content = examples.map((example) => `import './${path.basename(example.path, ".ts")}'`)
346 .join("\n")
347 return new Domain.File(
348 path.normalize(path.join(config.outDir, "examples", "index.ts")),
349 `${content}\n`,
350 true // make the file overwritable
351 )
352 })
353
354/**
355 * Removes the "examples" directory from the output directory specified in the configuration.
356 */
357const cleanupExamples = Effect.gen(function*() {
358 const fs = yield* FileSystem.FileSystem
359 const config = yield* Configuration.Configuration
360 const path = yield* Path.Path
361 const examplesDir = path.join(config.outDir, "examples")
362 const exists = yield* Effect.orDie(fs.exists(examplesDir))
363 if (exists) {
364 yield* fs.remove(examplesDir, { recursive: true })
365 }
366})
367
368/**
369 * Runs tsc on the examples directory.
370 */
371const runTscOnExamples = Effect.gen(function*() {
372 const config = yield* Configuration.Configuration
373 const process = yield* Domain.Process
374 const cwd = yield* process.cwd
375 const path = yield* Path.Path
376 const platform = yield* process.platform
377
378 const tsconfig = path.normalize(path.join(cwd, config.outDir, "examples", "tsconfig.json"))
379 const options = ["--noEmit", "--project", tsconfig]
380 yield* Effect.logDebug("Running tsc on examples...")
381 const result = yield* runCommand(
382 platform === "win32" ? `${config.tscExecutable}.cmd` : config.tscExecutable,
383 options,
384 platform === "win32"
385 ).pipe(Effect.mapError((error) =>
386 new Domain.DocgenError({
387 message: `Something went wrong while running tsc on examples:\n\n${globalThis.String(error)}`
388 })
389 ))
390 if (result.exitCode !== 0) {
391 return yield* new Domain.DocgenError({
392 message: `Something went wrong while running tsc on examples:\n\n${result.stdout}`
393 })
394 }
395})
396
397/**
398 * Runs tsc on the examples directory.
399 */
400const runTsxOnExamples = Effect.gen(function*() {
401 const config = yield* Configuration.Configuration
402 const path = yield* Path.Path
403 const process = yield* Domain.Process
404 const cwd = yield* process.cwd
405 const platform = yield* process.platform
406
407 const examples = path.normalize(path.join(cwd, config.outDir, "examples"))
408 const tsconfig = path.join(examples, "tsconfig.json")
409 const index = path.join(examples, "index.ts")
410 const options = ["--tsconfig", tsconfig, index]
411 yield* Effect.logDebug("Running tsx on examples...")
412 const result = yield* runCommand(
413 platform === "win32" ? "tsx.cmd" : "tsx",
414 options,
415 platform === "win32"
416 ).pipe(Effect.mapError((error) =>
417 new Domain.DocgenError({
418 message: `Something went wrong while running tsx on examples:\n\n${globalThis.String(error)}`
419 })
420 ))
421 if (result.exitCode !== 0) {
422 return yield* new Domain.DocgenError({
423 message: `Something went wrong while running tsx on examples:\n\n${result.stderr}`
424 })
425 }
426})
427
428const writeExamplesToOutDir = (examples: ReadonlyArray<Domain.File>) =>
429 Effect.gen(function*() {
430 yield* Effect.logDebug("Writing examples...")
431 const entryPoint = yield* getExamplesEntryPoint(examples)
432 const files = [entryPoint, ...examples]
433 yield* writeFilesToOutDir(files)
434 })
435
436const createExamplesTsConfigJson = Effect.gen(function*() {
437 yield* Effect.logDebug("Writing examples tsconfig...")
438 const config = yield* Configuration.Configuration
439 const process = yield* Domain.Process
440 const cwd = yield* process.cwd
441 const path = yield* Path.Path
442 yield* writeFileToOutDir(
443 new Domain.File(
444 path.join(cwd, config.outDir, "examples", "tsconfig.json"),
445 JSON.stringify({ compilerOptions: config.examplesCompilerOptions }, null, 2),
446 true // make the file overwritable
447 )
448 )
449})
450
451const getMarkdown = (modules: ReadonlyArray<Domain.Module>) =>
452 Effect.gen(function*() {
453 const homepage = yield* getMarkdownHomepage
454 const index = yield* getMarkdownIndex
455 const yml = yield* getMarkdownConfigYML
456 const moduleFiles = yield* getModuleMarkdownFiles(modules)
457 return [homepage, index, yml, ...moduleFiles]
458 })
459
460const getMarkdownHomepage = Effect.gen(function*() {
461 const config = yield* Configuration.Configuration
462 const process = yield* Domain.Process
463 const cwd = yield* process.cwd
464 const path = yield* Path.Path
465 return new Domain.File(
466 path.join(cwd, config.outDir, "index.md"),
467 String.stripMargin(
468 `|---
469 |title: Home
470 |nav_order: 1
471 |---
472 |`
473 ),
474 false
475 )
476})
477
478const getMarkdownIndex = Effect.gen(function*() {
479 const config = yield* Configuration.Configuration
480 const process = yield* Domain.Process
481 const cwd = yield* process.cwd
482 const path = yield* Path.Path
483 return new Domain.File(
484 path.join(cwd, config.outDir, "modules", "index.md"),
485 String.stripMargin(
486 `|---
487 |title: Modules
488 |has_children: true
489 |permalink: /docs/modules
490 |nav_order: 2
491 |---
492 |`
493 ),
494 false
495 )
496})
497
498const resolveConfigYML = (content: string) =>
499 Effect.gen(function*() {
500 const config = yield* Configuration.Configuration
501 return content
502 .replace(/^remote_theme:.*$/m, `remote_theme: ${config.theme}`)
503 .replace(
504 /^search_enabled:.*$/m,
505 `search_enabled: ${config.enableSearch}`
506 ).replace(
507 /^ {2}'\S* on GitHub':\n {4}- '.*'/m,
508 ` '${config.projectName} on GitHub':\n - '${config.projectHomepage}'`
509 )
510 })
511
512const getHomepageNavigationHeader = (config: Configuration.ConfigurationShape): string => {
513 const isGitHub = config.projectHomepage.toLowerCase().includes("github")
514 return isGitHub ? config.projectName + " on GitHub" : "Homepage"
515}
516
517const getMarkdownConfigYML = Effect.gen(function*() {
518 const config = yield* Configuration.Configuration
519 const process = yield* Domain.Process
520 const fs = yield* FileSystem.FileSystem
521 const cwd = yield* process.cwd
522 const path = yield* Path.Path
523 const configPath = path.join(cwd, config.outDir, "_config.yml")
524 const exists = yield* fs.exists(configPath)
525 if (exists) {
526 const content = yield* fs.readFileString(configPath)
527 const resolved = yield* resolveConfigYML(content)
528 return new Domain.File(configPath, resolved, true)
529 } else {
530 return new Domain.File(
531 configPath,
532 String.stripMargin(
533 `|remote_theme: ${config.theme}
534 |
535 |# Enable or disable the site search
536 |search_enabled: ${config.enableSearch}
537 |
538 |# Aux links for the upper right navigation
539 |aux_links:
540 |'${getHomepageNavigationHeader(config)}':
541 | - '${config.projectHomepage}'`
542 ),
543 false
544 )
545 }
546})
547
548const getModuleMarkdownOutputPath = (module: Domain.Module) => {
549 return Effect.gen(function*() {
550 const config = yield* Configuration.Configuration
551 const path = yield* Path.Path
552 return path.normalize(path.join(
553 config.outDir,
554 "modules",
555 `${path.relative(config.srcDir, module.path.join(path.sep))}.md`
556 ))
557 })
558}
559
560/** @internal */
561export const getModuleMarkdownFiles = (modules: ReadonlyArray<Domain.Module>) =>
562 Effect.forEach(modules, (module, i) =>
563 Effect.gen(function*() {
564 const outputPath = yield* getModuleMarkdownOutputPath(module)
565 const moduleContent = yield* Printer.printModule(module)
566 const tocgen = yield* Effect.promise(() => import("@effect/markdown-toc").then((module) => module.default)).pipe(
567 Effect.orDie
568 )
569 const toc = tocgen(moduleContent, { bullets: "-" }).content
570 const frontMatter = Printer.printFrontMatter(module, i + 1)
571 const content = (frontMatter + "\n\n" + moduleContent).replace(
572 "<!-- toc -->",
573 `---
574## Exports Grouped by Category
575${toc}
576---`
577 )
578
579 const prettified = yield* Printer.prettify(content)
580 return new Domain.File(outputPath, prettified, true)
581 }))
582
583const writeMarkdown = (files: ReadonlyArray<Domain.File>) =>
584 Effect.gen(function*() {
585 const config = yield* Configuration.Configuration
586 const fileSystem = yield* FileSystem.FileSystem
587 const path = yield* Path.Path.pipe(Effect.provide(NodePath.layerPosix))
588 const pattern = path.normalize(path.join(config.outDir, "**/*.ts.md"))
589 yield* Effect.logDebug(`Deleting ${chalk.black(pattern)}...`)
590 const paths = yield* glob(pattern)
591 yield* Effect.forEach(paths, (path) => fileSystem.remove(path, { recursive: true }), {
592 concurrency: "unbounded"
593 })
594 return yield* writeFilesToOutDir(files)
595 })
596
597/** @internal */
598export const program = Effect.gen(function*() {
599 yield* Effect.logInfo("Reading modules...")
600 const sourceFiles = yield* readSourceFiles
601 yield* Effect.logInfo("Parsing modules...")
602 const modules = yield* parseModules(sourceFiles)
603
604 const checkFiber = yield* Effect.gen(function*() {
605 yield* Effect.logInfo("Checking modules...")
606 const errors = yield* Checker.checkModules(modules)
607 if (errors.length > 0) {
608 return yield* Effect.fail(
609 new Domain.DocgenError({
610 message: `The following errors occurred while checking the modules:\n\n${errors.join("\n\n")}`
611 })
612 )
613 }
614 yield* typeCheckAndRunExamples(modules)
615 }).pipe(Effect.forkChild)
616
617 const markdownFiber = yield* Effect.gen(function*() {
618 yield* Effect.logInfo("Creating markdown files...")
619 const outputFiles = yield* getMarkdown(modules)
620 yield* Effect.logInfo("Writing markdown files...")
621 yield* writeMarkdown(outputFiles)
622 }).pipe(Effect.forkChild)
623
624 yield* Fiber.joinAll([checkFiber, markdownFiber])
625
626 yield* Effect.logInfo(chalk.bold.green("✓ Docs generation succeeded!"))
627})