diff --git a/docs-gen/description.mjs b/docs-gen/description.mjs new file mode 100644 index 00000000..e05448fd --- /dev/null +++ b/docs-gen/description.mjs @@ -0,0 +1,109 @@ +// SEO `description:` frontmatter. Ported verbatim from the previous +// hook.mjs::processDescription so the generated meta descriptions are +// byte-for-byte identical. Parameterised by the frontmatter category label +// (drives the fallback sentence) and the root SDK label. + +import { RENDER_OPTIONS } from './manifest.mjs'; + +const KIND_LABEL_BY_CATEGORY = { + Classes: 'class', + Components: 'component', + 'Error Classes': 'error class', + Functions: 'function', + Hooks: 'hook', + Types: 'type', + Enums: 'enum', + 'Handler Types': 'handler type', +}; + +const EXCLUDED_LINE_RE = + /^(\s*(\*|-|\d+\.)|>|#+|\||Default Value|Inherited from|Overrides|protected|private|public|@param|@returns)/; + +const isExcludedLine = text => EXCLUDED_LINE_RE.test(text.trim()); + +const startsWithExcludedBlock = text => { + const firstLine = text.split('\n').find(line => line.trim().length > 0); + return firstLine ? EXCLUDED_LINE_RE.test(firstLine.trim()) : true; +}; + +function categoryFallback(model, categoryLabel, rootPackage) { + const kindLabel = KIND_LABEL_BY_CATEGORY[categoryLabel]; + if (!kindLabel) return ''; + return `${model.name} is a ${kindLabel} in the MonoCloud ${rootPackage} SDK.`; +} + +/** + * @param {import('typedoc').DeclarationReflection} model + * @param {string} categoryLabel frontmatter category label (e.g. "Functions") + * @param {string} rootPackage root SDK label (e.g. "Next.js") + */ +export function computeDescription(model, categoryLabel, rootPackage) { + const MAX = RENDER_OPTIONS.descriptionMaxLength; + const HARD_MAX = RENDER_OPTIONS.descriptionHardMaxLength; + + let rawText = model.comment?.summary?.map(x => x.text).join('') || ''; + + if (!rawText.trim() && model.signatures && model.signatures.length > 0) { + for (const sig of model.signatures) { + const sigText = sig.comment?.summary?.map(x => x.text).join('') || ''; + if (sigText.trim().length > 20 && !startsWithExcludedBlock(sigText)) { + rawText = sigText; + break; + } + } + } + + rawText = rawText.replace(/```[\s\S]*?```/g, ''); + + let cleaned = rawText + .replace(/\[\s*['"`]\/?\(\(\?![\s\S]*?\]/g, '') + .replace(/['"`]\/?\(\(\?![\s\S]*?['"`]/g, '') + .split(/\n/) + .filter(line => !isExcludedLine(line)) + .join(' ') + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + .replace(/([*_]{1,3})(\S.*?\S{0,1})\1/g, '$2') + .replace(/`([^`]+)`/g, '$1') + .replace(/\s+/g, ' ') + .replace(/\s+([.!?,'":;]+)(?=\s|$)/g, '$1') + .trim(); + + if (cleaned.length < 20) { + return categoryFallback(model, categoryLabel, rootPackage); + } + + const sentences = cleaned.split(/(?<=[.!?(:)])\s+/); + const filteredSentences = sentences.filter(s => { + const trimmed = s.trim(); + if (trimmed.length === 0) return false; + if (trimmed.endsWith(':')) return false; + if (trimmed.includes('((?!') || trimmed.startsWith("['/")) return false; + const words = trimmed.split(/\s+/); + if (words.length === 1 && trimmed.match(/^[.!?,'":;\-]/)) return false; + return true; + }); + + let d = filteredSentences.join(' '); + + if (d.length > MAX) { + const sentenceEndRe = /[.!?](?=\s+[A-Z])/g; + let lastSentenceEnd = -1; + let match; + while ((match = sentenceEndRe.exec(d)) !== null) { + if (match.index >= MAX) break; + lastSentenceEnd = match.index; + } + if (lastSentenceEnd !== -1) { + d = d.substring(0, lastSentenceEnd + 1); + } else if (d.length <= HARD_MAX) { + // keep the single long sentence whole + } else { + const clipped = d.substring(0, MAX); + const lastSpace = clipped.lastIndexOf(' '); + d = lastSpace > 0 ? clipped.substring(0, lastSpace) : clipped; + } + } + + d = d.replace(/\s*\([^)]*$/, ''); + return d.replace(/"/g, "'").trim(); +} diff --git a/docs-gen/emitter.mjs b/docs-gen/emitter.mjs new file mode 100644 index 00000000..ff541f74 --- /dev/null +++ b/docs-gen/emitter.mjs @@ -0,0 +1,463 @@ +// Controlled markdown emitter for the MonoCloud SDK docs. +// +// Replaces typedoc-plugin-markdown + hook.mjs + post-generate.mjs with a +// fully-owned pipeline: +// - re-exports are materialised as full declarations (inline-references), +// so every SDK keeps its own copy of the types it surfaces (unchanged +// behaviour — one file per (package, module, type)); +// - per-kind renderers (render/*) produce the page body; +// - URLs are computed at emit time (no link-rewriting post-pass). +// +// What it emits, per merged project: one page per documentable declaration, +// one index page per submodule, one index page per package, plus the +// top-level README.md and modules.md. See manifest.mjs for all the knobs. + +import fs from 'node:fs'; +import path from 'node:path'; +import prettier from 'prettier'; +import { ReflectionKind, ReferenceReflection } from 'typedoc'; +import { registerInlineReferences } from './inline-references.mjs'; +import { + PACKAGES, + PACKAGE_ORDER, + PROJECT_NAME, + FRAMEWORK_SLUGS, + INDEX_CATEGORY_ORDER, + OTHER_CATEGORY, + categoryMeta, + packageFileSlug, + moduleSegment, +} from './manifest.mjs'; +import { renderPage } from './render/page.mjs'; +import { + frontmatter, + heading, + bulletList, + link, + sectionsToMarkdown, +} from './render/markdown.mjs'; +import { computeDescription } from './description.mjs'; +import { readCategoryTag, frameworkForSegment, kindGroupName } from './reflect.mjs'; +import { buildLinkResolver, urlForUnit } from './links.mjs'; + +const OUTPUT_NAME = 'monocloud-markdown'; + +// Each page is formatted with Prettier (markdown), reproducing the previous +// pipeline's `formatWithPrettier` step: aligns tables, normalises blank lines +// before lists, strips heading indentation — all render-neutral formatting. +// Mirrors the repo .prettierrc. +const PRETTIER_OPTS = { + parser: 'markdown', + printWidth: 80, + proseWrap: 'preserve', + tabWidth: 2, + endOfLine: 'lf', + // Prettier DEFAULTS for embedded code (singleQuote:false, embedded:'auto') — + // the previous pipeline formatted ```ts/```typescript blocks with double + // quotes. The example fences use a `tsx:path tab="…"` info string (unknown + // language), so Prettier leaves those untouched. +}; + +async function writeFormatted(filePath, md, logger) { + // Frontmatter is added post-format in the previous pipeline, so keep it raw + // (Prettier would rewrite "title" double-quotes to single). Format only the + // markdown body. + let out = md; + try { + const m = md.match(/^(---\n[\s\S]*?\n---\n)([\s\S]*)$/); + if (m) { + const body = await prettier.format(m[2], PRETTIER_OPTS); + out = `${m[1]}\n${body}`; + } else { + out = await prettier.format(md, PRETTIER_OPTS); + } + } catch (e) { + logger.warn(`[emitter] prettier failed for ${path.basename(filePath)}: ${e.message}`); + } + fs.writeFileSync(filePath, out); +} + +const DOCUMENTABLE = new Set([ + ReflectionKind.Class, + ReflectionKind.Interface, + ReflectionKind.TypeAlias, + ReflectionKind.Enum, + ReflectionKind.Function, + ReflectionKind.Variable, +]); + +/** @param {import('typedoc').Application} app */ +export const load = app => { + registerInlineReferences(app); + app.outputs.addOutput(OUTPUT_NAME, async (outDir, project) => { + await emit(project, outDir, app.logger); + }); +}; + +async function emit(project, outDir, logger) { + const repoRoot = process.cwd(); // typedoc runs from auth-js/ + const typeUnits = []; + const modulePages = []; + const packagePages = []; + + for (const pkgModule of project.children ?? []) { + if (pkgModule.kind !== ReflectionKind.Module) continue; + const pkgName = pkgModule.name; + const pkgInfo = PACKAGES[pkgName]; + if (!pkgInfo) { + logger.warn(`[emitter] unknown package "${pkgName}" — skipped`); + continue; + } + const pkgFileSlug = packageFileSlug(pkgName); + const submodules = (pkgModule.children ?? []).filter( + c => c.kind === ReflectionKind.Module || c.kind === ReflectionKind.Namespace + ); + + if (submodules.length === 0) { + // Single-entry package (react): types live directly under the package. + const directUnits = []; + for (const child of pkgModule.children ?? []) { + if (!DOCUMENTABLE.has(child.kind)) continue; + const u = makeTypeUnit(child, pkgName, pkgInfo, pkgFileSlug, null); + typeUnits.push(u); + directUnits.push(u); + } + packagePages.push({ pkgName, pkgInfo, pkgFileSlug, submoduleList: [], directUnits }); + } else { + const submoduleList = []; + for (const sub of submodules) { + const units = []; + for (const child of sub.children ?? []) { + if (!DOCUMENTABLE.has(child.kind)) continue; + const u = makeTypeUnit(child, pkgName, pkgInfo, pkgFileSlug, sub.name); + typeUnits.push(u); + units.push(u); + } + const seg = moduleSegment(sub.name); + const fw = frameworkForSegment(seg); + const slug = fw ? FRAMEWORK_SLUGS[fw.toLowerCase()] : pkgInfo.slug; + modulePages.push({ + moduleName: sub.name, + seg, + pkgFileSlug, + slug, + rootSdk: pkgInfo.rootSdk, + units, + }); + submoduleList.push({ moduleName: sub.name, seg, slug }); + } + packagePages.push({ pkgName, pkgInfo, pkgFileSlug, submoduleList, directUnits: null }); + } + } + + const copied = expandWithReferencedCopies(typeUnits); + + const resolver = buildLinkResolver(typeUnits); + + fs.mkdirSync(outDir, { recursive: true }); + for (const u of typeUnits) await writeTypePage(u, resolver, outDir, logger); + for (const m of modulePages) await writeModulePage(m, outDir, logger); + for (const p of packagePages) await writePackagePage(p, repoRoot, outDir, logger); + await writeReadme(repoRoot, outDir, logger); + await writeModulesIndex(outDir, logger); + + logger.info( + `[emitter] wrote ${typeUnits.length} type pages (${copied} in-SDK copies of ` + + `referenced types), ${modulePages.length} module pages, ` + + `${packagePages.length} package pages, README.md, modules.md` + ); +} + +// --------------------------------------------------------------------------- +// Referenced-type closure +// --------------------------------------------------------------------------- +// +// A page links out of its SDK whenever it references a type that the SDK does +// not itself export (mostly internal base types like `MonoCloudOidcClientBase` +// and base interfaces such as `IMonoCloudCookieRequest`, which live only under +// their owner package). To keep every link inside the current SDK, we +// materialise a local copy of each such referenced page in the SDK that +// references it, then repeat for the copies' own references until closure. +// +// The set of reflections a page would link to is exactly the set the renderers +// hand to `ctx.linkFor` / `ctx.linkForExact`, so we discover references by +// rendering each page with a recording context — no separate (and drift-prone) +// type-AST walker. Copies mirror only reflections that are already a documented +// page in their owner package (`originalRefIds`), so nothing is fabricated. + +/** Reflections that get their own page. */ +function derefTarget(reflection) { + let r = reflection; + if (r instanceof ReferenceReflection) r = r.tryGetTargetReflectionDeep?.() ?? r; + return r; +} + +/** Map a referenced reflection to the documentable page it belongs to (or null). */ +function pageTarget(reflection) { + const r = derefTarget(reflection); + if (!r) return null; + if (DOCUMENTABLE.has(r.kind)) return r; + // A referenced member (method/property/accessor) belongs to its owner's page. + const owner = r.parent ? derefTarget(r.parent) : null; + if (owner && DOCUMENTABLE.has(owner.kind)) return owner; + return null; +} + +/** Render a page with a recording context and return every reflection it links. */ +function collectReferencedReflections(ref, categoryTag) { + const seen = []; + const rec = r => { + if (r) seen.push(r); + return null; + }; + try { + renderPage({ ref, categoryTag, rootSdk: '', framework: undefined, description: '', ctx: { linkFor: rec, linkForExact: rec } }); + } catch { + // A malformed reflection shouldn't abort the whole closure; a missed + // reference just leaves that one link pointing at the canonical page. + } + return seen; +} + +/** + * Grow `typeUnits` in place with in-SDK copies of every transitively referenced + * page. Returns the number of copies added. + */ +function expandWithReferencedCopies(typeUnits) { + const ctxKeyOf = u => `${u.pkgName}|${u.framework ?? ''}`; + const ctxInfo = new Map(); // ctxKey -> { pkgName, framework, slug, rootSdk, pkgFileSlug } + const ctxSeg = new Map(); // ctxKey -> representative module segment (for filenames) + const segsByCtx = new Map(); + const originalRefIds = new Set(); + const coveredKeys = new Set(); // `${ctxKey}|${name}::${label}` + + for (const u of typeUnits) { + originalRefIds.add(u.ref.id); + const ck = ctxKeyOf(u); + if (!ctxInfo.has(ck)) { + ctxInfo.set(ck, { + pkgName: u.pkgName, + framework: u.framework, + slug: u.slug, + rootSdk: u.rootSdk, + pkgFileSlug: u.pkgFileSlug, + }); + } + if (!segsByCtx.has(ck)) segsByCtx.set(ck, []); + segsByCtx.get(ck).push(u.seg); + coveredKeys.add(`${ck}|${u.ref.name}::${u.meta.label}`); + } + + // Copies need a module segment only for their on-disk filename; pick a stable + // representative per context (prefer `index`, else the most common segment). + for (const [ck, segs] of segsByCtx) { + if (segs.includes('index')) { + ctxSeg.set(ck, 'index'); + continue; + } + const counts = new Map(); + for (const s of segs) counts.set(s, (counts.get(s) ?? 0) + 1); + let best = segs[0] ?? null; + let bestN = -1; + for (const [s, n] of [...counts.entries()].sort((a, b) => String(a[0]).localeCompare(String(b[0])))) { + if (n > bestN) { + best = s; + bestN = n; + } + } + ctxSeg.set(ck, best); + } + + const makeCopy = (targetRef, ck) => { + const info = ctxInfo.get(ck); + const seg = ctxSeg.get(ck); + const categoryTag = readCategoryTag(targetRef); + const meta = categoryMeta(categoryTag); + const fileName = seg + ? `${info.pkgFileSlug}.${seg}.${targetRef.name}.md` + : `${info.pkgFileSlug}.${targetRef.name}.md`; + return { + ref: targetRef, + pkgName: info.pkgName, + pkgFileSlug: info.pkgFileSlug, + moduleName: null, + seg, + framework: info.framework, + slug: info.slug, + rootSdk: info.rootSdk, + categoryTag, + meta, + fileName, + folder: meta.folder, + isCopy: true, + }; + }; + + const worklist = [...typeUnits]; + let added = 0; + while (worklist.length > 0) { + const u = worklist.pop(); + const ck = ctxKeyOf(u); + for (const raw of collectReferencedReflections(u.ref, u.categoryTag)) { + const target = pageTarget(raw); + if (!target) continue; + if (target.id === u.ref.id) continue; // self-reference + if (!originalRefIds.has(target.id)) continue; // only mirror real pages + const label = categoryMeta(readCategoryTag(target)).label; + const coverKey = `${ck}|${target.name}::${label}`; + if (coveredKeys.has(coverKey)) continue; // SDK already has this page + coveredKeys.add(coverKey); + const copy = makeCopy(target, ck); + typeUnits.push(copy); + worklist.push(copy); + added += 1; + } + } + return added; +} + +// --------------------------------------------------------------------------- +// Units +// --------------------------------------------------------------------------- + +function makeTypeUnit(ref, pkgName, pkgInfo, pkgFileSlug, moduleName) { + const seg = moduleName ? moduleSegment(moduleName) : null; + const fw = frameworkForSegment(seg); + const slug = fw ? FRAMEWORK_SLUGS[fw.toLowerCase()] : pkgInfo.slug; + const categoryTag = readCategoryTag(ref); + const meta = categoryMeta(categoryTag); + const fileName = seg + ? `${pkgFileSlug}.${seg}.${ref.name}.md` + : `${pkgFileSlug}.${ref.name}.md`; + return { + ref, + pkgName, + pkgFileSlug, + moduleName, + seg, + framework: fw, + slug, + rootSdk: pkgInfo.rootSdk, + categoryTag, + meta, + fileName, + folder: meta.folder, + }; +} + +// --------------------------------------------------------------------------- +// Writers +// --------------------------------------------------------------------------- + +async function writeTypePage(u, resolver, outDir, logger) { + const ctx = { + linkFor: r => resolver.resolve(r, u.pkgName, u.framework, u.ref.id), + linkForExact: r => resolver.resolveExact(r, u.pkgName, u.framework, u.ref.id), + }; + const description = computeDescription(u.ref, u.meta.label, u.rootSdk); + const md = renderPage({ + ref: u.ref, + categoryTag: u.categoryTag, + rootSdk: u.rootSdk, + framework: u.framework, + description, + ctx, + }); + const dir = path.join(outDir, u.folder); + fs.mkdirSync(dir, { recursive: true }); + await writeFormatted(path.join(dir, u.fileName), md, logger); +} + +async function writeModulePage(m, outDir, logger) { + const h1 = m.moduleName.includes('/') ? m.moduleName.split('/').pop() : m.moduleName; + const framework = frameworkForSegment(m.seg); + const fm = frontmatter({ + rootSdk: m.rootSdk, + title: m.moduleName, + category: OTHER_CATEGORY.label, + framework, + }); + const body = renderIndexGroups(m.units); + const md = sectionsToMarkdown([fm, heading(1, h1), body]) + '\n'; + const dir = path.join(outDir, OTHER_CATEGORY.folder); + fs.mkdirSync(dir, { recursive: true }); + await writeFormatted(path.join(dir, `${m.pkgFileSlug}.${m.seg}.md`), md, logger); +} + +async function writePackagePage(p, repoRoot, outDir, logger) { + const h1 = p.pkgName.split('/').pop(); + const fm = frontmatter({ + rootSdk: p.pkgInfo.rootSdk, + title: p.pkgName, + category: OTHER_CATEGORY.label, + }); + const readme = readReadme(path.join(repoRoot, 'packages', p.pkgInfo.dir, 'README.md'), logger); + + let tail; + if (p.submoduleList.length > 0) { + const mods = [...p.submoduleList].sort((a, b) => a.moduleName.localeCompare(b.moduleName)); + const items = mods.map(m => + link(m.moduleName, `/sdks/${m.slug}/api-reference/${OTHER_CATEGORY.url}/${m.seg}`) + ); + tail = `${heading(2, 'Modules')}\n\n${bulletList(items)}`; + } else { + tail = renderIndexGroups(p.directUnits); + } + + const body = sectionsToMarkdown([readme, tail]); + const md = sectionsToMarkdown([fm, heading(1, h1), body]) + '\n'; + const dir = path.join(outDir, OTHER_CATEGORY.folder); + fs.mkdirSync(dir, { recursive: true }); + await writeFormatted(path.join(dir, `${p.pkgFileSlug}.md`), md, logger); +} + +async function writeReadme(repoRoot, outDir, logger) { + const fm = frontmatter({ rootSdk: 'Docs', title: PROJECT_NAME, category: OTHER_CATEGORY.label }); + const readme = readReadme(path.join(repoRoot, 'README.md'), logger); + const md = sectionsToMarkdown([fm, heading(1, PROJECT_NAME), readme]) + '\n'; + await writeFormatted(path.join(outDir, 'README.md'), md, logger); +} + +async function writeModulesIndex(outDir, logger) { + const fm = frontmatter({ rootSdk: 'Docs', title: PROJECT_NAME, category: OTHER_CATEGORY.label }); + const items = PACKAGE_ORDER.map(name => + link(name, `/sdks/${PACKAGES[name].slug}/api-reference/${OTHER_CATEGORY.url}/${packageFileSlug(name)}`) + ); + const body = `${heading(2, 'Packages')}\n\n${bulletList(items)}`; + const md = sectionsToMarkdown([fm, heading(1, PROJECT_NAME), body]) + '\n'; + await writeFormatted(path.join(outDir, 'modules.md'), md, logger); +} + +// --------------------------------------------------------------------------- +// Index page bodies (grouped member lists) +// --------------------------------------------------------------------------- + +function renderIndexGroups(units) { + const groups = new Map(); + for (const u of units) { + const group = u.categoryTag ?? kindGroupName(u.ref.kind); + if (!groups.has(group)) groups.set(group, []); + groups.get(group).push(u); + } + + const order = [...INDEX_CATEGORY_ORDER]; + for (const g of groups.keys()) if (!order.includes(g)) order.push(g); + + const sections = []; + for (const group of order) { + const items = groups.get(group); + if (!items || items.length === 0) continue; + const list = bulletList(items.map(u => link(u.ref.name, urlForUnit(u)))); + sections.push(`${heading(2, group)}\n\n${list}`); + } + return sections.join('\n\n'); +} + +function readReadme(filePath, logger) { + try { + return fs.readFileSync(filePath, 'utf-8').replace(/\r\n/g, '\n').trim(); + } catch { + logger.warn(`[emitter] README not found: ${filePath}`); + return ''; + } +} diff --git a/docs-gen/links.mjs b/docs-gen/links.mjs new file mode 100644 index 00000000..0214e119 --- /dev/null +++ b/docs-gen/links.mjs @@ -0,0 +1,101 @@ +// Emit-time link resolution. URLs are computed up front (no post-processing +// pass over the markdown) and a reference is always resolved to the copy that +// lives in the *current* SDK. The emitter now materialises a local copy of +// every referenced type inside each SDK that references it (see +// `expandWithReferencedCopies` in emitter.mjs), so a same-SDK copy exists for +// essentially every reference and links never jump to another SDK. `byId` +// (canonical fallback) is only reached for the rare reflection that has no +// page at all. + +import { ReferenceReflection } from 'typedoc'; +import { categoryMeta } from './manifest.mjs'; +import { readCategoryTag } from './reflect.mjs'; + +/** Canonical URL for an emitted type unit. */ +export function urlForUnit(unit) { + // unit.meta.url is undefined for the "Other" bucket; the template literal + // intentionally renders it as the literal "undefined" segment to reproduce + // the current site's link shape. + return `/sdks/${unit.slug}/api-reference/${unit.meta.url}/${unit.ref.name.toLowerCase()}`; +} + +/** + * Build resolvers over all emitted type units. + * + * `resolve` (type references): a re-exported type's reflection points at the + * ORIGINAL declaration, but the previous pipeline linked to the copy in the + * current SDK — so prefer a same-SDK copy, falling back to the exact target. + * + * `resolveExact` (inline `{@link}` tags): TypeDoc resolves these to the actual + * target declaration (e.g. a base class defined in auth-core). Prefer the copy + * of that declaration in the current SDK so inline links stay in-SDK too, + * falling back to the canonical page (and then to a member anchor on its + * owner's page) only when no same-SDK copy exists. + */ +export function buildLinkResolver(units) { + const byId = new Map(); + const byContent = new Map(); + + for (const u of units) { + const url = urlForUnit(u); + // Copies share their canonical reflection's id; keep `byId` pointing at the + // canonical (non-copy) page so the cross-SDK fallback stays deterministic. + // Same-SDK copies are found through `byContent` (sameSdkCopy) instead. + if (!u.isCopy) byId.set(u.ref.id, { unit: u, url }); + const key = `${u.ref.name}::${u.meta.label}`; + if (!byContent.has(key)) byContent.set(key, []); + byContent.get(key).push({ unit: u, url }); + } + + const deref = reflection => { + let r = reflection; + if (r instanceof ReferenceReflection) r = r.tryGetTargetReflectionDeep?.() ?? r; + return r; + }; + + const sameSdkCopy = (r, curPkg, curFw) => { + const label = categoryMeta(readCategoryTag(r)).label; + const cands = byContent.get(`${r.name}::${label}`); + if (!cands || cands.length === 0) return null; + const fw = curFw ?? null; + const pick = + cands.find(c => c.unit.pkgName === curPkg && (c.unit.framework ?? null) === fw) || + cands.find(c => c.unit.pkgName === curPkg) || + (fw && cands.find(c => (c.unit.framework ?? null) === fw)) || + cands[0]; + return pick.url; + }; + + function resolve(reflection, curPkg, curFw, selfId) { + const r = deref(reflection); + if (!r) return null; + if (selfId != null && r.id === selfId) return null; + return sameSdkCopy(r, curPkg, curFw) ?? byId.get(r.id)?.url ?? null; + } + + function resolveExact(reflection, curPkg, curFw, selfId) { + const r = deref(reflection); + if (!r) return null; + if (selfId != null && r.id === selfId) return null; + // Prefer the current SDK's own copy of the target (post-closure this almost + // always exists), so inline links don't jump packages either. + const same = sameSdkCopy(r, curPkg, curFw); + if (same) return same; + // No same-SDK copy: link to the canonical page for the exact target. + const hit = byId.get(r.id); + if (hit) return hit.url; + // A `{@link}` to a class/interface member (method/property/accessor) links + // to that member's anchor on its owner's page — or just `#anchor` when the + // owner is the current page. + const owner = r.parent; + if (owner) { + const anchor = `#${r.name.toLowerCase()}`; + if (selfId != null && owner.id === selfId) return anchor; + const ownerUrl = sameSdkCopy(owner, curPkg, curFw) ?? byId.get(owner.id)?.url; + if (ownerUrl) return ownerUrl + anchor; + } + return null; + } + + return { resolve, resolveExact }; +} diff --git a/docs-gen/manifest.mjs b/docs-gen/manifest.mjs new file mode 100644 index 00000000..80dd1fa8 --- /dev/null +++ b/docs-gen/manifest.mjs @@ -0,0 +1,150 @@ +// Single source of truth for the controlled markdown emitter. +// +// Everything that decides *what* gets emitted and *how it is labelled* lives +// here, so the rendering layer (render/*) stays mechanical. This is the file +// you edit to retune the docs: package labels, category mapping, URL shape, +// and the render toggles (show/hide Returns, Examples, parameter format, …). + +// --------------------------------------------------------------------------- +// Packages +// --------------------------------------------------------------------------- +// +// Listed in canonical-owner priority order. `slug` is the URL segment used by +// the Docs site (/sdks//…); `rootSdk` is the human label written into +// frontmatter and used by the site to group a type under the right SDK. + +export const PACKAGES = { + '@monocloud/auth-core': { slug: 'nodejs', rootSdk: 'Node.js', dir: 'core' }, + '@monocloud/auth-web-js': { slug: 'web-js', rootSdk: 'JavaScript', dir: 'web-js' }, + '@monocloud/auth-react': { slug: 'react', rootSdk: 'React', dir: 'react' }, + '@monocloud/auth-node-core': { slug: 'nodejs-core', rootSdk: 'Node.js Core', dir: 'node-core' }, + '@monocloud/auth-nextjs': { slug: 'nextjs', rootSdk: 'Next.js', dir: 'nextjs' }, + '@monocloud/backend-node': { slug: 'nodejs-backend', rootSdk: 'Node.js Backend', dir: 'node-backend' }, +}; + +// Order packages appear in the top-level modules.md package list. +export const PACKAGE_ORDER = Object.keys(PACKAGES); + +// Project name (typedoc `name`) — used for the README/modules H1 + title. +export const PROJECT_NAME = 'MonoCloud Authentication SDK'; + +// Framework-specific entry points (backend-node) route to their own SDK slug. +// Keyed by the module's leading path segment. +export const FRAMEWORK_SLUGS = { + express: 'express-backend', + fastify: 'fastify-backend', +}; + +// Module path segment -> framework label (frontmatter `framework:`). +export const FRAMEWORK_LABELS = { + express: 'Express', + fastify: 'Fastify', +}; + +// --------------------------------------------------------------------------- +// Categories +// --------------------------------------------------------------------------- +// +// Keyed by the source-level JSDoc `@category` tag value. `folder` is the +// on-disk directory; `label` is the frontmatter `category:` value; `prefix` +// is the H1 title prefix ("Class: Foo"); `url` is the path segment used when +// linking to a member of this category. A reflection with no `@category` tag +// falls into OTHER_CATEGORY below. + +export const CATEGORIES = { + Classes: { folder: 'Classes', label: 'Classes', prefix: 'Class', url: 'classes' }, + Components: { folder: 'Components', label: 'Components', prefix: 'Component', url: 'components' }, + 'Error Classes': { folder: 'Error_Classes', label: 'Error Classes', prefix: 'Error Class', url: 'error-classes' }, + Functions: { folder: 'Functions', label: 'Functions', prefix: 'Function', url: 'functions' }, + Hooks: { folder: 'Hooks', label: 'Hooks', prefix: 'Hook', url: 'hooks' }, + Types: { folder: 'Types', label: 'Types', prefix: 'Type', url: 'types' }, + 'Types (Enums)': { folder: 'Types_(Enums)', label: 'Enums', prefix: 'Enum', url: 'enums' }, + 'Types (Handler)': { folder: 'Types_(Handler)', label: 'Handler Types', prefix: 'Handler Type', url: 'handler-types' }, +}; + +// Bucket for reflections without an `@category` tag (utility functions) and +// for module/package index pages. NOTE: `url` is intentionally undefined to +// reproduce the current site's link shape (…/api-reference/undefined/). +// Set `url: 'other'` here once the Docs site adds an `other` route. +export const OTHER_CATEGORY = { + folder: 'Other', + label: 'Other', + prefix: null, + url: undefined, +}; + +// Order categories appear in module index pages ("## Classes", "## Types", …). +export const INDEX_CATEGORY_ORDER = [ + 'Classes', + 'Components', + 'Error Classes', + 'Functions', + 'Hooks', + 'Types', + 'Types (Enums)', + 'Types (Handler)', +]; + +// --------------------------------------------------------------------------- +// Render options — the control surface +// --------------------------------------------------------------------------- +// +// Defaults reproduce the current production output exactly. Flip these to +// change what the renderers emit; nothing else needs to change. + +export const RENDER_OPTIONS = { + // Returns ----------------------------------------------------------------- + showReturns: true, // emit the "Returns" section for functions/methods/hooks + showReturnsForComponents: false, // components never show a Returns section + + // Examples / remarks ------------------------------------------------------ + showExamples: true, // emit `@example` blocks + showRemarks: true, // emit `@remarks` content + + // Parameters / properties ------------------------------------------------- + parametersFormat: 'table', // 'table' (only table is implemented today) + dropEmptyTableColumns: true, // drop a table column when every cell is blank + showParameters: true, + + // Members ----------------------------------------------------------------- + showInheritedFrom: true, // emit "Inherited from" under constructors/methods + showImplementationOf: false, // current pipeline strips "Implementation of" + showExtendedBy: false, // current pipeline strips "Extended by" + + // Titles / headings ------------------------------------------------------- + // Per-category H1 prefix override. `null`/missing -> use CATEGORIES prefix. + titlePrefixOverrides: {}, + + // Frontmatter ------------------------------------------------------------- + emitDescription: true, // emit the SEO `description:` frontmatter key + descriptionMaxLength: 160, + descriptionHardMaxLength: 200, +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** `@monocloud/auth-core` -> `_monocloud_auth-core` */ +export function packageFileSlug(pkgName) { + return pkgName.replace(/@/g, '_').replace(/\//g, '_'); +} + +/** A submodule reflection name (`frameworks/express`) -> `frameworks_express`. */ +export function moduleSegment(moduleName) { + return moduleName.replace(/\//g, '_'); +} + +/** Resolve category meta from a `@category` tag value (or null -> Other). */ +export function categoryMeta(tagValue) { + if (tagValue && CATEGORIES[tagValue]) return CATEGORIES[tagValue]; + return OTHER_CATEGORY; +} + +/** Map a frontmatter category label back to its URL segment (for links). */ +export function urlSegmentForLabel(label) { + for (const meta of Object.values(CATEGORIES)) { + if (meta.label === label) return meta.url; + } + return OTHER_CATEGORY.url; // undefined for Other +} diff --git a/docs-gen/post-generate.mjs b/docs-gen/post-generate.mjs deleted file mode 100644 index 18938a44..00000000 --- a/docs-gen/post-generate.mjs +++ /dev/null @@ -1,244 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { glob } from 'glob'; - -const SDK_SLUGS = { - default: 'docs', - '_monocloud_auth-nextjs': 'nextjs', - '_monocloud_auth-react': 'react', - '_monocloud_auth-node-core': 'nodejs-core', - '_monocloud_auth-core': 'nodejs', - '_monocloud_backend-node': 'nodejs-backend', - '_monocloud_auth-web-js': 'web-js', -}; - -const CATEGORY_MAP = { - Classes: 'classes', - Functions: 'functions', - Types: 'types', - Enums: 'enums', - 'Handler Types': 'handler-types', - Components: 'components', - Hooks: 'hooks', - 'Error Classes': 'error-classes', -}; - -const FRAMEWORK_SLUGS = { - express: 'express-backend', - fastify: 'fastify-backend', -}; - -const DOCS_DIR = './docs/markdown'; - -async function main() { - console.log('🔍 Starting post-processing...'); - const files = await glob(`${DOCS_DIR}/**/*.md{,x}`); - - const fileIndex = new Map(); - console.log(' Index building...'); - - for (const filePath of files) { - const content = fs.readFileSync(filePath, 'utf-8'); - - const sdkMatch = content.match(/^rootSdk:\s*(.*)/m); - const catMatch = content.match(/^category:\s*(.*)/m); - const fwMatch = content.match(/^framework:\s*(.*)/m); - - const rootSdk = sdkMatch ? sdkMatch[1].trim() : 'default'; - const category = catMatch ? catMatch[1].trim() : 'other'; - const framework = fwMatch ? fwMatch[1].trim() : null; - - fileIndex.set(path.resolve(filePath), { rootSdk, category, framework }); - } - - // Build framework lookup: (category, className, framework) → filePath - const frameworkIndex = new Map(); - for (const [absPath, meta] of fileIndex.entries()) { - if (!meta.framework) continue; - const className = path - .basename(absPath, path.extname(absPath)) - .toLowerCase() - .split('.') - .at(-1); - frameworkIndex.set( - `${meta.category}|${className}|${meta.framework}`, - absPath - ); - } - - console.log(' Processing links and tables...'); - for (const filePath of files) { - await processFile(filePath, fileIndex, frameworkIndex); - } - - console.log('✅ Post-processing complete.'); -} - -function rewriteLinks( - content, - sourceFileDir, - fileIndex, - sourceFramework, - frameworkIndex -) { - const linkRegex = /\[([^\]]+)\]\((?:<([^>]+)>|([^)]+))\)/g; - - return content.replace( - linkRegex, - (fullLink, linkText, urlWithBrackets, urlStandard) => { - const rawLinkUrl = urlWithBrackets || urlStandard; - - if (!rawLinkUrl) return fullLink; - let linkUrl = rawLinkUrl.trim(); - - if ( - linkUrl.startsWith('http') || - linkUrl.startsWith('#') || - linkUrl.startsWith('/') - ) { - return fullLink; - } - - const hashIndex = linkUrl.indexOf('#'); - let cleanPath = linkUrl; - let urlHash = ''; - - if (hashIndex !== -1) { - cleanPath = linkUrl.substring(0, hashIndex); - urlHash = linkUrl.substring(hashIndex); - } - - const targetAbsPath = path.resolve(sourceFileDir, cleanPath); - const targetMeta = fileIndex.get(targetAbsPath); - - if (!targetMeta) { - return fullLink; - } - - const { category } = targetMeta; - - const typeName = path - .basename(cleanPath, path.extname(cleanPath)) - .toLowerCase() - .split('.'); - - const targetClassName = typeName.at(-1); - - // Determine effective framework for URL construction - let effectiveFramework = targetMeta.framework; - - // If source is framework-specific and target is NOT, check for sibling - if (sourceFramework && !effectiveFramework) { - const key = `${category}|${targetClassName}|${sourceFramework}`; - if (frameworkIndex.has(key)) { - effectiveFramework = sourceFramework; - } - } - - // Use framework slug if applicable, otherwise standard SDK slug - const finalSdkSlug = effectiveFramework - ? FRAMEWORK_SLUGS[effectiveFramework.toLowerCase()] || - SDK_SLUGS[typeName[0]] || - SDK_SLUGS['default'] - : SDK_SLUGS[typeName[0]] || SDK_SLUGS['default']; - - const newUrl = `/sdks/${finalSdkSlug}/api-reference/${CATEGORY_MAP[category]}/${targetClassName}`; - - return `[${linkText}](${newUrl}${urlHash})`; - } - ); -} - -async function processFile(filePath, fileIndex, frameworkIndex) { - let content = fs.readFileSync(filePath, 'utf-8'); - let hasChanges = false; - const fileDir = path.dirname(filePath); - const sourceMeta = fileIndex.get(path.resolve(filePath)); - const sourceFramework = sourceMeta?.framework || null; - - if (content.includes(' { - const transformed = body - .replace(/\n#{3,6}\s+Type Literal\s*\n/g, '\n') - .replace(/\n---\n/g, '\n') - .replace(/^(\\\{[^\n]*\\\})\s*$/gm, '> $1\n') - .replace(/^(`[^`\n]+`)\s*$/gm, '> $1\n'); - return `\n${hashes} Type Declaration${transformed}`; - } - ); - if (unionTransformed !== content) { - content = unionTransformed; - hasChanges = true; - } - - const propsLinkRegex = - /(#{2,})\s+Parameters[\s\S]*?\|\s*`props`\s*\|\s*\[.*?\]\((.*?)\)/; - const match = content.match(propsLinkRegex); - - if (match) { - const [fullMatch, headingLevel, relativeLinkPath] = match; - const cleanLinkPath = relativeLinkPath.replace(/^<|>$/g, ''); - const typeFilePath = path.resolve(fileDir, cleanLinkPath); - - if (fs.existsSync(typeFilePath)) { - let typeFileContent = fs.readFileSync(typeFilePath, 'utf-8'); - - const typeFileDir = path.dirname(typeFilePath); - typeFileContent = rewriteLinks( - typeFileContent, - typeFileDir, - fileIndex, - sourceFramework, - frameworkIndex - ); - - typeFileContent = typeFileContent.replace(/<\/a>\s*/g, ''); - - const tableRegex = - /(#{2,})\s+(Properties|Type declaration)[\s\S]*?(\|[\s\S]*?)(?=\n#{2,} |$)/; - const tableMatch = typeFileContent.match(tableRegex); - - if (tableMatch) { - const [, , , tableData] = tableMatch; - const newSection = `${headingLevel} Props\n\n${tableData}\n`; - const entireParamsBlockRegex = - /(#{2,})\s+Parameters[\s\S]*?(?=\n#{2,} |$)/; - content = content.replace(entireParamsBlockRegex, newSection); - hasChanges = true; - } - } - } - - const newContent = rewriteLinks( - content, - fileDir, - fileIndex, - sourceFramework, - frameworkIndex - ); - - if (newContent !== content) { - content = newContent; - hasChanges = true; - } - - if (hasChanges) { - fs.writeFileSync(filePath, content, 'utf-8'); - } -} - -main().catch(console.error); diff --git a/docs-gen/reflect.mjs b/docs-gen/reflect.mjs new file mode 100644 index 00000000..32d577d9 --- /dev/null +++ b/docs-gen/reflect.mjs @@ -0,0 +1,41 @@ +// Small reflection helpers shared by the emitter and link resolver. + +import { ReflectionKind } from 'typedoc'; + +/** Read the `@category` tag value from a reflection (or its signatures). */ +export function readCategoryTag(ref) { + const read = comment => { + for (const t of comment?.blockTags ?? []) { + if (t.tag === '@category') return t.content?.map(p => p.text).join('').trim(); + } + return null; + }; + return ( + read(ref.comment) ?? + (ref.signatures ?? []).map(s => read(s.comment)).find(Boolean) ?? + null + ); +} + +/** Module path segment -> framework label, or undefined. */ +export function frameworkForSegment(seg) { + if (!seg) return undefined; + if (seg.includes('express')) return 'Express'; + if (seg.includes('fastify')) return 'Fastify'; + return undefined; +} + +// Default typedoc group name per kind, used as the index-page heading for +// members that carry no `@category` tag (utility functions). +const KIND_GROUP = { + [ReflectionKind.Class]: 'Classes', + [ReflectionKind.Interface]: 'Interfaces', + [ReflectionKind.TypeAlias]: 'Type Aliases', + [ReflectionKind.Enum]: 'Enumerations', + [ReflectionKind.Function]: 'Functions', + [ReflectionKind.Variable]: 'Variables', +}; + +export function kindGroupName(kind) { + return KIND_GROUP[kind] ?? 'Other'; +} diff --git a/docs-gen/render/class.mjs b/docs-gen/render/class.mjs new file mode 100644 index 00000000..1267e410 --- /dev/null +++ b/docs-gen/render/class.mjs @@ -0,0 +1,112 @@ +// Class / error-class body. Section order matches the existing output: +// ## Extends ## Implements ## Constructors ## Properties ## Methods +// "Extended by" and "Implementation of" are intentionally omitted. + +import { heading, sectionsToMarkdown, bulletList, inlineCode } from './markdown.mjs'; +import { renderType } from './type.mjs'; +import { + renderSignature, + renderMemberSignatures, + renderTypeParametersSection, + renderParametersTable, + renderReturns, +} from './signature.mjs'; +import { renderDescription } from './comment.mjs'; +import { renderPropertiesTable, partitionMembers } from './members.mjs'; +import { hasGroups, renderFlatMembers } from './flat-members.mjs'; + +/** + * @param {object} [opts] + * @param {boolean} [opts.extendedBy] render the reverse "Extended by" list + * (kept only for Error Classes; Classes/Types strip it). + */ +export function renderClassBody(ref, ctx, opts = {}) { + const heritage = [ + renderDescription(ref.comment, ctx), + renderHeritage('Extends', ref.extendedTypes, ctx), + opts.extendedBy ? renderExtendedBy(ref, ctx) : '', + renderHeritage('Implements', ref.implementedTypes, ctx), + renderTypeParametersSection(ref.typeParameters, ctx, 2), + ]; + + // No groups -> flat `## member` sections (typically `export type` re-exports). + if (!hasGroups(ref)) { + return sectionsToMarkdown([...heritage, renderFlatMembers(ref, ctx)]); + } + + const { props, methods, ctors, accessors } = partitionMembers(ref); + return sectionsToMarkdown([ + ...heritage, + renderConstructors(ctors, ref, ctx), + renderPropertiesTable(props, ctx), + renderAccessors(accessors, ctx), + renderMethods(methods, ctx), + ]); +} + +function renderAccessors(accessors, ctx) { + if (!accessors || accessors.length === 0) return ''; + const blocks = accessors.map(a => { + const parts = [heading(3, a.name)]; + if (a.getSignature) { + parts.push(heading(4, 'Get Signature')); + parts.push(`> **get** **${a.name}**(): ${renderType(a.getSignature.type, ctx)}`); + const d = renderDescription(a.getSignature.comment, ctx); + if (d) parts.push(d); + parts.push(renderReturns(a.getSignature, ctx, 5)); + } + if (a.setSignature) { + const param = a.setSignature.parameters?.[0]; + parts.push(heading(4, 'Set Signature')); + const paramStr = param ? `${inlineCode(param.name)}: ${renderType(param.type, ctx)}` : ''; + parts.push(`> **set** **${a.name}**(${paramStr}): ${renderType(a.setSignature.type, ctx)}`); + const d = renderDescription(a.setSignature.comment, ctx); + if (d) parts.push(d); + parts.push(renderParametersTable(a.setSignature.parameters ?? [], ctx, 5)); + parts.push(renderReturns(a.setSignature, ctx, 5)); + } + return parts.filter(Boolean).join('\n\n'); + }); + return `${heading(2, 'Accessors')}\n\n${blocks.join('\n\n---\n\n')}`; +} + +// Multiple heritage types render as a SINGLE bullet joined by `.` +// (typedoc-plugin-markdown's quirk for the Extends/Implements lists). +function renderHeritage(label, types, ctx) { + if (!types || types.length === 0) return ''; + return `${heading(2, label)}\n\n- ${types.map(t => renderType(t, ctx)).join('.')}`; +} + +function renderExtendedBy(ref, ctx) { + const subs = ref.extendedBy ?? []; + if (subs.length === 0) return ''; + return `${heading(2, 'Extended by')}\n\n${bulletList(subs.map(t => renderType(t, ctx)))}`; +} + +function renderConstructors(ctors, classRef, ctx) { + if (ctors.length === 0) return ''; + const blocks = []; + for (const ctor of ctors) { + for (const sig of ctor.signatures ?? []) { + blocks.push( + renderSignature(sig, { + displayName: classRef.name, + asNew: true, + headingLevel: 4, + owner: ctor, + ctx, + }) + ); + } + } + if (blocks.length === 0) return ''; + return `${heading(2, 'Constructors')}\n\n${heading(3, 'Constructor')}\n\n${blocks.join('\n\n---\n\n')}`; +} + +function renderMethods(methods, ctx) { + if (methods.length === 0) return ''; + const blocks = methods.map( + m => `${heading(3, `${m.name}()`)}\n\n${renderMemberSignatures(m, m.name, 4, ctx)}` + ); + return `${heading(2, 'Methods')}\n\n${blocks.join('\n\n---\n\n')}`; +} diff --git a/docs-gen/render/comment.mjs b/docs-gen/render/comment.mjs new file mode 100644 index 00000000..f16349de --- /dev/null +++ b/docs-gen/render/comment.mjs @@ -0,0 +1,113 @@ +// Render JSDoc comments to markdown. +// +// A TypeDoc Comment has `summary` (CommentDisplayPart[]) and `blockTags` +// ({ tag, content, name? }[]). We resolve inline `{@link}` tags to real Docs +// URLs (they show up as links in the current output) and group `@example` +// blocks under a single pluralised heading, matching the existing pipeline. + +import { RENDER_OPTIONS } from '../manifest.mjs'; +import { heading, link, inlineCode } from './markdown.mjs'; + +/** Render CommentDisplayPart[] to markdown, resolving inline links. */ +export function partsToMarkdown(parts, ctx) { + if (!parts) return ''; + return parts + .map(p => { + if (p.kind === 'inline-tag') return renderInlineTag(p, ctx); + // text + code parts are emitted verbatim + return p.text ?? ''; + }) + .join(''); +} + +function renderInlineTag(part, ctx) { + const tag = part.tag; + if (tag === '@link' || tag === '@linkcode' || tag === '@linkplain') { + const target = part.target; + // Inline links resolve to the EXACT target declaration (e.g. an error class + // in auth-core), not a same-SDK copy. + const resolveLink = ctx?.linkForExact ?? ctx?.linkFor; + const url = + target && typeof target === 'object' && resolveLink + ? resolveLink(target) + : typeof target === 'string' + ? target + : null; + const text = (part.text ?? '').trim() || ''; + if (url) return link(text, url); + return text; + } + // Other inline tags (e.g. {@inheritDoc}) collapse to their text. + return part.text ?? ''; +} + +/** Summary (lead description) only. */ +export function renderSummary(comment, ctx) { + if (!comment) return ''; + return partsToMarkdown(comment.summary ?? [], ctx).trim(); +} + +/** + * The lead description = the summary only. `@remarks`, `@example`, `@see` are + * rendered as their own sections (see renderRemarks/renderExamples/renderSee). + */ +export function renderDescription(comment, ctx) { + return renderSummary(comment, ctx); +} + +/** `@remarks` rendered as a "Remarks" section. */ +export function renderRemarks(comment, ctx, level) { + if (!comment || !RENDER_OPTIONS.showRemarks) return ''; + const body = (comment.blockTags ?? []) + .filter(t => t.tag === '@remarks') + .map(t => partsToMarkdown(t.content ?? [], ctx).trim()) + .filter(Boolean) + .join('\n\n'); + return body ? `${heading(level, 'Remarks')}\n\n${body}` : ''; +} + +/** `@see` rendered as a "See" section (one bullet per tag when multiple). */ +export function renderSee(comment, ctx, level) { + const tags = (comment?.blockTags ?? []).filter(t => t.tag === '@see'); + if (tags.length === 0) return ''; + const items = tags.map(t => partsToMarkdown(t.content ?? [], ctx).trim()).filter(Boolean); + if (items.length === 0) return ''; + const body = items.length === 1 ? items[0] : items.map(i => `- ${i}`).join('\n'); + return `${heading(level, 'See')}\n\n${body}`; +} + +/** + * Render grouped @example blocks as a single section. Heading is "Example" + * for one block, "Examples" for several. + * + * @param {number} level heading level (2 or 3) + */ +export function renderExamples(comment, ctx, level) { + if (!comment || !RENDER_OPTIONS.showExamples) return ''; + const examples = (comment.blockTags ?? []) + .filter(t => t.tag === '@example') + .map(t => partsToMarkdown(t.content ?? [], ctx).trim()) + .filter(Boolean); + if (examples.length === 0) return ''; + const title = examples.length > 1 ? 'Examples' : 'Example'; + return `${heading(level, title)}\n\n${examples.join('\n\n')}`; +} + +/** @returns description text (the prose after `@returns`), if any. */ +export function returnsDescription(comment, ctx) { + const tag = (comment?.blockTags ?? []).find(t => t.tag === '@returns'); + return tag ? partsToMarkdown(tag.content ?? [], ctx).trim() : ''; +} + +/** Render `@throws` blocks — each gets its own "Throws" heading. */ +export function renderThrows(comment, ctx, level) { + const tags = (comment?.blockTags ?? []).filter(t => t.tag === '@throws'); + if (tags.length === 0) return ''; + return tags + .map(t => { + const body = partsToMarkdown(t.content ?? [], ctx).trim(); + return body ? `${heading(level, 'Throws')}\n\n${body}` : ''; + }) + .filter(Boolean) + .join('\n\n'); +} diff --git a/docs-gen/render/component.mjs b/docs-gen/render/component.mjs new file mode 100644 index 00000000..70027cc8 --- /dev/null +++ b/docs-gen/render/component.mjs @@ -0,0 +1,19 @@ +// React components. No signature line, no Returns. Layout: +// ## Props (from the single `props` parameter) ## Examples + +import { sectionsToMarkdown } from './markdown.mjs'; +import { renderDescription, renderExamples } from './comment.mjs'; +import { renderPropertiesTable, collectProps } from './members.mjs'; + +export function renderComponentBody(ref, ctx) { + const sig = ref.signatures?.[0]; + const comment = sig?.comment ?? ref.comment; + // The props are the component's first (and only) parameter — it may be named + // `props` or destructured, and typed as a reference or an intersection. + const props = collectProps(sig?.parameters?.[0]?.type); + return sectionsToMarkdown([ + renderDescription(comment, ctx), + renderPropertiesTable(props, ctx, { title: 'Props', level: 2 }), + renderExamples(comment, ctx, 2), + ]); +} diff --git a/docs-gen/render/enum.mjs b/docs-gen/render/enum.mjs new file mode 100644 index 00000000..8c9833b9 --- /dev/null +++ b/docs-gen/render/enum.mjs @@ -0,0 +1,49 @@ +// Category "Types (Enums)": real TS enums and string-literal-union aliases. +// +// real enum -> `## Members` table +// literal-union alias -> `> Name = "a" | "b"` line + `## Type Declaration` +// bulleted list (value + per-member summary). + +import { ReflectionKind } from 'typedoc'; +import { table, escapeTableCell, heading, inlineCode, sectionsToMarkdown } from './markdown.mjs'; +import { renderType } from './type.mjs'; +import { renderDescription, renderSummary, partsToMarkdown } from './comment.mjs'; + +export function renderEnumBody(ref, ctx) { + if (ref.kind === ReflectionKind.Enum) return renderRealEnum(ref, ctx); + return sectionsToMarkdown([ + ref.type ? `> **${ref.name}** = ${renderType(ref.type, ctx)}` : '', + renderDescription(ref.comment, ctx), + renderLiteralUnionList(ref, ctx), + ]); +} + +function renderRealEnum(ref, ctx) { + const members = (ref.children ?? []).filter(c => c.kind === ReflectionKind.EnumMember); + const desc = renderDescription(ref.comment, ctx); + if (members.length === 0) return desc; + const rows = members.map(m => [ + inlineCode(m.name), + renderType(m.type, ctx), + escapeTableCell(renderSummary(m.comment, ctx)), + ]); + const tbl = `${heading(2, 'Members')}\n\n${table(['Member', 'Value', 'Description'], rows)}`; + return sectionsToMarkdown([desc, tbl]); +} + +function renderLiteralUnionList(ref, ctx) { + const t = ref.type; + if (t?.type !== 'union') return ''; + const summaries = t.elementSummaries ?? []; + const lines = []; + t.types.forEach((member, i) => { + if (member.type !== 'literal') return; + const value = member.value; // raw value, unquoted, in a code span + const text = summaries[i] + ? partsToMarkdown(summaries[i], ctx).replace(/\n\n/g, ' ').trim() + : ''; + lines.push(text ? `- ${inlineCode(String(value))} - ${text}` : `- ${inlineCode(String(value))}`); + }); + if (lines.length === 0) return ''; + return `${heading(2, 'Type Declaration')}\n\n${lines.join('\n')}`; +} diff --git a/docs-gen/render/flat-members.mjs b/docs-gen/render/flat-members.mjs new file mode 100644 index 00000000..aef2b740 --- /dev/null +++ b/docs-gen/render/flat-members.mjs @@ -0,0 +1,144 @@ +// Flat ("declarations") member rendering. +// +// typedoc-plugin-markdown renders a container's members as a grouped Properties +// table + Methods section when the reflection has `groups`. When `groups` is +// empty (e.g. interfaces/classes brought in via `export type { … }` re-exports), +// it falls back to rendering each member as its own `## name` section. This +// module reproduces that flat layout. + +import { ReflectionKind } from 'typedoc'; +import { heading, inlineCode, sectionsToMarkdown } from './markdown.mjs'; +import { renderType } from './type.mjs'; +import { renderDescription, renderExamples, partsToMarkdown } from './comment.mjs'; +import { + renderSignature, + renderMemberSignatures, + renderParamsOrProps, + renderReturns, +} from './signature.mjs'; +import { renderInheritedFrom, renderOverrides, propsTable } from './members.mjs'; + +/** Whether typedoc grouped this reflection's members (-> table) or not. */ +export function hasGroups(ref) { + return Array.isArray(ref.groups) && ref.groups.length > 0; +} + +export function renderFlatMembers(ref, ctx) { + const blocks = []; + for (const m of ref.children ?? []) { + if (m.flags?.isPrivate) continue; + const b = renderFlatMember(m, ref, ctx); + if (b) blocks.push(b); + } + return blocks.join('\n\n---\n\n'); +} + +function renderFlatMember(m, owner, ctx) { + switch (m.kind) { + case ReflectionKind.Constructor: { + const sig = m.signatures?.[0]; + if (!sig) return ''; + return `${heading(2, 'Constructor')}\n\n${renderSignature(sig, { + displayName: owner.name, + asNew: true, + headingLevel: 3, + owner: m, + ctx, + })}`; + } + case ReflectionKind.Method: + return `${heading(2, `${m.name}()`)}\n\n${renderMemberSignatures(m, m.name, 3, ctx)}`; + case ReflectionKind.Property: + case ReflectionKind.Variable: + return renderFlatProperty(m, ctx); + case ReflectionKind.Accessor: + return renderFlatAccessor(m, ctx); + default: + return ''; + } +} + +function modifierPrefix(m) { + const mods = []; + if (m.flags?.isProtected) mods.push('`protected`'); + if (m.flags?.isStatic) mods.push('`static`'); + if (m.flags?.isReadonly) mods.push('`readonly`'); + if (m.flags?.isOptional) mods.push('`optional`'); + return mods.length ? `${mods.join(' ')} ` : ''; +} + +function inheritFooter(m, ctx) { + // Flat members are level-2 headings, so their inheritance line is level 3. + return renderInheritedFrom(m, ctx, 3) || renderOverrides(m, ctx, 3); +} + +function renderFlatProperty(m, ctx) { + const isCallable = m.type?.type === 'reflection' && m.type.declaration?.signatures?.length; + const def = m.defaultValue ? ` = ${inlineCode(m.defaultValue)}` : ''; + const declLine = `> ${modifierPrefix(m)}**${m.name}**: ${renderType(m.type, ctx)}${def}`; + const head = `${heading(2, m.name)}\n${declLine}`; + const desc = renderDescription(m.comment, ctx); + const footer = inheritFooter(m, ctx); + + if (isCallable) { + const sigs = m.type.declaration.signatures + .map(sig => renderCallSignature(sig, ctx)) + .join('\n\n'); + return sectionsToMarkdown([head, desc, sigs, footer]); + } + + return sectionsToMarkdown([ + head, + desc, + renderObjectNameTable(m.type, ctx), + renderDefaultValueSection(m.comment, ctx), + renderExamples(m.comment, ctx, 3), + footer, + ]); +} + +// A property typed as an (array of) object literal expands to a "Name" table. +function renderObjectNameTable(type, ctx) { + let decl = null; + if (type?.type === 'reflection') decl = type.declaration; + else if (type?.type === 'array' && type.elementType?.type === 'reflection') + decl = type.elementType.declaration; + if (!decl || decl.signatures?.length) return ''; + const props = (decl.children ?? []).filter(c => c.kind === ReflectionKind.Property); + return propsTable(props, ctx, 'Name'); +} + +function renderDefaultValueSection(comment, ctx) { + const tag = (comment?.blockTags ?? []).find( + t => t.tag === '@default' || t.tag === '@defaultValue' + ); + if (!tag) return ''; + const content = partsToMarkdown(tag.content ?? [], ctx).trim(); + return content ? `${heading(3, 'Default Value')}\n\n${content}` : ''; +} + +// A nameless call signature inside a callable property: `> (p): R`, its own +// description, then level-4 Parameters/Returns. +function renderCallSignature(sig, ctx) { + const params = (sig.parameters ?? []) + .map(p => `${inlineCode(`${p.name}${p.flags?.isOptional ? '?' : ''}`)}: ${renderType(p.type, ctx)}`) + .join(', '); + const line = `> (${params}): ${renderType(sig.type, ctx)}`; + return sectionsToMarkdown([ + heading(3, 'Call Signature'), + line, + renderDescription(sig.comment, ctx), + renderParamsOrProps(sig.parameters ?? [], ctx, 4), + renderReturns(sig, ctx, 4), + ]); +} + +function renderFlatAccessor(m, ctx) { + const get = m.getSignature; + const declLine = `> ${modifierPrefix(m)}**${m.name}**: ${get ? renderType(get.type, ctx) : '`unknown`'}`; + return sectionsToMarkdown([ + `${heading(2, m.name)}\n${declLine}`, + renderDescription((get ?? m).comment, ctx), + inheritFooter(m, ctx), + ]); +} diff --git a/docs-gen/render/function.mjs b/docs-gen/render/function.mjs new file mode 100644 index 00000000..1436219d --- /dev/null +++ b/docs-gen/render/function.mjs @@ -0,0 +1,26 @@ +// Standalone functions (and "Other" utility functions). A single signature +// renders inline under the H1 with level-2 sub-sections; multiple overloads +// each get a "## Call Signature" heading with level-3 sub-sections. + +import { heading } from './markdown.mjs'; +import { renderSignature } from './signature.mjs'; + +export function renderFunctionBody(ref, ctx) { + const sigs = ref.signatures ?? []; + if (sigs.length === 0) return ''; + + if (sigs.length === 1) { + return renderSignature(sigs[0], { displayName: ref.name, headingLevel: 2, ctx }); + } + + return sigs + .map( + sig => + `${heading(2, 'Call Signature')}\n\n${renderSignature(sig, { + displayName: ref.name, + headingLevel: 3, + ctx, + })}` + ) + .join('\n\n'); +} diff --git a/docs-gen/render/hook.mjs b/docs-gen/render/hook.mjs new file mode 100644 index 00000000..61b5af62 --- /dev/null +++ b/docs-gen/render/hook.mjs @@ -0,0 +1,8 @@ +// React hooks render exactly like a single-/multi-signature function: the +// signature line under the H1, description, Returns, then Examples. + +import { renderFunctionBody } from './function.mjs'; + +export function renderHookBody(ref, ctx) { + return renderFunctionBody(ref, ctx); +} diff --git a/docs-gen/render/markdown.mjs b/docs-gen/render/markdown.mjs new file mode 100644 index 00000000..8188420b --- /dev/null +++ b/docs-gen/render/markdown.mjs @@ -0,0 +1,107 @@ +// Markdown primitives. Pure string helpers shared by every renderer. +// +// These intentionally mirror the shapes typedoc-plugin-markdown produced so +// the generated pages render identically on the Docs site (same headings, +// tables, code fences and links). + +import { RENDER_OPTIONS } from '../manifest.mjs'; + +/** Build a YAML frontmatter block from an ordered object. */ +export function frontmatter(obj) { + const lines = ['---']; + for (const [k, v] of Object.entries(obj)) { + if (v === undefined || v === null) continue; + lines.push(`${k}: ${formatYamlValue(k, v)}`); + } + lines.push('---'); + return lines.join('\n'); +} + +function formatYamlValue(key, v) { + if (typeof v !== 'string') return String(v); + // `title` and `description` are always quoted (matches existing output). + if (key === 'title' || key === 'description') { + return `"${v.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + } + return v; +} + +export function heading(level, text) { + return `${'#'.repeat(level)} ${text}`; +} + +export function inlineCode(text) { + if (!text.includes('`')) return `\`${text}\``; + return `\`\` ${text} \`\``; +} + +export function codeBlock(text, lang = '') { + return `\`\`\`${lang}\n${text}\n\`\`\``; +} + +export function link(text, url) { + return `[${text}](${url})`; +} + +export function bulletList(items) { + return items.map(i => `- ${i}`).join('\n'); +} + +/** + * Escape a value for use inside a markdown table cell. Newlines collapse to + * spaces and pipes are escaped so they don't break the column. `<`/`>` are + * left untouched because cell content is pre-rendered markdown (links, code). + */ +export function escapeTableCell(text) { + return String(text ?? '') + .replace(/\s+/g, ' ') // collapse newlines + space runs to a single space + .replace(/\|/g, '\\|') + .trim(); +} + +/** + * Render a GFM table. Columns whose every body cell is blank are dropped + * (when RENDER_OPTIONS.dropEmptyTableColumns is on) — this reproduces + * typedoc-plugin-markdown omitting e.g. an all-empty "Description" column. + * + * @param {string[]} headers + * @param {string[][]} rows + */ +export function table(headers, rows) { + if (rows.length === 0) return ''; + + let cols = headers.map((_, i) => i); + if (RENDER_OPTIONS.dropEmptyTableColumns) { + // Emptiness is judged on the ORIGINAL cells (before the `-` placeholder). + cols = cols.filter(i => rows.some(r => (r[i] ?? '').trim().length > 0)); + // Always keep at least the first column. + if (cols.length === 0) cols = [0]; + } + + const h = cols.map(i => headers[i] ?? ''); + // In a kept column, an empty body cell renders as `-` (matches the previous + // output). All-empty columns were already dropped above. + const body = rows.map(r => + cols.map(i => { + const cell = r[i] ?? ''; + return cell.trim() === '' ? '-' : cell; + }) + ); + const all = [h, ...body]; + const widths = h.map((_, c) => Math.max(...all.map(r => (r[c] ?? '').length))); + + const renderRow = r => + `| ${r.map((cell, i) => (cell ?? '').padEnd(widths[i])).join(' | ')} |`; + const sep = `| ${widths.map(w => '-'.repeat(Math.max(w, 3))).join(' | ')} |`; + return [renderRow(h), sep, ...body.map(renderRow)].join('\n'); +} + +/** Join non-empty markdown chunks with exactly one blank line between them. */ +export function sectionsToMarkdown(parts) { + return parts.filter(p => p && String(p).trim().length > 0).join('\n\n'); +} + +/** URL-name convention used by the Docs site: lowercased member name. */ +export function slugifyName(name) { + return name.toLowerCase(); +} diff --git a/docs-gen/render/members.mjs b/docs-gen/render/members.mjs new file mode 100644 index 00000000..6344678b --- /dev/null +++ b/docs-gen/render/members.mjs @@ -0,0 +1,171 @@ +// Property/member tables shared by class, interface and type-alias renderers. + +import { ReflectionKind } from 'typedoc'; +import { table, escapeTableCell, heading, inlineCode, link } from './markdown.mjs'; +import { renderType } from './type.mjs'; +import { renderSummary, partsToMarkdown } from './comment.mjs'; + +// Block tags appended inline into a member's table-cell description. Only +// @example is surfaced (matches the previous output); its fenced code block +// is inlined to a code span since it lives inside a table cell. +const INLINE_CELL_TAGS = [['@example', 'Example']]; + +/** Description for a member table cell: summary + inline @example. */ +export function renderMemberDescription(comment, ctx) { + if (!comment) return ''; + let out = renderSummary(comment, ctx); + for (const [tag, label] of INLINE_CELL_TAGS) { + for (const t of comment.blockTags ?? []) { + if (t.tag !== tag) continue; + const content = inlineCodeExample(partsToMarkdown(t.content ?? [], ctx)); + out = `${out} **${label}** ${content}`.trim(); + } + } + return out; +} + +// Strip a single fenced code block down to an inline code span. +function inlineCodeExample(text) { + const m = text.trim().match(/^```[^\n]*\n([\s\S]*?)\n?```$/); + const inner = (m ? m[1] : text).trim(); + return inner.includes('`') ? `\`\` ${inner} \`\`` : `\`${inner}\``; +} + +/** + * Render a properties table. + * @param {import('typedoc').DeclarationReflection[]} props + * @param {object} [opts] { level=2, title='Properties' } + */ +export function renderPropertiesTable(props, ctx, opts = {}) { + const { level = 2, title = 'Properties', header = 'Property' } = opts; + const t = propsTable(props, ctx, header); + if (!t) return ''; + return `${heading(level, title)}\n\n${t}`; +} + +/** Just the table (no heading). `header` is the first column label. */ +export function propsTable(props, ctx, header = 'Property') { + if (!props || props.length === 0) return ''; + // Modifiers (readonly/protected/…) are hidden in table mode (the previous + // pipeline set tableColumnSettings.hideModifiers); only the optional `?` is + // kept since it's part of the name. + const rows = props.map(p => { + const opt = p.flags?.isOptional ? '?' : ''; + return [ + inlineCode(`${p.name}${opt}`), + renderType(memberType(p), ctx), + escapeTableCell(renderMemberDescription(memberComment(p), ctx)), + ]; + }); + return table([header, 'Type', 'Description'], rows); +} + +/** + * Collect the property reflections a props-type resolves to, handling a direct + * reference, an anonymous object, or an intersection (whose members are merged + * and sorted) — e.g. a component whose props are `Foo & { ref }`. + */ +export function collectProps(type) { + if (!type) return []; + if (type.type === 'reference' && type.reflection) { + const t = type.reflection; + const decl = t.type?.type === 'reflection' ? t.type.declaration : t; + return (decl?.children ?? []).filter(c => c.kind === ReflectionKind.Property); + } + if (type.type === 'reflection') { + return (type.declaration?.children ?? []).filter(c => c.kind === ReflectionKind.Property); + } + if (type.type === 'intersection') { + const merged = new Map(); + for (const member of type.types) { + for (const p of collectProps(member)) if (!merged.has(p.name)) merged.set(p.name, p); + } + return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name)); + } + return []; +} + +/** A property may carry its type directly or via a get-signature (accessor). */ +function memberType(p) { + if (p.type) return p.type; + if (p.getSignature?.type) return p.getSignature.type; + return p.setSignature?.parameters?.[0]?.type; +} + +function memberComment(p) { + return p.comment ?? p.getSignature?.comment ?? p.signatures?.[0]?.comment; +} + +/** + * Split a container's children into { props, methods, ctors, accessors }, + * skipping private/external members. + */ +export function partitionMembers(ref) { + const props = []; + const methods = []; + const ctors = []; + const accessors = []; + for (const child of ref.children ?? []) { + // Inherited members (e.g. the Error constructor) carry isExternal but are + // still documented; only private members are dropped here (excludePrivate + // already removed them at the TypeDoc level). + if (child.flags?.isPrivate) continue; + switch (child.kind) { + case ReflectionKind.Property: + case ReflectionKind.Variable: + props.push(child); + break; + case ReflectionKind.Accessor: + accessors.push(child); + break; + case ReflectionKind.Method: + methods.push(child); + break; + case ReflectionKind.Constructor: + ctors.push(child); + break; + } + } + return { props, methods, ctors, accessors }; +} + +/** Render the "Inherited from" line for a member, or '' when not inherited. */ +export function renderInheritedFrom(reflection, ctx, level = 4) { + const ref = reflection.inheritedFrom; + if (!ref) return ''; + return inheritanceLine('Inherited from', ref, ctx, level); +} + +/** Render the "Overrides" line for a member, or ''. */ +export function renderOverrides(reflection, ctx, level = 4) { + const ref = reflection.overwrites; + if (!ref) return ''; + return inheritanceLine('Overrides', ref, ctx, level); +} + +function inheritanceLine(label, refType, ctx, level) { + // refType.name is like "MonoCloudAuthBaseError.constructor". + const target = refType.reflection; + const fullName = refType.name ?? ''; + const [className, memberName] = splitOwnerMember(fullName, target); + + let body; + if (target && className && memberName) { + const owner = target.parent; + const ownerUrl = owner && ctx.linkFor ? ctx.linkFor(owner) : null; + const memberUrl = ownerUrl ? `${ownerUrl}#${memberName.toLowerCase()}` : null; + const ownerPart = ownerUrl ? link(inlineCode(className), ownerUrl) : inlineCode(className); + const memberPart = memberUrl ? link(inlineCode(memberName), memberUrl) : inlineCode(memberName); + body = `${ownerPart}.${memberPart}`; + } else { + body = inlineCode(fullName); + } + return `${heading(level, label)}\n\n${body}`; +} + +function splitOwnerMember(fullName, target) { + const dot = fullName.lastIndexOf('.'); + if (dot > 0) return [fullName.slice(0, dot), fullName.slice(dot + 1)]; + if (target?.parent) return [target.parent.name, target.name]; + return [null, null]; +} diff --git a/docs-gen/render/page.mjs b/docs-gen/render/page.mjs new file mode 100644 index 00000000..dd4d0ffb --- /dev/null +++ b/docs-gen/render/page.mjs @@ -0,0 +1,73 @@ +// Top-level page assembler for a single documentable reflection: +// frontmatter + H1 + body. Body placement (declaration line, description, +// sections) is owned by the per-kind renderers. + +import { ReflectionKind } from 'typedoc'; +import { frontmatter, heading, sectionsToMarkdown } from './markdown.mjs'; +import { categoryMeta, OTHER_CATEGORY, RENDER_OPTIONS } from '../manifest.mjs'; +import { renderClassBody } from './class.mjs'; +import { renderTypeBody } from './type-alias.mjs'; +import { renderEnumBody } from './enum.mjs'; +import { renderFunctionBody } from './function.mjs'; +import { renderHookBody } from './hook.mjs'; +import { renderComponentBody } from './component.mjs'; +import { renderDescription } from './comment.mjs'; + +/** + * @param {object} args + * @param {import('typedoc').DeclarationReflection} args.ref + * @param {string|null} args.categoryTag raw @category tag value (null -> Other) + * @param {string} args.rootSdk frontmatter rootSdk label + * @param {string} [args.framework] frontmatter framework label + * @param {string} [args.description] SEO description (already computed) + * @param {import('./type.mjs').RenderContext} args.ctx + */ +export function renderPage({ ref, categoryTag, rootSdk, framework, description, ctx }) { + const meta = categoryMeta(categoryTag); + + const fm = frontmatter({ + rootSdk, + title: ref.name, + category: meta.label, + framework, + description: RENDER_OPTIONS.emitDescription && description ? description : undefined, + }); + + const body = renderBody(ref, meta, ctx); + return sectionsToMarkdown([fm, heading(1, h1Title(ref, meta)), body]) + '\n'; +} + +function h1Title(ref, meta) { + const prefix = RENDER_OPTIONS.titlePrefixOverrides[meta.label] ?? meta.prefix; + if (!prefix) { + return ref.name.includes('/') ? ref.name.split('/').pop() : ref.name; + } + if (prefix === 'Component') return `Component: <${ref.name}>`; + return `${prefix}: ${ref.name}`; +} + +function renderBody(ref, meta, ctx) { + switch (meta.label) { + case 'Classes': + return renderClassBody(ref, ctx); + case 'Error Classes': + return renderClassBody(ref, ctx, { extendedBy: true }); + case 'Components': + return renderComponentBody(ref, ctx); + case 'Hooks': + return renderHookBody(ref, ctx); + case 'Functions': + return renderFunctionBody(ref, ctx); + case 'Enums': + return renderEnumBody(ref, ctx); + case 'Types': + case 'Handler Types': + if (ref.kind === ReflectionKind.Function) return renderFunctionBody(ref, ctx); + return renderTypeBody(ref, ctx); + default: + // Other: utility functions render as a function body; anything else + // falls back to its description. + if (ref.signatures?.length) return renderFunctionBody(ref, ctx); + return renderDescription(ref.comment, ctx); + } +} diff --git a/docs-gen/render/signature.mjs b/docs-gen/render/signature.mjs new file mode 100644 index 00000000..f761a3b1 --- /dev/null +++ b/docs-gen/render/signature.mjs @@ -0,0 +1,209 @@ +// Render function/method/constructor signatures: the `> **name**(…)` line, +// description, Parameters table, Returns, Inherited-from and Examples. + +import { ReflectionKind } from 'typedoc'; +import { RENDER_OPTIONS } from '../manifest.mjs'; +import { table, escapeTableCell, heading, inlineCode, sectionsToMarkdown } from './markdown.mjs'; +import { renderType } from './type.mjs'; +import { + renderDescription, + renderExamples, + returnsDescription, + renderThrows, + renderSummary, + renderRemarks, + renderSee, +} from './comment.mjs'; +import { + renderMemberDescription, + renderInheritedFrom, + renderOverrides, + renderPropertiesTable, +} from './members.mjs'; + +/** + * @param {import('typedoc').SignatureReflection} sig + * @param {object} opts + * @param {string} opts.displayName + * @param {boolean} [opts.asNew] render as `new (…)` (constructors) + * @param {boolean} [opts.static] + * @param {number} [opts.headingLevel=3] level for Parameters/Returns/Examples + * @param {boolean} [opts.showReturns] + * @param {import('typedoc').DeclarationReflection} [opts.owner] member reflection + * carrying inheritedFrom/overwrites (constructors/methods) + * @param {RenderContext} opts.ctx + */ +export function renderSignature(sig, opts) { + const { ctx, headingLevel = 3, owner } = opts; + const showReturns = opts.showReturns ?? RENDER_OPTIONS.showReturns; + + const sigLine = renderSignatureLine(sig, opts); + const desc = renderDescription(sig.comment, ctx); + const typeParams = renderTypeParametersSection(sig.typeParameters, ctx, headingLevel); + const params = RENDER_OPTIONS.showParameters + ? renderParamsOrProps(sig.parameters ?? [], ctx, headingLevel) + : ''; + const returns = showReturns ? renderReturns(sig, ctx, headingLevel) : ''; + const remarks = renderRemarks(sig.comment, ctx, headingLevel); + const examples = renderExamples(sig.comment, ctx, headingLevel); + const see = renderSee(sig.comment, ctx, headingLevel); + const throws = renderThrows(sig.comment, ctx, headingLevel); + const inherited = + RENDER_OPTIONS.showInheritedFrom && owner + ? renderInheritedFrom(owner, ctx, headingLevel) || renderOverrides(owner, ctx, headingLevel) + : ''; + + return sectionsToMarkdown([ + sigLine, + desc, + typeParams, + params, + returns, + remarks, + examples, + see, + throws, + inherited, + ]); +} + +/** Render several signatures (overloads) separated by horizontal rules. */ +export function renderSignatures(signatures, opts) { + if (!signatures || signatures.length === 0) return ''; + return signatures.map(sig => renderSignature(sig, opts)).join('\n\n---\n\n'); +} + +/** + * Render a member's signatures under its `name()` heading. A single signature + * renders inline at `baseLevel`; multiple overloads each get a "Call Signature" + * heading at `baseLevel` with sub-sections at `baseLevel + 1`. + */ +export function renderMemberSignatures(member, displayName, baseLevel, ctx) { + const sigs = member.signatures ?? []; + if (sigs.length === 0) return ''; + const base = { displayName, static: !!member.flags?.isStatic, owner: member, ctx }; + if (sigs.length === 1) { + return renderSignature(sigs[0], { ...base, headingLevel: baseLevel }); + } + return sigs + .map( + sig => + `${heading(baseLevel, 'Call Signature')}\n\n${renderSignature(sig, { + ...base, + headingLevel: baseLevel + 1, + })}` + ) + .join('\n\n'); +} + +// Signature line ------------------------------------------------------------ + +function renderSignatureLine(sig, opts) { + const { displayName, asNew = false, static: isStatic = false, ctx } = opts; + const modifiers = isStatic ? '`static` ' : ''; + const typeParams = renderTypeParametersInline(sig.typeParameters); + const params = renderInlineParameters(sig.parameters ?? [], ctx); + const returnType = renderType(sig.type, ctx); + const namePart = asNew ? `**new ${displayName}**` : `**${displayName}**`; + return `> ${modifiers}${namePart}${typeParams}(${params}): ${returnType}`; +} + +// A parameter with a default value is optional even if `isOptional` is unset. +const paramOptional = p => p.flags?.isOptional || p.defaultValue != null; + +function renderInlineParameters(params, ctx) { + return params + .map(p => { + const opt = paramOptional(p) ? '?' : ''; + const rest = p.flags?.isRest ? '...' : ''; + return `${rest}${inlineCode(`${p.name}${opt}`)}: ${renderType(p.type, ctx)}`; + }) + .join(', '); +} + +// In the signature line, type parameters appear as names only (`\`); +// their constraints/defaults live in the "Type Parameters" section below. +export function renderTypeParametersInline(typeParams) { + if (!typeParams || typeParams.length === 0) return ''; + return `\\<${typeParams.map(tp => inlineCode(tp.name)).join(', ')}\\>`; +} + +export function renderTypeParametersSection(typeParams, ctx, level) { + if (!typeParams || typeParams.length === 0) return ''; + const rows = typeParams.map(tp => { + let cell = inlineCode(tp.name); + if (tp.type) cell += ` _extends_ ${renderType(tp.type, ctx)}`; + // Note: the default type (`= X`) is intentionally not shown — matches the + // previous output. + return [cell, escapeTableCell(renderSummary(tp.comment, ctx))]; + }); + // The Description column is dropped automatically when no type param is + // documented (drop-empty-columns). + return `${heading(level, 'Type Parameters')}\n\n${table(['Type Parameter', 'Description'], rows)}`; +} + +// A single `props` parameter that references a documented type renders as a +// "Props" section (the props type's properties); everything else is a normal +// "Parameters" table. Anonymous object-literal `props` stay as Parameters. +export function renderParamsOrProps(params, ctx, level) { + if (params.length === 1 && params[0].name === 'props') { + const decl = resolveReferencedProps(params[0].type); + if (decl) { + const props = (decl.children ?? []).filter(c => c.kind === ReflectionKind.Property); + if (props.length) return renderPropertiesTable(props, ctx, { title: 'Props', level }); + } + } + return renderParametersTable(params, ctx, level); +} + +function resolveReferencedProps(type) { + if (type?.type !== 'reference' || !type.reflection) return null; + const t = type.reflection; + if (t.type?.type === 'reflection') return t.type.declaration; // alias of object + return t; // interface +} + +// Parameters table (with anonymous object-literal expansion) ----------------- + +export function renderParametersTable(params, ctx, level) { + if (params.length === 0) return ''; + const rows = []; + for (const p of params) { + // In the table (unlike the signature line) a default value alone does NOT + // add `?` — only an explicitly optional parameter does. + const opt = p.flags?.isOptional ? '?' : ''; + const rest = p.flags?.isRest ? '...' : ''; + rows.push([ + inlineCode(`${rest}${p.name}${opt}`), + renderType(p.type, ctx), + escapeTableCell(renderMemberDescription(p.comment, ctx)), + ]); + expandObjectParam(`${p.name}`, p.type, ctx, rows); + } + return `${heading(level, 'Parameters')}\n\n${table(['Parameter', 'Type', 'Description'], rows)}`; +} + +function expandObjectParam(prefix, type, ctx, rows, depth = 0) { + if (depth > 3) return; + const decl = type?.type === 'reflection' ? type.declaration : null; + if (!decl || decl.signatures?.length) return; // skip callables + for (const child of decl.children ?? []) { + if (child.kind !== ReflectionKind.Property) continue; + const opt = child.flags?.isOptional ? '?' : ''; + rows.push([ + inlineCode(`${prefix}.${child.name}${opt}`), + renderType(child.type, ctx), + escapeTableCell(renderMemberDescription(child.comment, ctx)), + ]); + expandObjectParam(`${prefix}.${child.name}`, child.type, ctx, rows, depth + 1); + } +} + +// Returns ------------------------------------------------------------------- + +export function renderReturns(sig, ctx, level) { + if (!sig.type) return ''; + const typeStr = renderType(sig.type, ctx); + const desc = returnsDescription(sig.comment, ctx); + return sectionsToMarkdown([heading(level, 'Returns'), typeStr, desc]); +} diff --git a/docs-gen/render/type-alias.mjs b/docs-gen/render/type-alias.mjs new file mode 100644 index 00000000..94c548b2 --- /dev/null +++ b/docs-gen/render/type-alias.mjs @@ -0,0 +1,127 @@ +// Interface and type-alias bodies (categories: Types, Handler Types). +// +// Dispatch is by reflection kind, not category: +// Interface -> Extends / Properties / Methods / Indexable +// TypeAlias -> `> Name = ` line, then type-dependent sections: +// function type -> Parameters + Returns (handler types) +// object literal -> Type Declaration table +// plus trailing Examples. + +import { ReflectionKind } from 'typedoc'; +import { heading, sectionsToMarkdown, bulletList, inlineCode } from './markdown.mjs'; +import { renderType } from './type.mjs'; +import { renderDescription, renderExamples, partsToMarkdown } from './comment.mjs'; +import { renderPropertiesTable, propsTable, partitionMembers, collectProps } from './members.mjs'; +import { hasGroups, renderFlatMembers } from './flat-members.mjs'; +import { + renderMemberSignatures, + renderParamsOrProps, + renderReturns, + renderTypeParametersSection, + renderTypeParametersInline, +} from './signature.mjs'; + +export function renderTypeBody(ref, ctx) { + if (ref.kind === ReflectionKind.Interface) return renderInterfaceBody(ref, ctx); + return renderAliasBody(ref, ctx); +} + +// Interfaces ---------------------------------------------------------------- + +function renderInterfaceBody(ref, ctx) { + const top = [ + renderDescription(ref.comment, ctx), + renderHeritage('Extends', ref.extendedTypes, ctx), + renderTypeParametersSection(ref.typeParameters, ctx, 2), + renderIndexable(ref, ctx), + ]; + + // No groups -> flat `## member` sections (typically `export type` re-exports). + if (!hasGroups(ref)) { + return sectionsToMarkdown([...top, renderFlatMembers(ref, ctx)]); + } + + const { props, methods } = partitionMembers(ref); + return sectionsToMarkdown([ + ...top, + renderPropertiesTable(props, ctx), + renderInterfaceMethods(methods, ctx), + ]); +} + +// Multiple heritage types -> single bullet joined by `.` (see class.mjs). +function renderHeritage(label, types, ctx) { + if (!types || types.length === 0) return ''; + return `${heading(2, label)}\n\n- ${types.map(t => renderType(t, ctx)).join('.')}`; +} + +function renderInterfaceMethods(methods, ctx) { + if (!methods || methods.length === 0) return ''; + const blocks = methods.map( + m => `${heading(3, `${m.name}()`)}\n\n${renderMemberSignatures(m, m.name, 4, ctx)}` + ); + return `${heading(2, 'Methods')}\n\n${blocks.join('\n\n---\n\n')}`; +} + +function renderIndexable(ref, ctx) { + const idx = ref.indexSignatures?.[0]; + if (!idx) return ''; + const key = idx.parameters?.[0]; + const line = `> \\[${inlineCode(key?.name ?? 'key')}: ${renderType(key?.type, ctx)}\\]: ${renderType(idx.type, ctx)}`; + const summary = renderDescription(idx.comment, ctx); + return sectionsToMarkdown([heading(2, 'Indexable'), line, summary]); +} + +// Type aliases -------------------------------------------------------------- + +function renderAliasBody(ref, ctx) { + const t = ref.type; + const aliasLine = t + ? `> **${ref.name}**${renderTypeParametersInline(ref.typeParameters)} = ${renderType(t, ctx)}` + : ''; + const desc = renderDescription(ref.comment, ctx); + + const sections = [aliasLine, desc, renderTypeParametersSection(ref.typeParameters, ctx, 2)]; + + if (t?.type === 'reflection' && t.declaration?.signatures?.length) { + // Function-typed alias (handler types): expand the signature. + const sig = t.declaration.signatures[0]; + sections.push(renderParamsOrProps(sig.parameters ?? [], ctx, 2)); + sections.push(renderReturns(sig, ctx, 2)); + } else if (t?.type === 'union') { + sections.push(renderUnionDeclaration(t, ctx)); + } else { + // Object-literal or intersection alias -> a "Type Declaration" table of + // the resolved properties (header "Name"). + const props = collectProps(t); + if (props.length) { + sections.push(`${heading(2, 'Type Declaration')}\n\n${propsTable(props, ctx, 'Name')}`); + } + } + + sections.push(renderExamples(ref.comment, ctx, 2)); + return sectionsToMarkdown(sections); +} + +// Union type alias -> "## Type Declaration" with one `> member` block per +// union member, its per-member summary, and (for object-literal members) a +// properties table headed "Name". +function renderUnionDeclaration(union, ctx) { + const summaries = union.elementSummaries ?? []; + // The union-members section only appears when members are individually + // documented (per-member summaries). A plain `A | B` union shows just the + // alias line. + if (!summaries.some(s => s && s.length > 0)) return ''; + const blocks = union.types.map((member, i) => { + const parts = [`> ${renderType(member, ctx)}`]; + const summary = summaries[i] ? partsToMarkdown(summaries[i], ctx).replace(/\n\n/g, ' ').trim() : ''; + if (summary) parts.push(summary); + if (member.type === 'reflection') { + const props = (member.declaration?.children ?? []).filter(c => c.kind === ReflectionKind.Property); + const tbl = propsTable(props, ctx, 'Name'); + if (tbl) parts.push(tbl); + } + return parts.join('\n\n'); + }); + return `${heading(2, 'Type Declaration')}\n\n${blocks.join('\n\n')}`; +} diff --git a/docs-gen/render/type.mjs b/docs-gen/render/type.mjs new file mode 100644 index 00000000..596c0a35 --- /dev/null +++ b/docs-gen/render/type.mjs @@ -0,0 +1,165 @@ +// Render a TypeDoc Type AST node to a markdown fragment. +// +// Convention (mirrors typedoc-plugin-markdown): every leaf token (intrinsic +// name, literal, reference name) is wrapped in a code span, and structural +// punctuation markdown would otherwise eat (`<`, `>`, `|`) is backslash- +// escaped, so the result is safe in prose, tables, or quote blocks. + +import { inlineCode, link } from './markdown.mjs'; + +const LT = '\\<'; +const GT = '\\>'; +const OR = '\\|'; + +/** + * @typedef {object} RenderContext + * @property {(reflection: import('typedoc').Reflection) => string | null} linkFor + */ + +export function renderType(type, ctx) { + if (!type) return inlineCode('unknown'); + const fn = HANDLERS[type.type]; + if (!fn) return inlineCode(safeToString(type)); + return fn(type, ctx); +} + +const HANDLERS = { + intrinsic: t => inlineCode(t.name), + + literal: t => { + if (typeof t.value === 'string') return inlineCode(`"${t.value}"`); + if (t.value === null) return inlineCode('null'); + if (typeof t.value === 'object' && t.value && 'value' in t.value) + return inlineCode(`${t.value.negative ? '-' : ''}${t.value.value}n`); + return inlineCode(String(t.value)); + }, + + reference: (t, ctx) => { + const url = t.reflection ? ctx.linkFor(t.reflection) : null; + const head = url ? link(inlineCode(t.name), url) : inlineCode(t.name); + const args = t.typeArguments?.length + ? `${LT}${t.typeArguments.map(a => renderType(a, ctx)).join(', ')}${GT}` + : ''; + return `${head}${args}`; + }, + + array: (t, ctx) => `${wrapForArray(renderType(t.elementType, ctx))}[]`, + + tuple: (t, ctx) => + `[${(t.elements ?? []).map(e => renderType(e, ctx)).join(', ')}]`, + + namedTupleMember: (t, ctx) => + `${t.name}${t.isOptional ? '?' : ''}: ${renderType(t.element, ctx)}`, + + union: (t, ctx) => + t.types + .map(x => { + const r = renderType(x, ctx); + // Parenthesize function types inside a union: `A \| ((x) => B)`. + if (x.type === 'reflection' && x.declaration?.signatures?.length === 1) { + return `(${r})`; + } + return r; + }) + .join(` ${OR} `), + + intersection: (t, ctx) => t.types.map(x => renderType(x, ctx)).join(' & '), + + conditional: (t, ctx) => + `${renderType(t.checkType, ctx)} extends ${renderType(t.extendsType, ctx)} ? ${renderType(t.trueType, ctx)} : ${renderType(t.falseType, ctx)}`, + + indexedAccess: (t, ctx) => + `${renderType(t.objectType, ctx)}[${renderType(t.indexType, ctx)}]`, + + inferred: t => `infer ${inlineCode(t.name)}`, + + predicate: (t, ctx) => { + const prefix = t.asserts ? 'asserts ' : ''; + const target = t.targetType ? ` is ${renderType(t.targetType, ctx)}` : ''; + return `${prefix}${inlineCode(t.name)}${target}`; + }, + + query: (t, ctx) => `typeof ${renderType(t.queryType, ctx)}`, + + rest: (t, ctx) => `...${renderType(t.elementType, ctx)}`, + + optional: (t, ctx) => `${renderType(t.elementType, ctx)}?`, + + templateLiteral: (t, ctx) => { + const parts = [t.head]; + for (const [innerType, suffix] of t.tail ?? []) { + parts.push('${', renderType(innerType, ctx), '}', suffix); + } + return inlineCode(`\`${parts.join('')}\``); + }, + + typeOperator: (t, ctx) => `${t.operator} ${renderType(t.target, ctx)}`, + + mapped: (t, ctx) => { + const param = t.parameter; + const paramType = renderType(t.parameterType, ctx); + return `\\{ [${inlineCode(param)} in ${paramType}]: ${renderType(t.templateType, ctx)} \\}`; + }, + + reflection: (t, ctx) => renderReflectionType(t, ctx), + + unknown: t => inlineCode(t.name ?? 'unknown'), +}; + +// Inline anonymous types ---------------------------------------------------- + +function renderReflectionType(t, ctx) { + const decl = t.declaration; + if (!decl) return inlineCode('object'); + + if (decl.signatures?.length) { + // Single call signature -> arrow form `(p) => R`. Multiple (overloaded + // callable) -> brace/colon form `\{(p): R; (p): R; \}`. + if (decl.signatures.length === 1) { + return renderCallableInline(decl.signatures[0], ctx, '=>'); + } + return `\\{${decl.signatures.map(s => renderCallableInline(s, ctx, ':')).join('; ')}; \\}`; + } + + const props = decl.children ?? []; + const indexSig = decl.indexSignatures?.[0]; + const parts = []; + if (indexSig) { + const key = indexSig.parameters?.[0]; + parts.push( + `\\[${inlineCode(key?.name ?? 'key')}: ${renderType(key?.type, ctx)}\\]: ${renderType(indexSig.type, ctx)}` + ); + } + for (const p of props) { + const opt = p.flags?.isOptional ? '?' : ''; + parts.push(`${inlineCode(p.name + opt)}: ${renderType(p.type, ctx)}`); + } + if (parts.length === 0) return inlineCode('{}'); + return `\\{ ${parts.join('; ')}; \\}`; +} + +function renderCallableInline(sig, ctx, sep) { + const params = (sig.parameters ?? []) + .map(p => { + const opt = p.flags?.isOptional ? '?' : ''; + return `${inlineCode(p.name + opt)}: ${renderType(p.type, ctx)}`; + }) + .join(', '); + const ret = renderType(sig.type, ctx); + return sep === '=>' ? `(${params}) => ${ret}` : `(${params}): ${ret}`; +} + +// Helpers ------------------------------------------------------------------- + +function wrapForArray(rendered) { + if (rendered.includes(OR) || rendered.includes(' & ')) return `(${rendered})`; + return rendered; +} + +function safeToString(t) { + try { + return t.toString?.() ?? `${t.type ?? 'unknown'}`; + } catch { + return String(t.type ?? 'unknown'); + } +} diff --git a/docs-gen/typedoc.markdown.mjs b/docs-gen/typedoc.markdown.mjs index 318b97af..ce427a41 100644 --- a/docs-gen/typedoc.markdown.mjs +++ b/docs-gen/typedoc.markdown.mjs @@ -1,34 +1,22 @@ -/** @type {import("typedoc-plugin-markdown").PluginOptions} */ -const typedocPluginMarkdownOptions = { - hideBreadcrumbs: true, - hidePageHeader: true, - parametersFormat: 'table', - hidePageTitle: true, - interfacePropertiesFormat: 'table', - classPropertiesFormat: 'table', - enumMembersFormat: 'table', - propertyMembersFormat: 'table', - typeDeclarationFormat: 'table', - typeDeclarationVisibility: 'compact', - typeAliasPropertiesFormat: 'table', - useHTMLAnchors: false, - tableColumnSettings: { - hideSources: true, - hideModifiers: true, - hideDefaults: true, - hideInherited: true, - hideOverrides: true, - }, - excludeScopesInPaths: true, - expandObjects: true, - formatWithPrettier: true, - expandParameters: true, -}; +// TypeDoc config driving the controlled markdown emitter (emitter.mjs). +// +// The markdown docs are produced entirely by our own emitter + render/* layer +// (see manifest.mjs for the knobs). typedoc-plugin-markdown is no longer used. +// Reflection options below mirror what the old pipeline used so the converted +// project is identical; only the rendering (output) layer is ours. /** @type {import("typedoc").TypeDocOptions} */ const config = { - plugin: ['typedoc-plugin-markdown', './hook.mjs'], - router: 'category', + plugin: ['./emitter.mjs'], + entryPoints: [ + '../packages/core', + '../packages/web-js', + '../packages/react', + '../packages/node-core', + '../packages/nextjs', + '../packages/node-backend', + ], + entryPointStrategy: 'packages', packageOptions: { gitRevision: 'main', includeVersion: false, @@ -41,14 +29,6 @@ const config = { disableSources: true, sort: 'alphabetical', }, - entryPoints: [ - '../packages/core', - '../packages/web-js', - '../packages/react', - '../packages/node-core', - '../packages/nextjs', - '../packages/node-backend', - ], exclude: [ '**/dist/**', '**/node_modules/**', @@ -57,27 +37,11 @@ const config = { '**/examples/**', '**/packages/test-utils', ], - visibilityFilters: [], - includeHierarchySummary: false, - headings: true, - sortEntryPoints: false, - entryPointStrategy: 'packages', - out: '../docs/markdown', + outputs: [{ name: 'monocloud-markdown', path: '../docs/markdown' }], name: 'MonoCloud Authentication SDK', readme: '../README.md', hideGenerator: true, - disableSources: false, - categorizeByGroup: false, - navigation: { - includeCategories: true, - }, - theme: 'default', - validation: { - notExported: true, - invalidLink: true, - notDocumented: false, - }, - ...typedocPluginMarkdownOptions, + validation: { notExported: false, invalidLink: false, notDocumented: false }, }; export default config; diff --git a/package.json b/package.json index 2ecac394..b8861965 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,7 @@ "preinstall": "npx only-allow pnpm", "build": "turbo run build", "gen:docs:html": "rimraf docs/html && typedoc --options ./docs-gen/typedoc.html.mjs", - "gen:docs:post-markdown": "node ./docs-gen/post-generate.mjs", - "gen:docs:markdown": "rimraf docs/markdown && typedoc --options ./docs-gen/typedoc.markdown.mjs && pnpm run gen:docs:post-markdown", + "gen:docs:markdown": "rimraf docs/markdown && typedoc --options ./docs-gen/typedoc.markdown.mjs", "gen:docs": "pnpm gen:docs:html && pnpm gen:docs:markdown", "lint": "turbo run lint:ts && turbo run lint:es -- --fix", "test": "turbo run test",