|
| 1 | +// Post-process the Markdown that DocFxMarkdownGen writes into website/docs/api. |
| 2 | +// |
| 3 | +// Why: the generator emits inline cross-reference links WITHOUT a file |
| 4 | +// extension, e.g. `[ActionException](../CodeFactory/ActionException)`. Docusaurus |
| 5 | +// only resolves relative links to the correct doc permalink when they end in |
| 6 | +// `.md` (it matches the target file in the doc graph). Without `.md`, the link |
| 7 | +// is treated as a raw URL and resolved with trailing-slash math — which drops |
| 8 | +// the `api` segment on namespace summary pages (served at /docs/api/<NS> while |
| 9 | +// their source file lives at <NS>/<NS>.md). Appending `.md` makes Docusaurus |
| 10 | +// map every link to the real page, immune to that routing quirk and to the |
| 11 | +// backtick-named generic-type files. |
| 12 | +// |
| 13 | +// Run after `dfmg`, before `npm run build`. |
| 14 | + |
| 15 | +import { readdir, readFile, writeFile } from 'node:fs/promises'; |
| 16 | +import { join } from 'node:path'; |
| 17 | +import { fileURLToPath } from 'node:url'; |
| 18 | + |
| 19 | +const apiDir = join(fileURLToPath(new URL('.', import.meta.url)), '..', 'website', 'docs', 'api'); |
| 20 | + |
| 21 | +// Match a Markdown link target that is a relative path (starts with ./ or ../), |
| 22 | +// capturing the path and an optional #anchor. We append `.md` to the path part |
| 23 | +// when it doesn't already have a markdown extension. |
| 24 | +const linkRe = /\]\((\.\.?\/[^)#]+?)(#[^)]*)?\)/g; |
| 25 | + |
| 26 | +// A fully-qualified BCL type name: `System.*` or `Microsoft.*`. |
| 27 | +const BCL_NAME = '(?:System|Microsoft)\\.[A-Za-z0-9_.]+'; |
| 28 | + |
| 29 | +// Generic BCL type: outer name followed by `<...>` (rendered type args) or |
| 30 | +// `%60N` (DocFxMarkdownGen's encoded arity artifact, e.g. IReadOnlyList%601). |
| 31 | +// The whole span is linked to the OUTER type's arity-suffixed Learn page; the |
| 32 | +// `<...>` part stays as plain display text (inner type args are not linked). |
| 33 | +const BCL_GENERIC = new RegExp(`(?<!\\[)\`(${BCL_NAME})(<[^\`]*>|%60\\d+)\`(?!\\]\\()`, 'g'); |
| 34 | + |
| 35 | +// Non-generic BCL type: a code span that is exactly one fully-qualified type, |
| 36 | +// e.g. `System.String`. Excludes `<`/`%` so it never overlaps BCL_GENERIC. |
| 37 | +const BCL_TYPE = new RegExp(`(?<!\\[)\`(${BCL_NAME})\`(?!\\]\\()`, 'g'); |
| 38 | + |
| 39 | +// Build the canonical Microsoft Learn .NET API URL for a fully-qualified type. |
| 40 | +// Convention: lowercase the dotted name; generic arity is suffixed with -N. |
| 41 | +// e.g. System.String -> .../api/system.string |
| 42 | +// System.Threading.Tasks.Task (arity 2) -> .../api/system.threading.tasks.task-2 |
| 43 | +function msLearnUrl(typeName, arity = 0) { |
| 44 | + const slug = typeName.toLowerCase() + (arity > 0 ? `-${arity}` : ''); |
| 45 | + return `https://learn.microsoft.com/en-us/dotnet/api/${slug}`; |
| 46 | +} |
| 47 | + |
| 48 | +// Count top-level type arguments in an angle-bracket group like `<A, B<C,D>>` |
| 49 | +// (nested commas don't count) -> the generic arity. |
| 50 | +function genericArity(angle) { |
| 51 | + const inner = angle.slice(1, -1); |
| 52 | + let depth = 0; |
| 53 | + let commas = 0; |
| 54 | + for (const ch of inner) { |
| 55 | + if (ch === '<') depth++; |
| 56 | + else if (ch === '>') depth--; |
| 57 | + else if (ch === ',' && depth === 0) commas++; |
| 58 | + } |
| 59 | + return commas + 1; |
| 60 | +} |
| 61 | + |
| 62 | +// Link BCL type code spans to Microsoft Learn, but never inside fenced code |
| 63 | +// blocks (declarations show C# keywords, not these spans, but be safe). |
| 64 | +function linkBclTypes(content) { |
| 65 | + let inFence = false; |
| 66 | + return content |
| 67 | + .split('\n') |
| 68 | + .map((line) => { |
| 69 | + if (/^\s*```/.test(line)) { |
| 70 | + inFence = !inFence; |
| 71 | + return line; |
| 72 | + } |
| 73 | + if (inFence) return line; |
| 74 | + // Generics first (they contain `<`/`%`, which BCL_TYPE excludes). |
| 75 | + let out = line.replace(BCL_GENERIC, (_m, outer, suffix) => { |
| 76 | + const arity = suffix.startsWith('<') ? genericArity(suffix) : Number(suffix.slice(3)); |
| 77 | + // Keep rendered type args as display text; drop the %60N artifact. |
| 78 | + const display = suffix.startsWith('<') ? `${outer}${suffix}` : outer; |
| 79 | + return `[\`${display}\`](${msLearnUrl(outer, arity)})`; |
| 80 | + }); |
| 81 | + out = out.replace(BCL_TYPE, (_m, type) => `[\`${type}\`](${msLearnUrl(type)})`); |
| 82 | + return out; |
| 83 | + }) |
| 84 | + .join('\n'); |
| 85 | +} |
| 86 | + |
| 87 | +async function* walk(dir) { |
| 88 | + for (const entry of await readdir(dir, { withFileTypes: true })) { |
| 89 | + const full = join(dir, entry.name); |
| 90 | + if (entry.isDirectory()) yield* walk(full); |
| 91 | + else if (entry.name.endsWith('.md')) yield full; |
| 92 | + } |
| 93 | +} |
| 94 | + |
| 95 | +let files = 0; |
| 96 | +let rewrites = 0; |
| 97 | +let dropped = 0; |
| 98 | +let bclLinks = 0; |
| 99 | + |
| 100 | +for await (const file of walk(apiDir)) { |
| 101 | + const original = await readFile(file, 'utf8'); |
| 102 | + let updated = original.replace(linkRe, (match, path, anchor = '') => { |
| 103 | + if (/\.mdx?$/i.test(path)) return match; // already has an extension |
| 104 | + rewrites++; |
| 105 | + return `](${path}.md${anchor})`; |
| 106 | + }); |
| 107 | + |
| 108 | + // Link BCL parameter/return/base types to Microsoft Learn. |
| 109 | + const beforeBcl = updated; |
| 110 | + updated = linkBclTypes(updated); |
| 111 | + if (updated !== beforeBcl) { |
| 112 | + bclLinks += |
| 113 | + (beforeBcl.match(BCL_GENERIC) || []).length + (beforeBcl.match(BCL_TYPE) || []).length; |
| 114 | + } |
| 115 | + |
| 116 | + // Drop index entries with empty link text, e.g. `* [](./Foo/Foo.md)`. These are |
| 117 | + // emitted for namespaces that contain only sub-namespaces (no documented types), |
| 118 | + // so DocFxMarkdownGen never generates the target page — the link is both empty |
| 119 | + // and broken. |
| 120 | + updated = updated.replace(/^\s*[-*] \[\]\([^)]*\)\s*$\n?/gm, () => { |
| 121 | + dropped++; |
| 122 | + return ''; |
| 123 | + }); |
| 124 | + |
| 125 | + if (updated !== original) { |
| 126 | + await writeFile(file, updated); |
| 127 | + files++; |
| 128 | + } |
| 129 | +} |
| 130 | + |
| 131 | +console.log( |
| 132 | + `postprocess-api: appended .md to ${rewrites} link(s), linked ${bclLinks} BCL type(s) to Microsoft Learn, ` + |
| 133 | + `dropped ${dropped} empty entry(ies) across ${files} file(s).`, |
| 134 | +); |
0 commit comments