|
| 1 | +import type { Context, Page } from '@/types' |
| 2 | +import { allVersions } from '@/versions/lib/all-versions' |
| 3 | +import { getFeaturesByVersion } from '@/versions/middleware/features' |
| 4 | +import { getDataByLanguage, getDeepDataByLanguage } from '@/data-directory/lib/get-data' |
| 5 | +import { |
| 6 | + normalizeLinkPath, |
| 7 | + renderAndExtractLinks, |
| 8 | + resolveInternalLinkKey, |
| 9 | +} from '@/links/lib/extract-links' |
| 10 | +import { computeHeadingIds } from '@/links/lib/heading-anchors' |
| 11 | + |
| 12 | +/** |
| 13 | + * Shared, version-aware helpers for validating that a `path#fragment` link lands on a |
| 14 | + * real heading of its target page. Kept neutral (no PR/CI specifics) so both the |
| 15 | + * scheduled internal checker and the PR-time gate can render a target page in a given |
| 16 | + * version and compute its heading anchor IDs the same way. |
| 17 | + */ |
| 18 | + |
| 19 | +// Explicit version prefix on a resolved pageMap key, e.g. the `enterprise-cloud@latest` |
| 20 | +// in `/en/enterprise-cloud@latest/actions/foo`. `resolveInternalLinkKey` already maps |
| 21 | +// `enterprise-server@latest` to the stable release, so `@latest` never appears for GHES. |
| 22 | +const EXPLICIT_VERSION_RE = |
| 23 | + /^\/(?:[a-z]{2}(?:-[a-z]{2})?\/)?(free-pro-team@latest|enterprise-cloud@latest|enterprise-server@[0-9.]+)(?:\/|$)/ |
| 24 | + |
| 25 | +/** |
| 26 | + * Extract the version a resolved link key pins to, or null when the key carries no |
| 27 | + * explicit version segment (an unversioned link that inherits the source page's version). |
| 28 | + */ |
| 29 | +export function versionFromResolvedKey(key: string): string | null { |
| 30 | + const match = key.match(EXPLICIT_VERSION_RE) |
| 31 | + if (!match) return null |
| 32 | + return allVersions[match[1]] ? match[1] : null |
| 33 | +} |
| 34 | + |
| 35 | +/** |
| 36 | + * Line numbers (1-based) in `content` where a Markdown link points at exactly |
| 37 | + * `hrefWithFragment`. |
| 38 | + * |
| 39 | + * The destination must END at the match: a bare substring search is a prefix match, so |
| 40 | + * looking for `](/a#foo` would also hit `](/a#foobar)` and misreport that line. A Markdown |
| 41 | + * destination ends at `)` or at whitespace before an optional title. |
| 42 | + */ |
| 43 | +export function findLinkLines(content: string, hrefWithFragment: string): number[] { |
| 44 | + const needle = `](${hrefWithFragment}` |
| 45 | + const lines = content.split('\n') |
| 46 | + const found: number[] = [] |
| 47 | + for (let i = 0; i < lines.length; i++) { |
| 48 | + const line = lines[i] |
| 49 | + for (let at = line.indexOf(needle); at !== -1; at = line.indexOf(needle, at + 1)) { |
| 50 | + const next = line[at + needle.length] |
| 51 | + if (next === undefined || next === ')' || /\s/.test(next)) { |
| 52 | + found.push(i + 1) |
| 53 | + break |
| 54 | + } |
| 55 | + } |
| 56 | + } |
| 57 | + return found |
| 58 | +} |
| 59 | + |
| 60 | +/** |
| 61 | + * Resolve a link href to a pageMap key, considering the version the source page is being |
| 62 | + * rendered in. |
| 63 | + * |
| 64 | + * `resolveInternalLinkKey` only tries the href as written. An unversioned href like |
| 65 | + * `/copilot/foo` written on a GHEC-only page has no `/en/copilot/foo` key in the pageMap |
| 66 | + * (that key only exists when the target applies to FPT), so resolution returns null and |
| 67 | + * the link is silently skipped. Retrying with the source version prefixed picks up those |
| 68 | + * targets so their anchors get checked too. |
| 69 | + */ |
| 70 | +export function resolveLinkKeyForVersion( |
| 71 | + href: string, |
| 72 | + version: string, |
| 73 | + pageMap: Record<string, Page>, |
| 74 | +): string | null { |
| 75 | + const direct = resolveInternalLinkKey(href, pageMap) |
| 76 | + if (direct) return direct |
| 77 | + |
| 78 | + // Only worth retrying when the href carries no version of its own. |
| 79 | + const normalized = normalizeLinkPath(href) |
| 80 | + if (EXPLICIT_VERSION_RE.test(normalized)) return null |
| 81 | + |
| 82 | + const withoutLang = normalized.replace(/^\/[a-z]{2}(?:-[a-z]{2})?(?=\/|$)/, '') |
| 83 | + return resolveInternalLinkKey(`/${version}${withoutLang}`, pageMap) |
| 84 | +} |
| 85 | + |
| 86 | +/** |
| 87 | + * Whether a target page's heading anchors can be derived from its Markdown headings. |
| 88 | + * |
| 89 | + * Two page kinds compute their headings from data at runtime, not from static Markdown |
| 90 | + * headings, so `computeHeadingIds` can't see them and would report false positives: |
| 91 | + * - `autogenerated` pages (REST/GraphQL/webhooks/audit-log-events): headings come from |
| 92 | + * OpenAPI operation IDs / action prefixes. |
| 93 | + * - the glossary page: headings come from a `{% for glossary in glossaries %}` loop over |
| 94 | + * `data/glossaries/external.yml`, which isn't populated in this lightweight render. |
| 95 | + * |
| 96 | + * Links into these pages are skipped rather than flagged. |
| 97 | + */ |
| 98 | +export function isAnchorCheckableTarget(page: Page): boolean { |
| 99 | + if ((page as unknown as { autogenerated?: unknown }).autogenerated) return false |
| 100 | + const markdown = page.markdown |
| 101 | + if ( |
| 102 | + typeof markdown === 'string' && |
| 103 | + /\{%-?\s*for\s+\w+\s+in\s+glossaries\s*-?%\}/.test(markdown) |
| 104 | + ) { |
| 105 | + return false |
| 106 | + } |
| 107 | + return true |
| 108 | +} |
| 109 | + |
| 110 | +// Loaded once and shared: getDeepDataByLanguage walks the whole data/tables tree, which is |
| 111 | +// far too expensive to repeat per page per version. |
| 112 | +let tablesCache: Record<string, unknown> | null = null |
| 113 | +function getTables(): Record<string, unknown> { |
| 114 | + if (!tablesCache) tablesCache = getDeepDataByLanguage('tables', 'en') |
| 115 | + return tablesCache |
| 116 | +} |
| 117 | + |
| 118 | +/** |
| 119 | + * Build a minimal per-version Liquid context for rendering a page's Markdown. Mirrors the |
| 120 | + * context the scheduled internal checker uses (version flags + feature flags), plus the |
| 121 | + * `variables` site data so `{% data variables.* %}` in headings resolves, and the `tables` |
| 122 | + * data so pages that generate headings from a `{% for %}` loop over a reference table |
| 123 | + * (e.g. `content/copilot/reference/copilot-feature-matrix.md`) produce their real heading |
| 124 | + * text instead of a literal `{{ groupName }}`, which would otherwise false-positive. |
| 125 | + */ |
| 126 | +export function buildRenderContext( |
| 127 | + page: Page, |
| 128 | + version: string, |
| 129 | + pageMap: Record<string, Page>, |
| 130 | + redirects: Record<string, string>, |
| 131 | +): Context { |
| 132 | + const versionObj = allVersions[version] |
| 133 | + return { |
| 134 | + currentVersion: version, |
| 135 | + currentLanguage: 'en', |
| 136 | + currentVersionObj: versionObj, |
| 137 | + [versionObj.shortName]: true, |
| 138 | + site: getDataByLanguage('variables', 'en'), |
| 139 | + tables: getTables(), |
| 140 | + page, |
| 141 | + pages: pageMap, |
| 142 | + redirects, |
| 143 | + ...getFeaturesByVersion(version), |
| 144 | + } as unknown as Context |
| 145 | +} |
| 146 | + |
| 147 | +/** |
| 148 | + * Compute the set of heading anchor IDs for a page rendered in a specific version. |
| 149 | + * Results are memoized in the optional `cache` (keyed by version + relative path) so a |
| 150 | + * target referenced by many links is only rendered once per version. |
| 151 | + */ |
| 152 | +export async function getPageHeadingIds( |
| 153 | + page: Page, |
| 154 | + version: string, |
| 155 | + pageMap: Record<string, Page>, |
| 156 | + redirects: Record<string, string>, |
| 157 | + cache?: Map<string, Set<string>>, |
| 158 | +): Promise<Set<string>> { |
| 159 | + const cacheKey = `${version}|${page.relativePath}` |
| 160 | + const cached = cache?.get(cacheKey) |
| 161 | + if (cached) return cached |
| 162 | + |
| 163 | + const context = buildRenderContext(page, version, pageMap, redirects) |
| 164 | + const { renderedMarkdown } = await renderAndExtractLinks(page.markdown, context) |
| 165 | + const ids = computeHeadingIds(renderedMarkdown) |
| 166 | + |
| 167 | + cache?.set(cacheKey, ids) |
| 168 | + return ids |
| 169 | +} |
0 commit comments