diff --git a/docs/angular/src/content/en/components/list.mdx b/docs/angular/src/content/en/components/list.mdx index bbc09c7109..6c51853f19 100644 --- a/docs/angular/src/content/en/components/list.mdx +++ b/docs/angular/src/content/en/components/list.mdx @@ -300,7 +300,7 @@ toggleFavorite(item: IgxListItem) { } ``` -Let's also allow the user to choose the size of the list by using the `--ig-size` CSS custom property. We will do this by importing the `IgxButtonGroupModule` and using the [**IgxButtonGroup**](/components/inputs/button-group) to display all size values. This way whenever one gets selected, we will update the **size** of the list. +Let's also allow the user to choose the size of the list by using the `--ig-size` CSS custom property. We will do this by importing the `IgxButtonGroupModule` and using the [**IgxButtonGroup**](/inputs/button-group) to display all size values. This way whenever one gets selected, we will update the **size** of the list. ```typescript // app.module.ts diff --git a/docs/angular/src/content/en/components/themes/roundness.mdx b/docs/angular/src/content/en/components/themes/roundness.mdx index 36eb3f72e4..385c3ca003 100644 --- a/docs/angular/src/content/en/components/themes/roundness.mdx +++ b/docs/angular/src/content/en/components/themes/roundness.mdx @@ -20,7 +20,7 @@ Many Ignite UI components have predefined minimum and maximum border-radius valu When you set `--ig-radius-factor` to 0, the component uses its minimum border-radius and will appear more block-like with sharp corners. When set to 1, the component uses its maximum predefined border-radius and will appear rounded. Here is a list of the components that have predefined minimum and maximum border-radius values and can be modified using the `--ig-radius-factor` variable:
-• [Action Strip](/action-strip) • [Button](/button) • [Button Group](/components/inputs/button-group) • [Calendar](/calendar) • [Card](/card) • [Carousel](/carousel) • [Checkbox](/checkbox) • [Chip](/chip) • [Combo](/combo) • [Date Picker](/date-picker) • [Date Range Picker](/date-range-picker) • [Grid](/grid/grid) • [Input Group](/input-group) • [Linear Progress](/linear-progress) • [List](/list) • [Month Picker](/month-picker) • [Navigation Drawer](/navdrawer) • [Radio](/radio-button) • [Ripple](/ripple) • [Snackbar](/snackbar) • [Switch](/switch) • [Toast](/toast) +• [Action Strip](/action-strip) • [Button](/button) • [Button Group](/inputs/button-group) • [Calendar](/calendar) • [Card](/card) • [Carousel](/carousel) • [Checkbox](/checkbox) • [Chip](/chip) • [Combo](/combo) • [Date Picker](/date-picker) • [Date Range Picker](/date-range-picker) • [Grid](/grid/grid) • [Input Group](/input-group) • [Linear Progress](/linear-progress) • [List](/list) • [Month Picker](/month-picker) • [Navigation Drawer](/navdrawer) • [Radio](/radio-button) • [Ripple](/ripple) • [Snackbar](/snackbar) • [Switch](/switch) • [Toast](/toast) ## Usage diff --git a/docs/xplat/src/content/en/toc.json b/docs/xplat/src/content/en/toc.json index b2839ecc7a..a584fd8a52 100644 --- a/docs/xplat/src/content/en/toc.json +++ b/docs/xplat/src/content/en/toc.json @@ -668,7 +668,7 @@ "Angular" ], "name": "Theming", - "href": "grids/theming-grid.mdx", + "href": "grids/grid/theming-grid.mdx", "premium": true }, { diff --git a/scripts/check-relative-links.mjs b/scripts/check-relative-links.mjs index a2ca373826..de2f48ea02 100644 --- a/scripts/check-relative-links.mjs +++ b/scripts/check-relative-links.mjs @@ -9,6 +9,11 @@ * whether the target exists (trying .mdx, .md, and bare extensions). * JSX-style href attributes with relative paths are also checked. * + * The hrefs in each toc.json are checked in the same pass. They are file paths + * rather than URLs, but they break the same way a link does — and buildSidebar() + * drops an entry whose target is missing instead of failing, so a stale one + * disappears from the sidebar with no error. + * * When --platform=angular the script scans docs/angular/src/content. * When --platform=react|wc|blazor it scans docs/xplat/src/content. * Omitting --platform scans both trees in one pass. @@ -25,7 +30,7 @@ * Exit code: 0 = all OK, 1 = broken links found. */ -import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'; import { dirname, join, relative, resolve } from 'node:path'; // join used in walkMdx // CLI args @@ -189,6 +194,40 @@ const MD_LINK_RE = /\[(?:[^\]\\]|\\.)*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g; */ const JSX_HREF_RE = /href=["'](\.[^"'\s>]+)["']/g; +/** + * True when `path` is an existing *file*. + * + * Every doc page is a file, so a bare directory must never satisfy a link on + * its own: a group folder such as components/inputs/ exists on disk but + * publishes no page, and /inputs 404s. + */ +function isFile(path) { + try { + return statSync(path).isFile(); + } catch { + return false; + } +} + +/** + * Every on-disk file that can publish at the URL `base` maps to. + * + * Astro derives the id from the file path and strips a trailing `/index` + * (see getContentEntryIdAndSlug in astro/dist/content/utils.js), so + * components/themes/sass/index.mdx publishes at /themes/sass — a directory + * therefore does satisfy a link, but only when it holds an index page. + * `base` itself is included for links that already carry an extension. + */ +function pageCandidates(base) { + return [ + base, + base + '.mdx', + base + '.md', + join(base, 'index.mdx'), + join(base, 'index.md'), + ]; +} + /** Strip hash fragment (#anchor) from a URL path. */ function stripHash(url) { const i = url.indexOf('#'); @@ -205,6 +244,17 @@ const ASSET_EXTENSIONS = new Set([ '.html', '.htm', ]); +/** + * Asset extensions that disqualify an *absolute* link from being a doc link. + * + * .html/.htm are deliberately excluded from this set: legacy docfx URLs were + * written as /themes/palettes.html, and treating them as assets made them + * invisible to this check instead of reporting them as broken. + */ +const ABSOLUTE_LINK_ASSET_EXTENSIONS = new Set( + [...ASSET_EXTENSIONS].filter(ext => ext !== '.html' && ext !== '.htm') +); + /** * True when a URL is an absolute doc-internal link, e.g. /treegrid/tree-grid. * Rejects external URLs, anchors-only, protocol-relative, and asset extensions. @@ -218,7 +268,7 @@ function isAbsoluteDocLink(url) { const slash = path.lastIndexOf('/'); if (dot > slash) { const ext = path.slice(dot).toLowerCase(); - if (ASSET_EXTENSIONS.has(ext)) return false; + if (ABSOLUTE_LINK_ASSET_EXTENSIONS.has(ext)) return false; } return true; } @@ -234,27 +284,94 @@ function getLangRoot(filePath) { } /** - * Resolves an absolute doc link like /treegrid/tree-grid against the - * language content root. Tries components/{path}.mdx then {path}.mdx. - * Returns the resolved path string or null if not found. + * Paths that docs/angular/scripts/sync-generated.mjs refuses to copy into the + * Angular tree: grids/ and changelog/, because Angular ships its own versions. + * Pages under them exist in docs/xplat/generated/Angular but never reach the + * Angular site, so they must not satisfy an Angular link. + * + * Keep in step with shouldCopy() in docs/angular/scripts/sync-generated.mjs. + */ +const NOT_SYNCED_TO_ANGULAR_RE = /(^|\/)(grids|changelog)\//i; + +/** True when `relPath` (relative to components/) is synced into the Angular tree. */ +function isSyncedToAngular(relPath) { + return !NOT_SYNCED_TO_ANGULAR_RE.test(relPath); +} + +/** + * Content roots that feed the same published URL space as `langRoot`. + * + * New topics — Angular-only ones included — are authored under docs/xplat and + * copied into the Angular tree at build time by + * docs/angular/scripts/sync-generated.mjs. A link in docs/angular/src/content + * may therefore legitimately target a page that exists only under + * docs/xplat/generated/Angular//, so resolution has to consider both + * roots. Without the second root, every link to an xplat-authored Angular topic + * reports a false "not found" whenever the sync step has not run. + * + * Returns an ordered list of { dir, accepts } (own tree first). `accepts` takes + * a path relative to components/ and reports whether that root can serve it, so + * a root only satisfies links to pages it actually publishes. + */ +const docRootsCache = new Map(); + +function getDocRoots(langRoot) { + const cached = docRootsCache.get(langRoot); + if (cached) return cached; + + const roots = [{ dir: langRoot, accepts: () => true }]; + const m = langRoot.replace(/\\/g, '/').match(/docs\/angular\/src\/content\/(en|jp|kr)\/$/i); + if (m) { + const generated = resolve(process.cwd(), 'docs/xplat/generated/Angular', m[1]) + '/'; + if (existsSync(generated)) roots.push({ dir: generated, accepts: isSyncedToAngular }); + } + + docRootsCache.set(langRoot, roots); + return roots; +} + +/** Legacy docfx extension on a doc URL, e.g. /themes/palettes.html */ +const LEGACY_HTML_RE = /\.html?$/i; + +/** + * Resolves an absolute doc link like /treegrid/tree-grid against every content + * root feeding this URL space. Returns { resolved, reason }; resolved is null + * when the target does not exist. */ function resolveAbsoluteLink(langRoot, url) { - const path = stripHash(url).slice(1); // strip leading '/' - if (!path) return 'hash-only'; + let path = stripHash(url).slice(1); // strip leading '/' + if (!path) return { resolved: 'hash-only', reason: null }; + // Astro lowercases all URL slugs at build time. Always resolve using the // lowercased path so that camelCase links like /pivotGrid/... are flagged // as broken (the built URL is /pivotgrid/...). - const pathLower = path.toLowerCase(); - const candidates = [ - resolve(langRoot, 'components', pathLower), - resolve(langRoot, pathLower), - ]; - for (const base of candidates) { - if (existsSync(base)) return base; - if (existsSync(base + '.mdx')) return base + '.mdx'; - if (existsSync(base + '.md')) return base + '.md'; + path = path.toLowerCase(); + + // The docs collection is rooted at content//components (see + // docs/*/src/content.config.ts), so "components" is never a URL segment. + // A link starting with it is the on-disk file path written as a URL: it + // resolves to a real file yet 404s in the browser, which is what let + // https://github.com/IgniteUI/igniteui-documentation/issues/530 through. + if (path === 'components' || path.startsWith('components/')) { + return { resolved: null, reason: 'components-prefix' }; } - return null; + + // Legacy docfx URLs carried a .html suffix; Astro routes do not. + const legacyHtml = LEGACY_HTML_RE.test(path); + if (legacyHtml) path = path.replace(LEGACY_HTML_RE, ''); + + for (const { dir, accepts } of getDocRoots(langRoot)) { + if (!accepts(path)) continue; + + const base = resolve(dir, 'components', path); + for (const candidate of pageCandidates(base)) { + if (isFile(candidate)) { + return { resolved: candidate, reason: legacyHtml ? 'legacy-html' : null }; + } + } + } + + return { resolved: null, reason: legacyHtml ? 'legacy-html' : 'not-found' }; } /** @@ -336,27 +453,58 @@ function extractRelativeLinks(content, filePath) { // File resolution +/** + * Maps `abs` — a path inside the components dir of `langRoot` — onto the + * equivalent path under each additional content root feeding the same URL + * space. Returns [] when langRoot is unknown, has no extra roots, or the + * target sits outside the components dir (images and other assets). + */ +function siblingRootPaths(langRoot, abs) { + if (!langRoot) return []; + + const roots = getDocRoots(langRoot); + if (roots.length < 2) return []; + + const rel = relative(resolve(langRoot, 'components'), abs).replace(/\\/g, '/'); + if (!rel || rel.startsWith('../')) return []; + + return roots + .slice(1) + .filter(({ accepts }) => accepts(rel)) + .map(({ dir }) => resolve(dir, 'components', rel)); +} + /** * Returns { resolved, missingExt } for `href` relative to `fileDir`. - * resolved = null means the target does not exist on disk. + * resolved = null means the target does not exist in any content root. * missingExt = true means the path has no extension but resolves via .mdx — * the link should be written as ./page.mdx, not ./page. + * + * Candidates cover the tree the file lives in first, then the equivalent path + * under the other roots feeding this URL space, so a relative link to an + * xplat-authored Angular topic resolves before the sync step has run. */ -function resolveLink(fileDir, href) { +function resolveLink(fileDir, href, langRoot) { const path = stripHash(href); if (!path) return { resolved: 'hash-only', missingExt: false }; const abs = resolve(fileDir, path); + const candidates = [abs, ...siblingRootPaths(langRoot, abs)]; + + for (const candidate of candidates) { + if (isFile(candidate)) return { resolved: candidate, missingExt: false }; + } - if (existsSync(abs)) return { resolved: abs, missingExt: false }; + for (const candidate of candidates) { + const target = pageCandidates(candidate).find(isFile); + if (!target) continue; - if (existsSync(abs + '.mdx')) { const lastDot = path.lastIndexOf('.'); const lastSlash = path.lastIndexOf('/'); const hasExt = lastDot > lastSlash; const isBare = !path.startsWith('./') && !path.startsWith('../'); const missingExt = !hasExt || isBare; - return { resolved: abs + '.mdx', missingExt, bare: isBare }; + return { resolved: target, missingExt, bare: isBare }; } return { resolved: null, missingExt: false }; @@ -379,6 +527,23 @@ console.log(`Scope: ${scanDescription}`); if (PLATFORM) console.log(`Platform: ${PLATFORM}`); console.log(''); +// The Angular URL space is fed by two trees: docs/angular/src/content plus the +// xplat-authored Angular pages under docs/xplat/generated/Angular, which the +// build copies in via docs/angular/scripts/sync-generated.mjs. New topics are +// authored in xplat, so without that tree present links to them would report a +// false "not found". +if (PLATFORM === 'angular' || (!PLATFORM && !args.src)) { + for (const lang of ['en', 'jp']) { + if (existsSync(resolve(cwd, 'docs/xplat/generated/Angular', lang))) continue; + + const script = lang === 'en' ? 'generate:angular' : 'generate:angular:jp'; + console.log(` ! docs/xplat/generated/Angular/${lang} is missing — links to xplat-authored`); + console.log(` Angular topics may report a false "not found". Run:`); + console.log(` npm run ${script} --prefix docs/xplat`); + console.log(''); + } +} + /** @type {Array<{file: string, line: number, href: string}>} */ const brokenLinks = []; let totalFiles = 0; @@ -397,14 +562,16 @@ for (const file of filesToScan) { for (const { href, line, kind } of links) { if (kind === 'absolute') { if (langRoot) { - const resolved = resolveAbsoluteLink(langRoot, href); - if (resolved === null) { - brokenLinks.push({ file: relFile, line, href, reason: 'not-found' }); + const { resolved, reason } = resolveAbsoluteLink(langRoot, href); + // A legacy .html suffix 404s on the Astro routes even when the + // target page itself exists, so report it either way. + if (resolved === null || reason === 'legacy-html') { + brokenLinks.push({ file: relFile, line, href, reason }); } } continue; } - const { resolved, missingExt, bare } = resolveLink(fileDir, href); + const { resolved, missingExt, bare } = resolveLink(fileDir, href, langRoot); if (resolved === null) { brokenLinks.push({ file: relFile, line, href, reason: 'not-found' }); } else if (bare) { @@ -421,13 +588,156 @@ for (const file of filesToScan) { const href = m[1]; const hash = m[2] ?? ''; const abs = resolve(fileDir, href); - if (existsSync(abs + '.mdx')) { + const resolvesToPage = [abs, ...siblingRootPaths(langRoot, abs)] + .some(candidate => existsSync(candidate + '.mdx')); + if (resolvesToPage) { const line = (content.slice(0, m.index).match(/\n/g) || []).length + 1; brokenLinks.push({ file: relFile, line, href: href + hash, reason: 'bare-path' }); } } } +// toc.json navigation + +/** + * toc.json hrefs are the sidebar's only source of navigation, and they are file + * paths relative to the components dir carrying the .mdx extension — not URLs. + * + * They need checking for the same reason the links above do, and are easy to get + * wrong in the same way: a topic move changes both, and buildSidebar() drops an + * entry whose target is missing (docExists() → null in src/sidebar.ts) rather + * than failing, so a stale href disappears from the sidebar with no error. + * + * Resolution reuses getDocRoots(), so an Angular toc entry may point at a page + * that only exists under docs/xplat/generated/Angular// until the sync runs. + */ + +/** Ordered { value, line } for every "href" in a toc, read from the raw text. */ +function tocHrefLines(text) { + const lines = []; + const re = /"href"\s*:\s*"((?:[^"\\]|\\.)*)"/g; + let m; + while ((m = re.exec(text)) !== null) { + lines.push({ + value: m[1], + line: (text.slice(0, m.index).match(/\n/g) || []).length + 1, + }); + } + return lines; +} + +/** + * Yields every toc entry carrying an href, in document order, with the chain of + * entry names and the platforms it is excluded for. + * + * buildFilteredToc() in docs/xplat/astro.config.ts drops an excluded node along + * with its children, so exclusions accumulate down the tree. Excluded entries + * are still yielded (flagged) to keep the ordering aligned with tocHrefLines(). + */ +function* walkTocEntries(entries, trail = [], excluded = [], counter = { n: 0 }) { + if (!Array.isArray(entries)) return; + + for (const entry of entries) { + if (!entry || typeof entry !== 'object') continue; + + const path = [...trail, entry.name ?? '(unnamed)']; + const excl = Array.isArray(entry.exclude) ? [...excluded, ...entry.exclude] : excluded; + + if (typeof entry.href === 'string' && entry.href) { + yield { href: entry.href, trail: path, excluded: excl, index: counter.n++ }; + } + if (Array.isArray(entry.items)) { + yield* walkTocEntries(entry.items, path, excl, counter); + } + } +} + +/** + * True when a toc href resolves under any of `roots`. + * + * docExists() accepts the href as written and also swaps .md ↔ .mdx, so both + * count here. + */ +function tocHrefResolves(roots, href) { + const normalized = href.replace(/\\/g, '/'); + const swapped = normalized.endsWith('.mdx') ? normalized.slice(0, -4) + '.md' + : normalized.endsWith('.md') ? normalized.slice(0, -3) + '.mdx' + : null; + + for (const { dir, accepts } of roots) { + if (!accepts(normalized)) continue; + if (isFile(resolve(dir, 'components', normalized))) return true; + if (swapped && isFile(resolve(dir, 'components', swapped))) return true; + } + return false; +} + +/** + * The tocs in scope, as { toc, roots, platform }. + * + * Angular ships its own toc per language and resolves through getDocRoots(), so + * it picks up the xplat-authored pages the sync copies in. The xplat platforms + * share one source toc that astro.config.ts filters per platform at build time, + * so the same filter is applied here against each generated tree. + */ +function getTocTargets() { + const targets = []; + + if (!PLATFORM || PLATFORM === 'angular') { + for (const lang of ['en', 'jp']) { + const langRoot = resolve(cwd, 'docs/angular/src/content', lang) + '/'; + const toc = resolve(langRoot, 'components', 'toc.json'); + if (existsSync(toc)) targets.push({ toc, roots: getDocRoots(langRoot), platform: null }); + } + } + + if (!PLATFORM || PLATFORM === 'xplat' || XPLAT_PLATFORMS.has(PLATFORM)) { + for (const platform of ['React', 'WebComponents', 'Blazor']) { + for (const lang of ['en', 'jp']) { + const toc = resolve(cwd, 'docs/xplat/src/content', lang, 'toc.json'); + const dir = resolve(cwd, 'docs/xplat/generated', platform, lang) + '/'; + if (!existsSync(toc) || !existsSync(dir)) continue; + + targets.push({ toc, roots: [{ dir, accepts: () => true }], platform }); + } + } + } + + return targets; +} + +let totalTocHrefs = 0; + +for (const { toc, roots, platform } of getTocTargets()) { + const text = readFileSync(toc, 'utf-8'); + const relToc = relative(cwd, toc).replace(/\\/g, '/'); + + let entries; + try { + entries = JSON.parse(text); + } catch (error) { + brokenLinks.push({ file: relToc, line: 1, href: '(whole file)', reason: `unparseable JSON: ${error.message}` }); + continue; + } + + const hrefLines = tocHrefLines(text); + + for (const { href, trail, excluded, index } of walkTocEntries(entries)) { + if (platform && excluded.includes(platform)) continue; + + totalTocHrefs++; + if (tocHrefResolves(roots, href)) continue; + + brokenLinks.push({ + file: relToc, + line: hrefLines[index]?.value === href ? hrefLines[index].line : 1, + href, + reason: 'toc-not-found', + entry: platform ? `${platform}: ${trail.join(' → ')}` : trail.join(' → '), + }); + } +} + // Reporting const HR = '═'.repeat(72); @@ -437,7 +747,7 @@ const HR2 = '─'.repeat(72); if (SUMMARY) { const label = PLATFORM ?? 'all'; const status = brokenLinks.length === 0 ? '✅' : '❌'; - console.log(` ${status} ${label.padEnd(10)} Broken links: ${brokenLinks.length} (${totalFiles} files, ${totalLinks} links)`); + console.log(` ${status} ${label.padEnd(10)} Broken links: ${brokenLinks.length} (${totalFiles} files, ${totalLinks} links, ${totalTocHrefs} toc hrefs)`); process.exit(brokenLinks.length > 0 ? 1 : 0); } @@ -445,27 +755,33 @@ if (SUMMARY) { if (SUMMARY) { const label = PLATFORM ?? 'all'; const status = brokenLinks.length === 0 ? '✅' : '❌'; - console.log(` ${status} ${label.padEnd(10)} Broken links: ${brokenLinks.length} (${totalFiles} files, ${totalLinks} links)`); + console.log(` ${status} ${label.padEnd(10)} Broken links: ${brokenLinks.length} (${totalFiles} files, ${totalLinks} links, ${totalTocHrefs} toc hrefs)`); process.exit(brokenLinks.length > 0 ? 1 : 0); } console.log(` MDX/MD files scanned : ${totalFiles}`); console.log(` Relative links found : ${totalLinks}`); -console.log(` Broken links : ${brokenLinks.length}\n`); +console.log(` TOC hrefs found : ${totalTocHrefs}`); +console.log(` Broken : ${brokenLinks.length}\n`); console.log(HR); -console.log(' RELATIVE LINK CHECK REPORT (MDX source)'); +console.log(' RELATIVE LINK CHECK REPORT (MDX source + toc.json)'); console.log(HR); if (brokenLinks.length === 0) { - console.log('\n All relative links resolve to existing files.\n'); + console.log('\n All links and toc entries resolve to pages that ship.\n'); } else { for (const item of brokenLinks) { - const tag = item.reason === 'bare-path' ? '[use ./page.mdx instead]' - : item.reason === 'missing-mdx' ? '[add .mdx extension]' + const tag = item.reason === 'bare-path' ? '[use ./page.mdx instead]' + : item.reason === 'missing-mdx' ? '[add .mdx extension]' + : item.reason === 'components-prefix' ? '[drop the /components prefix]' + : item.reason === 'legacy-html' ? '[drop the .html extension]' + : item.reason === 'toc-not-found' ? '[toc target missing — entry is dropped from the sidebar]' + : item.reason.startsWith('unparseable') ? `[${item.reason}]` : '[not found]'; console.log(`\n ✗ ${item.file}:${item.line} ${tag}`); console.log(` href: ${item.href}`); + if (item.entry) console.log(` entry: ${item.entry}`); } console.log(''); } @@ -487,7 +803,8 @@ if (MD_OUTPUT) { lines.push('|---|---|'); lines.push(`| Files scanned | ${totalFiles} |`); lines.push(`| Relative links | ${totalLinks} |`); - lines.push(`| ✅ OK | ${totalLinks - brokenLinks.length} |`); + lines.push(`| TOC hrefs | ${totalTocHrefs} |`); + lines.push(`| ✅ OK | ${totalLinks + totalTocHrefs - brokenLinks.length} |`); lines.push(`| ❌ **Broken** | **${brokenLinks.length}** |`); lines.push(''); @@ -497,10 +814,15 @@ if (MD_OUTPUT) { lines.push('| File | Line | href | Issue |'); lines.push('|---|---:|---|---|'); for (const item of brokenLinks) { - const issue = item.reason === 'bare-path' ? 'use ./page.mdx instead' - : item.reason === 'missing-mdx' ? 'add .mdx extension' + const issue = item.reason === 'bare-path' ? 'use ./page.mdx instead' + : item.reason === 'missing-mdx' ? 'add .mdx extension' + : item.reason === 'components-prefix' ? 'drop the /components prefix' + : item.reason === 'legacy-html' ? 'drop the .html extension' + : item.reason === 'toc-not-found' ? 'toc target missing — entry is dropped from the sidebar' + : item.reason.startsWith('unparseable') ? item.reason : 'not found'; - lines.push(`| \`${item.file}\` | ${item.line} | \`${item.href}\` | ${issue} |`); + const where = item.entry ? ` (${item.entry})` : ''; + lines.push(`| \`${item.file}\` | ${item.line} | \`${item.href}\`${where} | ${issue} |`); } lines.push(''); }