1#!/usr/bin/env node
2/**
3 * @since 4.0.0
4 */
5import * as NodeRuntime from "@effect/platform-node/NodeRuntime"
6import * as NodeServices from "@effect/platform-node/NodeServices"
7import * as Array from "effect/Array"
8import * as Argument from "effect/cli/Argument"
9import * as Command from "effect/cli/Command"
10import * as Flag from "effect/cli/Flag"
11import * as Effect from "effect/Effect"
12import * as FileSystem from "effect/FileSystem"
13import { pipe } from "effect/Function"
14import * as Path from "effect/Path"
15import type * as PlatformError from "effect/PlatformError"
16import * as Stream from "effect/Stream"
17import * as String from "effect/String"
18
19const directory = Argument.Directory("directory", { mustExist: true })
20
21const output = Flag.Path("output").pipe(
22 Flag.withAlias("o"),
23 Flag.withDescription("Output file path")
24)
25
26const watch = Flag.Boolean("watch").pipe(
27 Flag.withAlias("w"),
28 Flag.withDescription("Watch for file changes and regenerate documentation"),
29 Flag.withDefault(false)
30)
31
32Command.make("effect-ai-docgen", { directory, output, watch }).pipe(
33 Command.withHandler(Effect.fnUntraced(function*({ directory, output, watch }) {
34 const fs = yield* FileSystem.FileSystem
35 const markdown = yield* directoryToMarkdown(directory)
36 yield* fs.writeFileString(output, markdown)
37
38 if (!watch) return
39
40 yield* Effect.logInfo("Watching for changes...")
41
42 yield* fs.watch(directory).pipe(
43 Stream.debounce(1000),
44 Stream.tap(() => Effect.logInfo("Changes detected, regenerating documentation...")),
45 Stream.switchMap(() =>
46 directoryToMarkdown(directory).pipe(
47 Stream.fromEffect
48 )
49 ),
50 Stream.runForEach(Effect.fn(function*(markdown) {
51 yield* fs.writeFileString(output, markdown)
52 yield* Effect.logInfo("Documentation updated.")
53 }))
54 )
55 })),
56 Command.run({
57 version: "0.0.0"
58 }),
59 Effect.provide(NodeServices.layer),
60 NodeRuntime.runMain
61)
62
63const directoryToMarkdown = Effect.fn("directoryToMarkdown")(
64 function*(
65 directory: string
66 ): Effect.fn.Return<string, PlatformError.PlatformError, FileSystem.FileSystem | Path.Path> {
67 const pathService = yield* Path.Path
68 const fs = yield* FileSystem.FileSystem
69
70 const indexMd = yield* fs.readFileString(pathService.join(directory, "index.md")).pipe(
71 Effect.map(String.trim),
72 Effect.orElseSucceed(() => null)
73 )
74
75 const allFiles = yield* fs.readDirectory(directory)
76 const hasInlineFiles = allFiles.some((file) => pathService.basename(file).startsWith("0") && /\.tsx?$/.test(file))
77
78 const tsFileContent = yield* Effect.forEach(
79 allFiles,
80 Effect.fn(function*(file) {
81 const filePath = pathService.join(directory, file)
82 const stat = yield* fs.stat(filePath)
83 const basename = pathService.basename(filePath)
84
85 if (stat.type === "Directory") {
86 if (basename === "fixtures") return null
87 return `${yield* directoryToMarkdown(filePath)}\n`
88 } else if (/\.tsx?$/.test(file)) {
89 const metadata = yield* tsFileMetadata(filePath)
90
91 if (metadata.fileNameWithoutExt.startsWith("0")) {
92 return `### ${metadata.title}
93
94${metadata.description ?? ""}
95
96\`\`\`ts
97${metadata.content}
98\`\`\`
99`
100 }
101
102 const relativePath = pathService.relative(process.cwd(), filePath)
103 const link = `[${metadata.title}](./${relativePath})`
104 let content = ""
105
106 if (hasInlineFiles && metadata.fileNameWithoutExt.startsWith("10")) {
107 content += `### More examples\n\n`
108 }
109
110 content += `- **${link}**`
111
112 if (metadata.description) {
113 if (metadata.description.includes("\n")) {
114 const indentedDescription = metadata.description
115 .split("\n")
116 .map((line) => ` ${line}`)
117 .join("\n")
118 content += `:\n${indentedDescription}`
119 } else {
120 content += `: ${metadata.description}`
121 }
122 }
123 return content
124 }
125
126 return null
127 }),
128 { concurrency: 5 }
129 ).pipe(
130 Effect.map(Array.filter((s): s is string => s !== null && s.trim() !== "")),
131 Effect.map(Array.join("\n")),
132 Effect.map(String.trim)
133 )
134
135 return indexMd ? `${indexMd}\n\n${tsFileContent}` : tsFileContent
136 }
137)
138
139const tsFileMetadata = Effect.fn("tsFileMetadata")(function*(filePath: string) {
140 const pathService = yield* Path.Path
141 const fs = yield* FileSystem.FileSystem
142
143 let content = yield* fs.readFileString(filePath)
144 const fileNameWithoutExt = pathService.basename(filePath, pathService.extname(filePath))
145
146 let title: string = pipe(
147 fileNameWithoutExt,
148 String.replaceAll(/^\d+/g, ""),
149 String.replaceAll(/[-_]/g, " "),
150 String.trim,
151 String.capitalize
152 )
153
154 const firstDocString = content.indexOf("/**")
155 if (firstDocString === -1) {
156 return {
157 title,
158 description: undefined,
159 content,
160 fileNameWithoutExt
161 } as const
162 }
163
164 const linesWithComment = content.slice(firstDocString).split("\n")
165
166 let description = ""
167
168 for (let i = 1; i < linesWithComment.length; i++) {
169 const line = linesWithComment[i]
170 if (!line.startsWith(" *")) break
171 if (line.endsWith(" */")) {
172 content = linesWithComment.slice(i + 1).join("\n").trim()
173 break
174 }
175 const lineContent = line.replace(/^ \*\s?/, "")
176 if (lineContent.startsWith("@title")) {
177 title = lineContent.replace("@title", "").trim()
178 continue
179 }
180 description += lineContent + "\n"
181 }
182
183 return {
184 title,
185 description: String.trim(description) || undefined,
186 content,
187 fileNameWithoutExt
188 } as const
189})