| 1 | /** |
| 2 | * Loads and provides configuration for documentation generation. |
| 3 | * |
| 4 | * @since 0.6.0 |
| 5 | */ |
| 6 | |
| 7 | import * as Array from "effect/Array" |
| 8 | import * as Config from "effect/Config" |
| 9 | import * as ConfigProvider from "effect/ConfigProvider" |
| 10 | import * as Context from "effect/Context" |
| 11 | import * as Effect from "effect/Effect" |
| 12 | import * as FileSystem from "effect/FileSystem" |
| 13 | import { pipe } from "effect/Function" |
| 14 | import * as Layer from "effect/Layer" |
| 15 | import * as Option from "effect/Option" |
| 16 | import * as Path from "effect/Path" |
| 17 | import * as Result from "effect/Result" |
| 18 | import * as Schema from "effect/Schema" |
| 19 | import * as tsconfck from "tsconfck" |
| 20 | import * as Domain from "./Domain.ts" |
| 21 | import { 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 | */ |
| 29 | export const DEFAULT_THEME = "mikearnaldi/just-the-docs" |
| 30 | |
| 31 | const PACKAGE_JSON_FILE_NAME = "package.json" |
| 32 | const CONFIG_FILE_NAME = "docgen.json" |
| 33 | |
| 34 | const 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 | */ |
| 45 | export 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 | */ |
| 114 | export 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 | */ |
| 138 | export class Configuration |
| 139 | extends Context.Service<Configuration, ConfigurationShape>()("@effect/docgen/Configuration") |
| 140 | {} |
| 141 | |
| 142 | /** @internal */ |
| 143 | export 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 | |
| 155 | const 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 | |
| 170 | const 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 |
| 188 | const 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 | |
| 203 | const 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 | |
| 225 | const loadCompilerOptions = (configKey: string) => |
| 226 | Config.String(configKey).pipe( |
| 227 | Effect.flatMap((config) => |
| 228 | Schema.decodeUnknownEffect(JsonRecordSchema)(config).pipe(Effect.orElseSucceed(() => config)) |
| 229 | ) |
| 230 | ) |
| 231 | |
| 232 | const 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 | |
| 256 | const JsonRecordSchema = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)) |
| 257 | |
| 258 | const PackageJsonSchema = Schema.Struct({ |
| 259 | name: Schema.String, |
| 260 | homepage: Schema.String |
| 261 | }) |
| 262 | |
| 263 | /** @internal */ |
| 264 | export 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 */ |
| 361 | export 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 | })) |