1/**
2 * Renders parsed documentation models as Markdown.
3 *
4 * @since 0.6.0
5 */
6import * as Array from "effect/Array"
7import * as Effect from "effect/Effect"
8import { pipe } from "effect/Function"
9import * as Order from "effect/Order"
10import * as Record from "effect/Record"
11import * as String from "effect/String"
12import * as Prettier from "prettier"
13import * as Configuration from "./Configuration.ts"
14import type * as Domain from "./Domain.ts"
15import * as Parser from "./Parser.ts"
16
17/** @internal */
18export type Printable =
19 | Domain.Class
20 | Domain.Constant
21 | Domain.Export
22 | Domain.Function
23 | Domain.Interface
24 | Domain.TypeAlias
25 | Domain.Namespace
26
27const Markdown = {
28 bold: (content: string) => `**${content}**`,
29 fence: (content: string) => `\`\`\`ts\n${content}\n\`\`\`\n\n`,
30 strikethrough: (content: string) => `~~${content}~~`
31}
32
33/**
34 * Replaces the link from a JSDoc link tag with a simple text.
35 *
36 * Given "This is a description containing two links to {@link foo} and {@link bar baz}."
37 * returns "This is a description containing two links to `foo` and `baz`."
38 */
39function replaceJSDocLinks(text: string): string {
40 return text.replace(/\{@link\s+([^\s}]+)(?:\s+([^}]+))?\}/g, (_, link, label) => {
41 // Use the label if provided; otherwise, use the link target
42 return `\`${(label || link).trim()}\``
43 })
44}
45
46/**
47 * Removes all extra metadata from fenced code blocks in a Markdown string.
48 * For each code fence, only the first token (the language identifier) is preserved.
49 */
50function removeFenceMetadata(markdown: string): string {
51 return markdown.replace(/^(`{3,})([^\n]*)/gm, (_match, fence, info) => {
52 // Trim the info string and split by whitespace into tokens.
53 // The first token (if present) is typically the language identifier.
54 const tokens = info.trim().split(/\s+/)
55 // Rebuild the fence line with just the language (if it exists)
56 return fence + (tokens[0] || "")
57 })
58}
59
60const printOptionalDescription = (description: string | undefined) => {
61 return Effect.gen(function*() {
62 if (description === undefined) {
63 return ""
64 }
65 const config = yield* Configuration.Configuration
66 const descriptionWithoutLinks = replaceJSDocLinks(description)
67 const out = config.theme === Configuration.DEFAULT_THEME
68 ? removeFenceMetadata(descriptionWithoutLinks)
69 : descriptionWithoutLinks
70 return `\n\n${out}`
71 })
72}
73
74const printArray = (title: string, ss?: ReadonlyArray<string>): string => {
75 if (ss === undefined || ss.length === 0) {
76 return ""
77 }
78 return `\n\n${Markdown.bold(title)}\n\n${ss.join("\n")}`
79}
80
81const printFence = (code: string): string => {
82 if (code.startsWith("```ts") || code.startsWith("~~~ts")) {
83 return code
84 }
85 return "```ts\n" + code + "\n```"
86}
87
88const printOptionalSignature = (signature?: string): string => {
89 if (signature === undefined) {
90 return ""
91 }
92 return `\n\n${Markdown.bold("Signature")}\n\n${printFence(signature)}`
93}
94
95const printThrowsArray = (throws?: ReadonlyArray<string>): string => printArray("Throws", throws)
96
97const printExamplesArray = (examples: ReadonlyArray<string>): string => {
98 if (examples.length === 0) {
99 return ""
100 }
101 return examples.map((ex) => "\n\n**Example**\n\n" + printFence(ex)).join("")
102}
103
104const printOptionalSince = (since: ReadonlyArray<string>): string => {
105 if (since.length === 0) {
106 return ""
107 }
108 return `\n\nSince v${since.join(", ")}`
109}
110
111const printHeaderByIndentation = (indentation: number) => {
112 switch (indentation) {
113 case 0:
114 return "## "
115 case 1:
116 return "### "
117 default:
118 return "#### "
119 }
120}
121
122const printTitle = (s: string, deprecated: ReadonlyArray<string>, postfix?: string): string => {
123 const name = s.trim() === "hasOwnProperty" ? `${s} (function)` : s
124 const title = deprecated.length > 0 ? Markdown.strikethrough(name) : name
125 return postfix === undefined ? title : title + ` ${postfix}`
126}
127
128const printSeesArray = (sees?: ReadonlyArray<string>): string => {
129 if (sees === undefined || sees.length === 0) {
130 return ""
131 }
132 return `\n\n${Markdown.bold("See")}\n\n${sees.map((see) => `- ${replaceJSDocLinks(see)}`).join("\n")}`
133}
134
135const printOptionalSourceLink = (position?: Domain.Position) => {
136 return Effect.gen(function*() {
137 if (position === undefined) {
138 return ""
139 }
140 const config = yield* Configuration.Configuration
141 const source = yield* Parser.Source
142 const name = source.sourceFile.getBaseName()
143 return `\n\n[Source](${config.srcLink}${name}#L${position.line})`
144 })
145}
146
147const printModel = (name: string, doc: Domain.Doc, options: {
148 readonly signature?: string | undefined
149 readonly position?: Domain.Position | undefined
150 readonly indentation?: number | undefined
151 readonly postfix?: string | undefined
152}) => {
153 return Effect.gen(function*() {
154 const sourceLink = yield* printOptionalSourceLink(options.position)
155 const description = yield* printOptionalDescription(doc.description)
156 return printHeaderByIndentation(options.indentation ?? 0) + printTitle(name, doc.deprecated, options.postfix) +
157 description +
158 printThrowsArray(doc.throws) +
159 printExamplesArray(doc.examples) +
160 printSeesArray(doc.sees) +
161 printOptionalSignature(options.signature) +
162 sourceLink +
163 printOptionalSince(doc.since)
164 })
165}
166
167const printEntry = (model: Domain.DocEntry, options: {
168 readonly indentation?: number | undefined
169 readonly postfix?: string | undefined
170}) => {
171 return printModel(model.name, model.doc, {
172 signature: model.signature,
173 position: model.position,
174 indentation: options.indentation,
175 postfix: options.postfix
176 })
177}
178
179const printStaticMethod = (model: Domain.DocEntry) => {
180 return printEntry(model, {
181 indentation: 1,
182 postfix: "(static method)"
183 })
184}
185
186const printMethod = (model: Domain.DocEntry) => {
187 return printEntry(model, {
188 indentation: 1,
189 postfix: "(method)"
190 })
191}
192
193const printProperty = (model: Domain.DocEntry) => {
194 return printEntry(model, {
195 indentation: 1,
196 postfix: "(property)"
197 })
198}
199
200const printClass = (model: Domain.Class) => {
201 return Effect.gen(function*() {
202 const header = yield* printEntry(model, {
203 postfix: "(class)"
204 })
205 const staticMethods = yield* Effect.forEach(model.staticMethods, (method) => printStaticMethod(method))
206 const methods = yield* Effect.forEach(model.methods, (method) => printMethod(method))
207 const properties = yield* Effect.forEach(model.properties, (property) => printProperty(property))
208 return header +
209 staticMethods.map((s) => "\n\n" + s).join("") +
210 methods.map((s) => "\n\n" + s).join("") +
211 properties.map((s) => "\n\n" + s).join("")
212 })
213}
214
215const printConstant = (model: Domain.Constant) => {
216 return printEntry(model, {})
217}
218
219const printExport = (model: Domain.Export) => {
220 return printEntry(model, {
221 postfix: model.isNamespaceExport ? "(namespace export)" : undefined
222 })
223}
224
225const printFunction = (model: Domain.Function) => {
226 return printEntry(model, {})
227}
228
229const printInterface = (model: Domain.Interface, indentation: number) => {
230 return printEntry(model, {
231 indentation,
232 postfix: "(interface)"
233 })
234}
235
236const printTypeAlias = (model: Domain.TypeAlias, indentation: number) => {
237 return printEntry(model, {
238 indentation,
239 postfix: "(type alias)"
240 })
241}
242
243const printNamespace = (
244 model: Domain.Namespace,
245 indentation: number
246): Effect.Effect<string, never, Configuration.Configuration | Parser.Source> => {
247 return Effect.gen(function*() {
248 const header = yield* printModel(model.name, model.doc, {
249 position: model.position,
250 indentation,
251 postfix: "(namespace)"
252 })
253 const interfaces = yield* Effect.forEach(model.interfaces, (inter) => printInterface(inter, indentation + 1))
254 const typeAliases = yield* Effect.forEach(
255 model.typeAliases,
256 (typeAlias) => printTypeAlias(typeAlias, indentation + 1)
257 )
258 const namespaces = yield* Effect.forEach(
259 model.namespaces,
260 (namespace) => printNamespace(namespace, indentation + 1)
261 )
262 return header +
263 interfaces.map((s) => "\n\n" + s).join("") +
264 typeAliases.map((s) => "\n\n" + s).join("") +
265 namespaces.map((s) => "\n\n" + s).join("")
266 })
267}
268
269/** @internal */
270export const print = (p: Printable) => {
271 switch (p._tag) {
272 case "Class":
273 return printClass(p)
274 case "Constant":
275 return printConstant(p)
276 case "Export":
277 return printExport(p)
278 case "Function":
279 return printFunction(p)
280 case "Interface":
281 return printInterface(p, 0)
282 case "TypeAlias":
283 return printTypeAlias(p, 0)
284 case "Namespace":
285 return printNamespace(p, 0)
286 }
287}
288
289const DEFAULT_CATEGORY = "utils"
290
291const byCategory = Order.mapInput(
292 String.Order,
293 ([category]: [string, ...Array<unknown>]) => category
294)
295
296const getPrintables = (module: Domain.Module): ReadonlyArray<Printable> =>
297 Array.flatten([
298 module.classes,
299 module.constants,
300 module.exports,
301 module.functions,
302 module.interfaces,
303 module.typeAliases,
304 module.namespaces
305 ])
306
307const sortByName: <A extends { name: string }>(self: Iterable<A>) => Array<A> = Array.sort(
308 pipe(
309 String.Order,
310 Order.mapInput(({ name }: { name: string }) => name)
311 )
312)
313
314/**
315 * Renders a parsed module as a Markdown documentation page.
316 *
317 * @category printers
318 * @since 0.6.0
319 */
320export const printModule = (module: Domain.Module) => {
321 return Effect.gen(function*() {
322 const description = yield* printModel(module.name, module.doc, {
323 postfix: "overview"
324 })
325
326 const printables = pipe(
327 sortByName(getPrintables(module)),
328 Array.groupBy((printable) =>
329 printable.doc.category.length === 0 ? DEFAULT_CATEGORY : printable.doc.category.join(", ")
330 ),
331 Record.toEntries,
332 Array.sort(byCategory)
333 )
334
335 const strings = yield* Effect.forEach(printables, ([category, printables]) =>
336 Effect.gen(function*() {
337 const out = `\n\n# ${category}`
338 const strings = yield* Effect.forEach(sortByName(printables), (printable) => print(printable))
339 return out + strings.map((s) => "\n\n" + s).join("")
340 }))
341
342 const content = strings.join("")
343
344 return `${description}
345
346<!-- toc -->${content}`
347 }).pipe(Effect.provideService(Parser.Source, module.source))
348}
349
350const defaultPrettierOptions: Prettier.Options = {
351 parser: "markdown",
352 semi: false,
353 singleQuote: false,
354 printWidth: 120,
355 trailingComma: "none"
356}
357
358/**
359 * Renders the front matter for a generated module page.
360 *
361 * @category printers
362 * @since 0.6.0
363 */
364export const printFrontMatter = (module: Domain.Module, nav_order: number): string => {
365 return `---
366title: ${module.name}
367nav_order: ${nav_order}
368parent: Modules
369---`
370}
371
372/**
373 * Formats generated Markdown with the docgen Prettier settings.
374 *
375 * @category printers
376 * @since 0.6.0
377 */
378export function prettify(s: string) {
379 return Effect.tryPromise({
380 try: () => Prettier.format(s, defaultPrettierOptions),
381 catch: globalThis.String
382 }).pipe(Effect.orDie)
383}