1/**
2 * Loads and provides configuration for documentation generation.
3 *
4 * @since 0.6.0
5 */
6
7import * as Array from "effect/Array"
8import * as Config from "effect/Config"
9import * as ConfigProvider from "effect/ConfigProvider"
10import * as Context from "effect/Context"
11import * as Effect from "effect/Effect"
12import * as FileSystem from "effect/FileSystem"
13import { pipe } from "effect/Function"
14import * as Layer from "effect/Layer"
15import * as Option from "effect/Option"
16import * as Path from "effect/Path"
17import * as Result from "effect/Result"
18import * as Schema from "effect/Schema"
19import * as tsconfck from "tsconfck"
20import * as Domain from "./Domain.ts"
21import { DocgenError } from "./Domain.ts"
22
23/**
24 * Default GitHub Pages theme written to generated configuration.
25 *
26 * @category constants
27 * @since 0.6.0
28 */
29export const DEFAULT_THEME = "mikearnaldi/just-the-docs"
30
31const PACKAGE_JSON_FILE_NAME = "package.json"
32const CONFIG_FILE_NAME = "docgen.json"
33
34const compilerOptionsSchema = Schema.Union([
35 Schema.String,
36 Schema.Record(Schema.String, Schema.Unknown)
37])
38
39/**
40 * Schema for docgen configuration files.
41 *
42 * @category schemas
43 * @since 0.6.0
44 */
45export const ConfigurationSchema = Schema.Struct({
46 "$schema": Schema.optional(Schema.String),
47 projectHomepage: Schema.optional(Schema.String.annotate({
48 description: "Will link to the project homepage from the Auxiliary Links of the generated documentation."
49 })),
50 srcLink: Schema.optional(Schema.String.annotate({
51 description: "Will link to the project source code."
52 })),
53 srcDir: Schema.optional(Schema.String.annotate({
54 description: "The directory in which docgen will search for TypeScript files to parse.",
55 default: "src"
56 })),
57 outDir: Schema.optional(Schema.String.annotate({
58 description: "The directory to which docgen will generate its output markdown documents.",
59 default: "docs"
60 })),
61 theme: Schema.optional(Schema.String.annotate({
62 description: "The theme that docgen will specify should be used for GitHub Docs in the generated _config.yml file.",
63 default: DEFAULT_THEME
64 })),
65 enableSearch: Schema.optional(Schema.Boolean.annotate({
66 description: "Whether or not search should be enabled for GitHub Docs in the generated _config.yml file.",
67 default: true
68 })),
69 enforceDescriptions: Schema.optional(Schema.Boolean.annotate({
70 description: "Whether or not descriptions for each module export should be required.",
71 default: false
72 })),
73 enforceExamples: Schema.optional(Schema.Boolean.annotate({
74 description:
75 "Whether or not @example tags for each module export should be required. (Note: examples will not be enforced in module documentation)",
76 default: false
77 })),
78 enforceVersion: Schema.optional(Schema.Boolean.annotate({
79 description: "Whether or not @since tags for each module export should be required.",
80 default: true
81 })),
82 tscExecutable: Schema.optional(Schema.String.annotate({
83 description:
84 "The path to the TypeScript compiler executable that docgen should use when invoking the compiler programmatically.",
85 default: "tsc"
86 })),
87 runExamples: Schema.optional(Schema.Boolean.annotate({
88 description:
89 "Whether or not docgen should attempt to run example code snippets and include the output in the generated documentation.",
90 default: false
91 })),
92 exclude: Schema.optional(
93 Schema.Array(Schema.String).annotate({
94 description: "An array of glob strings specifying files that should be excluded from the documentation.",
95 default: []
96 })
97 ),
98 parseCompilerOptions: Schema.optional(compilerOptionsSchema.annotate({
99 description: "tsconfig for parsing options (or path to a tsconfig)",
100 default: {}
101 })),
102 examplesCompilerOptions: Schema.optional(compilerOptionsSchema.annotate({
103 description: "tsconfig for the examples options (or path to a tsconfig)",
104 default: {}
105 }))
106}).annotate({ identifier: "ConfigurationSchema" })
107
108/**
109 * Resolved configuration used by the docgen services.
110 *
111 * @category services
112 * @since 0.6.0
113 */
114export interface ConfigurationShape {
115 readonly projectName: string
116 readonly projectHomepage: string
117 readonly srcLink: string
118 readonly srcDir: string
119 readonly outDir: string
120 readonly theme: string
121 readonly enableSearch: boolean
122 readonly enforceDescriptions: boolean
123 readonly enforceExamples: boolean
124 readonly enforceVersion: boolean
125 readonly tscExecutable: string
126 readonly runExamples: boolean
127 readonly exclude: ReadonlyArray<string>
128 readonly parseCompilerOptions: Record<string, unknown>
129 readonly examplesCompilerOptions: Record<string, unknown>
130}
131
132/**
133 * Service that provides resolved docgen configuration.
134 *
135 * @category services
136 * @since 0.6.0
137 */
138export class Configuration
139 extends Context.Service<Configuration, ConfigurationShape>()("@effect/docgen/Configuration")
140{}
141
142/** @internal */
143export const defaultCompilerOptions = {
144 noEmit: true,
145 strict: true,
146 skipLibCheck: true,
147 moduleResolution: "Bundler",
148 target: "ES2022",
149 lib: [
150 "ES2022",
151 "DOM"
152 ]
153}
154
155const readJsonFile = (
156 path: string
157): Effect.Effect<unknown, never, FileSystem.FileSystem> =>
158 Effect.gen(function*() {
159 const fs = yield* FileSystem.FileSystem
160 const content = yield* Effect.orDie(fs.readFileString(path))
161 return yield* pipe(
162 Effect.try({
163 try: () => JSON.parse(content),
164 catch: (error) => `[FileSystem] Unable to read and parse JSON file from '${path}': ${String(error)}`
165 }),
166 Effect.orDie
167 )
168 })
169
170const validateJsonFile = <A, I>(
171 schema: Schema.Codec<A, I>,
172 path: string
173): Effect.Effect<A, never, FileSystem.FileSystem> =>
174 Effect.gen(function*() {
175 const content = yield* readJsonFile(path)
176 return yield* pipe(
177 Schema.decodeUnknownEffect(schema)(content),
178 Effect.mapError((error) =>
179 new DocgenError({
180 message: `[Configuration.validateJsonFile]\n${String(error)}`
181 })
182 ),
183 Effect.orDie
184 )
185 })
186
187// TODO: this is invoked twice
188const readDocgenConfig = (
189 path: string
190): Effect.Effect<Option.Option<Schema.Schema.Type<typeof ConfigurationSchema>>, never, FileSystem.FileSystem> => {
191 return Effect.gen(function*() {
192 const fs = yield* FileSystem.FileSystem
193 const exists = yield* Effect.orDie(fs.exists(path))
194 if (exists) {
195 const config = yield* validateJsonFile(ConfigurationSchema, path)
196 return Option.some(config)
197 } else {
198 return Option.none()
199 }
200 })
201}
202
203const readTSConfig = (fileName: string): Effect.Effect<
204 { readonly [x: string]: unknown },
205 never,
206 Path.Path | Domain.Process
207> =>
208 Effect.gen(function*() {
209 const path = yield* Path.Path
210 const process = yield* Domain.Process
211 const cwd = yield* process.cwd
212 return yield* pipe(
213 Effect.tryPromise(() => tsconfck.parse(path.resolve(cwd, fileName))).pipe(
214 Effect.map(({ tsconfig }) => tsconfig.compilerOptions ?? defaultCompilerOptions),
215 Effect.mapError((error) =>
216 new DocgenError({
217 message: `[Configuration.readTSConfig] Failed to read TSConfig file\n${String(error)}`
218 })
219 ),
220 Effect.orDie
221 )
222 )
223 })
224
225const loadCompilerOptions = (configKey: string) =>
226 Config.String(configKey).pipe(
227 Effect.flatMap((config) =>
228 Schema.decodeUnknownEffect(JsonRecordSchema)(config).pipe(Effect.orElseSucceed(() => config))
229 )
230 )
231
232const resolveCompilerOptions = (
233 configKey: string,
234 fromCLI: Option.Option<string | Record<string, unknown>>,
235 fromDocgenJson: Option.Option<string | Record<string, unknown>>
236): Effect.Effect<{ readonly [x: string]: unknown }, never, Path.Path | Domain.Process> => {
237 const fromConfigProvider = loadCompilerOptions(configKey)
238 return Effect.gen(function*() {
239 let config: string | Record<string, unknown>
240 if (Option.isSome(fromCLI)) {
241 config = fromCLI.value
242 } else {
243 const provided = yield* Effect.result(fromConfigProvider)
244 if (Result.isSuccess(provided)) {
245 config = provided.success
246 } else if (Option.isSome(fromDocgenJson)) {
247 config = fromDocgenJson.value
248 } else {
249 config = defaultCompilerOptions
250 }
251 }
252 return typeof config === "string" ? yield* readTSConfig(config) : config
253 })
254}
255
256const JsonRecordSchema = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown))
257
258const PackageJsonSchema = Schema.Struct({
259 name: Schema.String,
260 homepage: Schema.String
261})
262
263/** @internal */
264export const load = (args: {
265 readonly projectHomepage: Option.Option<string>
266 readonly srcLink: Option.Option<string>
267 readonly srcDir: string
268 readonly outDir: string
269 readonly theme: string
270 readonly enableSearch: boolean
271 readonly enforceDescriptions: boolean
272 readonly enforceExamples: boolean
273 readonly enforceVersion: boolean
274 readonly runExamples: boolean
275 readonly exclude: ReadonlyArray<string>
276 readonly parseCompilerOptions: Option.Option<string | Record<string, unknown>>
277 readonly examplesCompilerOptions: Option.Option<string | Record<string, unknown>>
278}) =>
279 Effect.gen(function*() {
280 // Extract the requisite services
281 const process = yield* Domain.Process
282 const cwd = yield* process.cwd
283 const path = yield* Path.Path
284
285 // Read and parse the required fields from the `package.json`
286 const packageJsonPath = path.join(cwd, PACKAGE_JSON_FILE_NAME)
287 const packageJson = yield* validateJsonFile(PackageJsonSchema, packageJsonPath)
288 const projectName = packageJson.name
289 const projectHomepage = Option.getOrElse(args.projectHomepage, () => packageJson.homepage)
290 const srcLink = Option.getOrElse(args.srcLink, () => `${projectHomepage}/blob/main/src/`)
291
292 // Read the `docgen.json` configuration file to gain access to the TypeScript
293 // configuration options
294 const configPath = path.join(cwd, CONFIG_FILE_NAME)
295 const config = yield* readDocgenConfig(configPath)
296
297 // Resolve the excluded files
298 const exclude = yield* Array.match(args.exclude, {
299 onEmpty: () =>
300 Effect.result(Config.Array(Schema.String, "exclude")).pipe(
301 Effect.map((configured) =>
302 Result.isSuccess(configured)
303 ? configured.success
304 : Option.match(config, {
305 onNone: () => Array.empty<string>(),
306 onSome: ({ exclude }) => exclude || Array.empty<string>()
307 })
308 )
309 ),
310 onNonEmpty: (exclude) => Effect.succeed(exclude)
311 })
312
313 // Resolve the TypeScript configuration options
314 const examplesCompilerOptions = yield* resolveCompilerOptions(
315 "examplesCompilerOptions",
316 args.examplesCompilerOptions,
317 Option.flatMap(config, (config) => Option.fromNullishOr(config.examplesCompilerOptions))
318 )
319 const parseCompilerOptions = yield* resolveCompilerOptions(
320 "parseCompilerOptions",
321 args.parseCompilerOptions,
322 Option.flatMap(config, (config) => Option.fromNullishOr(config.parseCompilerOptions))
323 )
324
325 const srcDir = config.pipe(
326 Option.flatMapNullishOr((config) => config.srcDir),
327 Option.getOrElse(() => args.srcDir)
328 )
329
330 const outDir = config.pipe(
331 Option.flatMapNullishOr((config) => config.outDir),
332 Option.getOrElse(() => args.outDir)
333 )
334
335 const runExamples = config.pipe(
336 Option.flatMapNullishOr((config) => config.runExamples),
337 Option.getOrElse(() => args.runExamples)
338 )
339
340 const tscExecutable = config.pipe(
341 Option.flatMapNullishOr((config) => config.tscExecutable),
342 Option.getOrElse(() => "tsc")
343 )
344
345 return Configuration.of({
346 ...args,
347 srcDir,
348 outDir,
349 projectName,
350 projectHomepage,
351 srcLink,
352 exclude,
353 examplesCompilerOptions,
354 parseCompilerOptions,
355 runExamples,
356 tscExecutable
357 })
358 })
359
360/** @internal */
361export const configProviderLayer = Layer.effect(ConfigProvider.ConfigProvider)(Effect.gen(function*() {
362 // Extract the requisite services
363 const process = yield* Domain.Process
364 const cwd = yield* process.cwd
365 const env = yield* process.env
366 const path = yield* Path.Path
367 // Attempt to load the `docgen.json` configuration file
368 const configPath = path.join(cwd, CONFIG_FILE_NAME)
369 const maybeConfig = yield* readDocgenConfig(configPath)
370 // Construct a config provider for the environment
371 const fromEnv = ConfigProvider.fromEnv({ env }).pipe(
372 ConfigProvider.nested("DOCGEN"),
373 ConfigProvider.constantCase
374 )
375 // Construct a config provider for the `docgen.json` file
376 const fromDocgenJson = ConfigProvider.fromUnknown(Option.getOrElse(maybeConfig, () => ({})))
377 // Prefer the environment over the `docgen.json` file
378 const provider = fromEnv.pipe(ConfigProvider.orElse(fromDocgenJson))
379 return provider
380}))