Files
jose/tools/declaration-comments.js
2026-07-30 00:37:15 +02:00

227 lines
8.3 KiB
JavaScript

// Prepares the JSDoc in the published declarations for where it is actually read - an editor hover
// - by dropping what is written for the generated documentation and rewriting what only renders
// correctly on a docs page. docs/ is generated from src, so it keeps all of it.
//
// @example blocks - 22% of dist/types, and typedoc renders them on the page the reader is
// already on. Stripping them is not new: tools/postbump.cjs did it at
// release time, but its stripper treated any fence that was not ```js as
// the end of the example, so a ```ts one ended removal at the opening
// fence and left the body behind. jose 6.2.3 and 6.2.4 ship five orphaned
// code blocks and three stray `// @ts-ignore` lines in
// dist/types/jwks/remote.d.ts because of it. Doing it here instead puts
// the result under the type and packaging gates, and makes a local build
// match what is published.
//
// the subpath note - "This function is exported (as a named export) from the main 'jose'
// module entry point as well as from its subpath export
// 'jose/jwt/verify'." and its variants. Load-bearing on a docs page,
// since typedoc names its folders after source paths and that sentence is
// the only place the real specifier appears; noise in an editor hover.
//
// module documentation - TypeScript attaches it to the first import but does not expose it as
// useful module documentation in an editor.
//
// extended prose - only the first summary paragraph, alerts, signature-help tags, and
// @deprecated are useful in editor hovers. The rest remains in src for
// generated documentation.
//
// @ignore and friends - typedoc acts on them, TypeScript does not, so they reach the reader as
// a bare tag that means nothing to them.
//
// GitHub alerts - `> [!NOTE]` and the rest of the GFM set render as an admonition on
// GitHub and in the docs, and as a literal `[!NOTE]\` in a hover. The
// blockquote itself does carry over, so keep it and label it in words.
//
// Set JOSE_DEBUG_TYPES=1 to print the documentation stripped from publishable declarations.
import { readdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join, sep } from 'node:path'
const SUBPATH_NOTE = /^(?:This|These)\b[^.]*\bexported\b/
const BLOCK_TAG = /^@(\w+)/
const MARKER_TAG = /^@(?:ignore|internal|private|hidden)\s*$/
const PUBLISHED_TAGS = new Set(['param', 'returns', 'throws', 'deprecated'])
const REPORT_STRIPPED = process.env.JOSE_DEBUG_TYPES === '1'
const PRIVATE_DECLARATIONS = `${join('dist', 'types', 'lib')}${sep}`
/** The five alert types GitHub Flavored Markdown defines. */
const ALERT = /^>\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\\?\s*$/
const RELABELLED_ALERT = /^>\s*(?:Note|Tip|Important|Warning|Caution):\s/
/**
* Splits the text lines of a JSDoc body into paragraphs. Fenced code blocks stay whole, so neither
* a blank line nor a leading `@` inside one splits a paragraph or reads as a tag.
*/
function paragraphs(lines) {
const out = []
let current = []
let fenced = false
for (const line of lines) {
if (line.trimStart().startsWith('```')) fenced = !fenced
if (!fenced && line.trim() === '') {
if (current.length) out.push(current)
current = []
continue
}
current.push(line)
}
if (current.length) out.push(current)
return out
}
/** Splits adjacent block tags so filtering one tag cannot remove a neighboring retained tag. */
function blockTagSections(paragraph) {
const out = []
let current = []
let fenced = false
for (const line of paragraph) {
if (!fenced && BLOCK_TAG.test(line) && current.length) {
out.push(current)
current = []
}
current.push(line)
if (line.trimStart().startsWith('```')) fenced = !fenced
}
if (current.length) out.push(current)
return out
}
/** Turns `> [!NOTE]\` followed by the quote into a quote that opens with `Note:`. */
function relabelAlert(paragraph) {
const [marker, ...rest] = paragraph
const type = marker.trim().match(ALERT)?.[1]
if (!type || !rest.length) return null
const label = type.charAt(0) + type.slice(1).toLowerCase()
return [rest[0].replace(/^(\s*>\s*)/, `$1${label}: `), ...rest.slice(1)]
}
function* declarations(directory) {
const entries = readdirSync(directory, { withFileTypes: true }).sort((a, b) =>
a.name.localeCompare(b.name),
)
for (const entry of entries) {
const path = join(directory, entry.name)
if (entry.isDirectory()) {
yield* declarations(path)
} else if (entry.name.endsWith('.d.ts')) {
yield path
}
}
}
function transform(lines) {
const kept = []
const removed = []
let changed = false
let summary = false
// an @example runs until the next block tag that is not itself an @example - its description and
// its code fence are separate paragraphs, and there may be several in a row
let inExample = false
const sections = paragraphs(lines).flatMap(blockTagSections)
if (sections.some((paragraph) => paragraph[0].match(BLOCK_TAG)?.[1] === 'module')) {
return { kept, removed: paragraphs(lines), changed: true }
}
for (const paragraph of sections) {
const tag = paragraph[0].match(BLOCK_TAG)?.[1]
if (tag === 'example') {
inExample = true
changed = true
removed.push(paragraph)
continue
}
if (inExample) {
if (!tag) {
changed = true
removed.push(paragraph)
continue
}
inExample = false
}
if (SUBPATH_NOTE.test(paragraph[0]) || MARKER_TAG.test(paragraph[0])) {
changed = true
removed.push(paragraph)
continue
}
const relabelled = relabelAlert(paragraph)
if (relabelled) {
kept.push(relabelled)
changed = true
continue
}
if (RELABELLED_ALERT.test(paragraph[0])) {
kept.push(paragraph)
continue
}
if (tag) {
if (PUBLISHED_TAGS.has(tag)) {
const previous = kept.at(-1)
if (previous && BLOCK_TAG.test(previous[0])) {
previous.push(...paragraph)
} else {
kept.push(paragraph)
}
} else {
changed = true
removed.push(paragraph)
}
continue
}
if (!summary) {
kept.push(paragraph)
summary = true
} else {
changed = true
removed.push(paragraph)
}
}
return { kept, removed, changed }
}
let touched = 0
const reports = []
for (const file of declarations('dist/types')) {
const source = readFileSync(file, 'utf8')
// prettier-plugin-jsdoc puts the summary on the `/**` line when it fits, so both shapes occur
const updated = source.replace(
/^([ \t]*)\/\*\*[ \t]?([\s\S]*?)[ \t]*\*\/[ \t]*\n/gm,
(block, indent, body, offset) => {
const lines = body.split('\n').map((line) => line.replace(/^[ \t]*\*[ \t]?/, ''))
while (lines.length && lines.at(-1).trim() === '') lines.pop()
const { kept, removed, changed } = transform(lines)
if (!changed) return block
touched++
if (REPORT_STRIPPED && removed.length && !file.startsWith(PRIVATE_DECLARATIONS)) {
const line = source.slice(0, offset).split('\n').length
const stripped = removed.flatMap((paragraph, index) =>
index ? ['', ...paragraph] : paragraph,
)
reports.push(`${file}:${line}\n${stripped.map((line) => `- ${line}`).join('\n')}`)
}
if (!kept.length) return ''
const flat = kept.flatMap((paragraph, i) => (i ? ['', ...paragraph] : paragraph))
if (flat.length === 1) return `${indent}/** ${flat[0]} */\n`
const rendered = flat.map((line) => (line ? `${indent} * ${line}` : `${indent} *`)).join('\n')
return `${indent}/**\n${rendered}\n${indent} */\n`
},
)
if (updated !== source) writeFileSync(file, updated, 'utf8')
}
if (reports.length) {
console.log(`stripped published declaration documentation\n\n${reports.join('\n\n')}\n`)
}
console.log(`rewrote ${touched} declaration comment(s) for editor hovers`)