@vibe-agent-toolkit/resources 0.1.39-rc.1 → 0.1.39-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/frontmatter-link-validator.d.ts +4 -4
  2. package/dist/frontmatter-link-validator.d.ts.map +1 -1
  3. package/dist/frontmatter-link-validator.js +3 -3
  4. package/dist/frontmatter-link-validator.js.map +1 -1
  5. package/dist/html-link-parser.d.ts +47 -0
  6. package/dist/html-link-parser.d.ts.map +1 -0
  7. package/dist/html-link-parser.js +111 -0
  8. package/dist/html-link-parser.js.map +1 -0
  9. package/dist/html-transform.d.ts +45 -0
  10. package/dist/html-transform.d.ts.map +1 -0
  11. package/dist/html-transform.js +116 -0
  12. package/dist/html-transform.js.map +1 -0
  13. package/dist/index.d.ts +4 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +3 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/link-parser.d.ts +26 -2
  18. package/dist/link-parser.d.ts.map +1 -1
  19. package/dist/link-parser.js +28 -1
  20. package/dist/link-parser.js.map +1 -1
  21. package/dist/link-validator.d.ts +50 -3
  22. package/dist/link-validator.d.ts.map +1 -1
  23. package/dist/link-validator.js +72 -55
  24. package/dist/link-validator.js.map +1 -1
  25. package/dist/resource-registry.d.ts +46 -11
  26. package/dist/resource-registry.d.ts.map +1 -1
  27. package/dist/resource-registry.js +140 -34
  28. package/dist/resource-registry.js.map +1 -1
  29. package/dist/schemas/link-auth.d.ts +382 -0
  30. package/dist/schemas/link-auth.d.ts.map +1 -0
  31. package/dist/schemas/link-auth.js +124 -0
  32. package/dist/schemas/link-auth.js.map +1 -0
  33. package/dist/schemas/project-config.d.ts +642 -98
  34. package/dist/schemas/project-config.d.ts.map +1 -1
  35. package/dist/schemas/project-config.js +3 -0
  36. package/dist/schemas/project-config.js.map +1 -1
  37. package/dist/schemas/resource-metadata.d.ts +46 -10
  38. package/dist/schemas/resource-metadata.d.ts.map +1 -1
  39. package/dist/schemas/resource-metadata.js +14 -1
  40. package/dist/schemas/resource-metadata.js.map +1 -1
  41. package/package.json +4 -3
  42. package/src/frontmatter-link-validator.ts +5 -5
  43. package/src/html-link-parser.ts +140 -0
  44. package/src/html-transform.ts +157 -0
  45. package/src/index.ts +6 -1
  46. package/src/link-parser.ts +35 -2
  47. package/src/link-validator.ts +102 -82
  48. package/src/resource-registry.ts +157 -39
  49. package/src/schemas/link-auth.ts +146 -0
  50. package/src/schemas/project-config.ts +4 -0
  51. package/src/schemas/resource-metadata.ts +17 -1
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Structure-preserving HTML link rewriter.
3
+ *
4
+ * Rewrites `<a href>` and `<img src>` attribute VALUES in place by splicing the
5
+ * original source string at parse5-reported offsets. The document is never
6
+ * re-serialized (parse5's serializer normalizes whitespace, quotes, void
7
+ * elements, and the doctype), so unchanged input round-trips byte-for-byte.
8
+ *
9
+ * Uses the same `RewriteHref` callback model as `rewriteBodyLinks` — callers
10
+ * supply per-href target resolution; this module owns only the splice mechanics.
11
+ *
12
+ * Non-goal: `<base href>` is not honored. Relative hrefs are resolved by the
13
+ * caller against the file's own directory; a `<base>` element that would
14
+ * override that in a browser is ignored.
15
+ */
16
+
17
+ import { parseHtmlDocument, walkElements } from './html-link-parser.js';
18
+ import type { RewriteHref } from './rewriter-helpers.js';
19
+
20
+ /** Tag name → the single link-bearing attribute we rewrite. */
21
+ const LINK_ATTR_BY_TAG: Record<string, string | undefined> = { a: 'href', img: 'src' };
22
+
23
+ interface ValueSpan {
24
+ valueStart: number;
25
+ valueEnd: number;
26
+ /** `"` or `'` for quoted attributes; `''` for unquoted. */
27
+ quote: string;
28
+ }
29
+
30
+ interface Edit extends ValueSpan {
31
+ newValue: string;
32
+ }
33
+
34
+ /**
35
+ * Chars that force an unquoted HTML attribute value to be quoted. Follows the
36
+ * WHATWG unquoted-attribute-value rules: whitespace, quotes, `<`, `>`, plus
37
+ * backtick and `=` (a superset of paths we expect, kept strict for safety).
38
+ */
39
+ const UNQUOTED_UNSAFE = /[\s"'`<>=]/;
40
+
41
+ /**
42
+ * Locate the value sub-range within an attribute's full source span.
43
+ *
44
+ * parse5 reports the whole-attribute span (`href="value"`); this finds the
45
+ * value's absolute offsets and the quote char. Returns undefined for boolean
46
+ * attributes (no `=`).
47
+ */
48
+ function valueSpan(attrSource: string, base: number): ValueSpan | undefined {
49
+ const eq = attrSource.indexOf('=');
50
+ if (eq === -1) {
51
+ return undefined;
52
+ }
53
+ let i = eq + 1;
54
+ while (i < attrSource.length && /\s/.test(attrSource.charAt(i))) {
55
+ i += 1;
56
+ }
57
+ if (i >= attrSource.length) {
58
+ return undefined;
59
+ }
60
+ const ch = attrSource.charAt(i);
61
+ if (ch === '"' || ch === "'") {
62
+ const close = attrSource.indexOf(ch, i + 1);
63
+ const end = close === -1 ? attrSource.length : close;
64
+ return { valueStart: base + i + 1, valueEnd: base + end, quote: ch };
65
+ }
66
+ return { valueStart: base + i, valueEnd: base + attrSource.length, quote: '' };
67
+ }
68
+
69
+ /**
70
+ * Encode a new value for writing. For quoted attributes the surrounding quotes
71
+ * stay in the source (we only replace the inner value), so we escape `&` and the
72
+ * active quote. For originally-unquoted values we keep them bare when safe, else
73
+ * wrap in double quotes.
74
+ */
75
+ function encodeValue(newValue: string, quote: string): string {
76
+ if (quote === '"' || quote === "'") {
77
+ const amp = newValue.replaceAll('&', '&amp;');
78
+ return quote === '"' ? amp.replaceAll('"', '&quot;') : amp.replaceAll("'", '&#39;');
79
+ }
80
+ if (newValue.length > 0 && !UNQUOTED_UNSAFE.test(newValue)) {
81
+ return newValue.replaceAll('&', '&amp;');
82
+ }
83
+ const escaped = newValue.replaceAll('&', '&amp;').replaceAll('"', '&quot;');
84
+ return `"${escaped}"`;
85
+ }
86
+
87
+ /** A wanted link rewrite that could not be spliced back into the source. */
88
+ export interface UnappliedRewrite {
89
+ tagName: string;
90
+ attr: string;
91
+ /** The original attribute value the rewrite targeted. */
92
+ from: string;
93
+ /** The value the rewrite wanted to write. */
94
+ to: string;
95
+ /**
96
+ * Why the splice could not be located:
97
+ * - `no-source-location` — parse5 omitted the attribute's offsets (synthesized
98
+ * during error recovery on malformed input).
99
+ * - `unparseable-attribute` — the attribute's source span had no locatable
100
+ * value (e.g. an unterminated quote).
101
+ */
102
+ reason: 'no-source-location' | 'unparseable-attribute';
103
+ }
104
+
105
+ /**
106
+ * Rewrite `<a href>` / `<img src>` values in `source` using `rewriteHref`.
107
+ * Returns `source` unchanged (byte-for-byte) when no value changes.
108
+ *
109
+ * A rewrite that is wanted (the href resolves to a new value) but cannot be
110
+ * spliced back — because parse5 omitted the source location or the attribute
111
+ * span is unparseable — is reported via `onUnapplied` rather than dropped
112
+ * silently. Malformed pages are exactly the ones that hit this path, so the
113
+ * caller can surface it instead of shipping a stale link.
114
+ */
115
+ export function rewriteHtmlLinks(
116
+ source: string,
117
+ rewriteHref: RewriteHref,
118
+ onUnapplied?: (info: UnappliedRewrite) => void,
119
+ ): string {
120
+ const { document } = parseHtmlDocument(source);
121
+ const edits: Edit[] = [];
122
+
123
+ for (const element of walkElements(document)) {
124
+ const attrName = LINK_ATTR_BY_TAG[element.tagName];
125
+ if (attrName === undefined) {
126
+ continue;
127
+ }
128
+ const attr = element.attrs.find((a) => a.name === attrName);
129
+ if (attr === undefined) {
130
+ continue;
131
+ }
132
+ // Resolve first so we only record drops that actually lose a wanted edit.
133
+ const newValue = rewriteHref(attr.value);
134
+ if (newValue === attr.value) {
135
+ continue;
136
+ }
137
+ const location = element.sourceCodeLocation?.attrs?.[attrName];
138
+ if (location === undefined) {
139
+ onUnapplied?.({ tagName: element.tagName, attr: attrName, from: attr.value, to: newValue, reason: 'no-source-location' });
140
+ continue;
141
+ }
142
+ const span = valueSpan(source.slice(location.startOffset, location.endOffset), location.startOffset);
143
+ if (span === undefined) {
144
+ onUnapplied?.({ tagName: element.tagName, attr: attrName, from: attr.value, to: newValue, reason: 'unparseable-attribute' });
145
+ continue;
146
+ }
147
+ edits.push({ ...span, newValue });
148
+ }
149
+
150
+ // Apply descending by start offset so earlier edits don't shift later ones.
151
+ edits.sort((a, b) => b.valueStart - a.valueStart);
152
+ let result = source;
153
+ for (const edit of edits) {
154
+ result = result.slice(0, edit.valueStart) + encodeValue(edit.newValue, edit.quote) + result.slice(edit.valueEnd);
155
+ }
156
+ return result;
157
+ }
package/src/index.ts CHANGED
@@ -83,7 +83,12 @@ export {
83
83
  } from './schemas/validation-result.js';
84
84
 
85
85
  // Export parser interface for advanced use cases
86
- export { parseMarkdown, type ParseResult } from './link-parser.js';
86
+ export { parseMarkdown, classifyLink, isLocalFileLink, type ParseResult } from './link-parser.js';
87
+
88
+ export { parseHtml } from './html-link-parser.js';
89
+ // HtmlParseError is Zod-sourced (single source of truth) — see schemas/resource-metadata.ts.
90
+ export type { HtmlParseError } from './schemas/resource-metadata.js';
91
+ export { rewriteHtmlLinks, type UnappliedRewrite } from './html-transform.js';
87
92
 
88
93
  // Export frontmatter validation
89
94
  export { validateFrontmatter } from './frontmatter-validator.js';
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Markdown link parser and analyzer.
2
+ * Markdown link parser and shared resource-parsing types.
3
3
  *
4
4
  * Parses markdown files to extract:
5
5
  * - Links (regular, reference-style, autolinks)
@@ -7,6 +7,10 @@
7
7
  * - File size and token estimates
8
8
  *
9
9
  * Uses unified/remark for robust markdown parsing with GFM support.
10
+ *
11
+ * Also defines the format-neutral `ParseResult` contract shared with the HTML
12
+ * parser (`html-link-parser.ts`). The `HtmlParseError` shape is Zod-sourced
13
+ * from `schemas/resource-metadata.ts` (single source of truth).
10
14
  */
11
15
 
12
16
  import { readFile, stat } from 'node:fs/promises';
@@ -20,10 +24,11 @@ import { unified } from 'unified';
20
24
  import { visit } from 'unist-util-visit';
21
25
  import * as yaml from 'yaml';
22
26
 
27
+ import type { HtmlParseError } from './schemas/resource-metadata.js';
23
28
  import type { HeadingNode, LinkType, ResourceLink } from './types.js';
24
29
 
25
30
  /**
26
- * Result of parsing a markdown file.
31
+ * Result of parsing a resource file (markdown or HTML).
27
32
  */
28
33
  export interface ParseResult {
29
34
  links: ResourceLink[];
@@ -33,6 +38,10 @@ export interface ParseResult {
33
38
  content: string;
34
39
  sizeBytes: number;
35
40
  estimatedTokenCount: number;
41
+ /** Fragment targets (HTML `id`/`name` attributes). Markdown leaves this undefined. */
42
+ anchors?: string[];
43
+ /** HTML well-formedness diagnostics. Markdown leaves this undefined. */
44
+ parseErrors?: HtmlParseError[];
36
45
  }
37
46
 
38
47
  /**
@@ -185,6 +194,11 @@ function extractLinkText(node: Link | LinkReference): string {
185
194
  * classifyLink('#heading') // 'anchor'
186
195
  * classifyLink('./file.md') // 'local_file'
187
196
  * classifyLink('./file.md#anchor') // 'local_file'
197
+ * classifyLink('docs/') // 'local_directory'
198
+ * classifyLink('./docs/') // 'local_directory'
199
+ * classifyLink('../docs/') // 'local_directory'
200
+ * classifyLink('/docs/') // 'local_directory'
201
+ * classifyLink('https://x.com/docs/') // 'external' (not a local ref)
188
202
  * ```
189
203
  */
190
204
  export function classifyLink(href: string): LinkType {
@@ -202,6 +216,12 @@ export function classifyLink(href: string): LinkType {
202
216
  if (href.includes(':')) {
203
217
  return 'unknown';
204
218
  }
219
+ // Local directory: path component (before any # or ?) ends in '/'.
220
+ // Must come after all protocol guards so external URLs are never reclassified.
221
+ const pathPart = href.split(/[#?]/u)[0] ?? href;
222
+ if (pathPart.endsWith('/')) {
223
+ return 'local_directory';
224
+ }
205
225
  // Links with anchors are still local file links
206
226
  if (href.includes('#')) {
207
227
  return 'local_file';
@@ -242,6 +262,19 @@ export function classifyLink(href: string): LinkType {
242
262
  return 'unknown';
243
263
  }
244
264
 
265
+ /**
266
+ * Returns true for link types that represent local filesystem targets — both
267
+ * regular files and directories. Other packages (e.g. agent-skills walker)
268
+ * import this predicate as the single source of truth for "should we treat
269
+ * this link like a file link during validation/traversal?"
270
+ *
271
+ * @param type - The classified link type
272
+ * @returns `true` for `'local_file'` and `'local_directory'`
273
+ */
274
+ export function isLocalFileLink(type: LinkType): boolean {
275
+ return type === 'local_file' || type === 'local_directory';
276
+ }
277
+
245
278
  /**
246
279
  * Extract headings from the markdown AST and build a nested tree structure.
247
280
  *
@@ -25,7 +25,7 @@ import {
25
25
  verifyCaseSensitiveFilename,
26
26
  } from '@vibe-agent-toolkit/utils';
27
27
 
28
- import type { HeadingNode, ResourceLink } from './types.js';
28
+ import type { ResourceLink } from './types.js';
29
29
  import { isWithinProject, issueLocation, resolveLocalHref } from './utils.js';
30
30
 
31
31
  type LinkIssueExtras = Partial<Pick<ValidationIssue, 'location' | 'line' | 'link' | 'suggestion'>>;
@@ -66,7 +66,7 @@ export interface ValidateLinkOptions {
66
66
  *
67
67
  * @param link - The link to validate
68
68
  * @param sourceFilePath - Absolute path to the file containing the link
69
- * @param headingsByFile - Map of file paths to their heading trees
69
+ * @param fragmentsByFile - Fragment index: file path set of valid fragments (markdown slugs + HTML id/name)
70
70
  * @param options - Validation options (projectRoot, skipGitIgnoreCheck)
71
71
  * @returns ValidationIssue if link is broken, null if valid
72
72
  *
@@ -84,15 +84,16 @@ export interface ValidateLinkOptions {
84
84
  export async function validateLink(
85
85
  link: ResourceLink,
86
86
  sourceFilePath: string,
87
- headingsByFile: Map<string, HeadingNode[]>,
87
+ fragmentsByFile: FragmentIndex,
88
88
  options?: ValidateLinkOptions
89
89
  ): Promise<ValidationIssue | null> {
90
90
  switch (link.type) {
91
91
  case 'local_file':
92
- return await validateLocalFileLink(link, sourceFilePath, headingsByFile, options);
92
+ case 'local_directory':
93
+ return await validateLocalFileLink(link, sourceFilePath, fragmentsByFile, options);
93
94
 
94
95
  case 'anchor':
95
- return await validateAnchorLink(link, sourceFilePath, headingsByFile, options?.projectRoot);
96
+ return await validateAnchorLink(link, sourceFilePath, fragmentsByFile, options?.projectRoot);
96
97
 
97
98
  case 'external':
98
99
  // External URLs are not validated - don't report them
@@ -232,7 +233,7 @@ export function gitIgnoreSafetyIssue(
232
233
  async function validateLocalFileLink(
233
234
  link: ResourceLink,
234
235
  sourceFilePath: string,
235
- headingsByFile: Map<string, HeadingNode[]>,
236
+ fragmentsByFile: FragmentIndex,
236
237
  options?: ValidateLinkOptions
237
238
  ): Promise<ValidationIssue | null> {
238
239
  const resolved = resolveLocalHref(link.href, sourceFilePath, options?.projectRoot);
@@ -246,25 +247,12 @@ async function validateLocalFileLink(
246
247
  const notFound = fileExistenceIssue(fileResult, link, sourceFilePath, options?.projectRoot);
247
248
  if (notFound) return notFound;
248
249
 
249
- if (fileResult.isDirectory) {
250
- return createRegistryIssue(
251
- 'LINK_BROKEN_FILE',
252
- `Link target is a directory: ${fileResult.resolvedPath}`,
253
- linkExtras(
254
- link,
255
- sourceFilePath,
256
- options?.projectRoot,
257
- 'Link to a file inside the directory (e.g., README.md or index.md), or fix the link to point at the intended file.',
258
- ),
259
- );
260
- }
261
-
262
250
  const gitIgnoreIssue = gitIgnoreSafetyIssue(link, sourceFilePath, fileResult.resolvedPath, options);
263
251
  if (gitIgnoreIssue) return gitIgnoreIssue;
264
252
 
265
253
  if (resolved.anchor) {
266
- const anchorValid = await validateAnchor(resolved.anchor, fileResult.resolvedPath, headingsByFile);
267
- if (!anchorValid) {
254
+ const check = checkAnchor(resolved.anchor, fileResult.resolvedPath, fragmentsByFile);
255
+ if (check === 'broken') {
268
256
  return createRegistryIssue(
269
257
  'LINK_BROKEN_ANCHOR',
270
258
  `Anchor not found: #${resolved.anchor} in ${fileResult.resolvedPath}`,
@@ -276,30 +264,42 @@ async function validateLocalFileLink(
276
264
  return null;
277
265
  }
278
266
 
267
+ /**
268
+ * Exhaustiveness guard for the {@link AnchorCheck} union: a compile error at the
269
+ * call site means a new variant was added without being handled here.
270
+ */
271
+ function assertNever(value: never): never {
272
+ throw new Error(`Unhandled AnchorCheck variant: ${String(value)}`);
273
+ }
274
+
279
275
  /**
280
276
  * Validate an anchor link (within current file).
281
277
  */
282
278
  async function validateAnchorLink(
283
279
  link: ResourceLink,
284
280
  sourceFilePath: string,
285
- headingsByFile: Map<string, HeadingNode[]>,
281
+ fragmentsByFile: FragmentIndex,
286
282
  projectRoot?: string,
287
283
  ): Promise<ValidationIssue | null> {
288
284
  // Extract anchor (strip leading #)
289
285
  const anchor = link.href.startsWith('#') ? link.href.slice(1) : link.href;
290
286
 
291
287
  // Validate anchor exists in current file
292
- const isValid = await validateAnchor(anchor, sourceFilePath, headingsByFile);
288
+ const check = checkAnchor(anchor, sourceFilePath, fragmentsByFile);
293
289
 
294
- if (!isValid) {
295
- return createRegistryIssue(
296
- 'LINK_BROKEN_ANCHOR',
297
- `Anchor not found: ${link.href}`,
298
- linkExtras(link, sourceFilePath, projectRoot, ''),
299
- );
290
+ switch (check) {
291
+ case 'skip':
292
+ case 'valid':
293
+ return null;
294
+ case 'broken':
295
+ return createRegistryIssue(
296
+ 'LINK_BROKEN_ANCHOR',
297
+ `Anchor not found: ${link.href}`,
298
+ linkExtras(link, sourceFilePath, projectRoot, ''),
299
+ );
300
+ default:
301
+ return assertNever(check);
300
302
  }
301
-
302
- return null;
303
303
  }
304
304
 
305
305
 
@@ -338,65 +338,85 @@ async function validateResolvedFile(
338
338
  return result;
339
339
  }
340
340
 
341
+ /** Result of checking an anchor against the fragment index. */
342
+ export type AnchorCheck = 'skip' | 'valid' | 'broken';
343
+
341
344
  /**
342
- * Validate that an anchor (heading slug) exists in a file.
343
- *
344
- * @param anchor - The heading slug to find (without leading #)
345
- * @param targetFilePath - Absolute path to the file containing the heading
346
- * @param headingsByFile - Map of file paths to their heading trees
347
- * @returns True if anchor exists, false otherwise
348
- *
349
- * @example
350
- * ```typescript
351
- * const valid = await validateAnchor('my-heading', '/project/docs/guide.md', headingsMap);
352
- * ```
345
+ * A file's fragment targets plus how to match against them. HTML `id`/`name`
346
+ * anchors are matched case-sensitively; markdown heading slugs are case-folded.
347
+ * The policy lives on the entry rather than being re-derived from the file
348
+ * extension at match time, so a new resource format only has to set this flag.
353
349
  */
354
- async function validateAnchor(
355
- anchor: string,
356
- targetFilePath: string,
357
- headingsByFile: Map<string, HeadingNode[]>
358
- ): Promise<boolean> {
359
- // Get headings for target file
360
- const headings = headingsByFile.get(targetFilePath);
361
- if (!headings) {
362
- return false;
363
- }
350
+ export interface FragmentIndexEntry {
351
+ /** `true` for case-sensitive matching (HTML ids), `false` for case-folded (markdown slugs). */
352
+ caseSensitive: boolean;
353
+ fragments: Set<string>;
354
+ }
355
+
356
+ /** File path → its fragment targets and matching policy. */
357
+ export type FragmentIndex = Map<string, FragmentIndexEntry>;
364
358
 
365
- // Search for matching slug (case-insensitive)
366
- return findHeadingBySlug(headings, anchor);
359
+ /**
360
+ * Whether a path is an HTML resource (`.html`/`.htm`). The single place the
361
+ * extension drives format-specific behavior: case-sensitive ids (via
362
+ * {@link fragmentIndexEntry}) and the HTML top-fragment navigation rule.
363
+ */
364
+ export function isHtmlPath(filePath: string): boolean {
365
+ return /\.html?$/i.test(filePath);
366
+ }
367
+
368
+ /** Build one index entry, choosing the case-folding policy from the file type. */
369
+ export function fragmentIndexEntry(filePath: string, fragments: Set<string> = new Set()): FragmentIndexEntry {
370
+ return { caseSensitive: isHtmlPath(filePath), fragments };
367
371
  }
368
372
 
369
373
  /**
370
- * Recursively search heading tree for a matching slug.
371
- *
372
- * Performs case-insensitive comparison of slugs.
374
+ * Build a {@link FragmentIndex} from `[path, fragments]` pairs, deriving each
375
+ * entry's matching policy from its path. Single construction path shared by the
376
+ * registry and tests.
377
+ */
378
+ export function fragmentIndex(entries: Iterable<readonly [string, Set<string>]> = []): FragmentIndex {
379
+ const map: FragmentIndex = new Map();
380
+ for (const [filePath, fragments] of entries) {
381
+ map.set(filePath, fragmentIndexEntry(filePath, fragments));
382
+ }
383
+ return map;
384
+ }
385
+
386
+ /**
387
+ * Check whether a fragment exists in the target file's anchor set.
373
388
  *
374
- * @param headings - Array of heading nodes to search
375
- * @param targetSlug - The slug to find
376
- * @returns True if slug found, false otherwise
389
+ * - `'skip'` — target file is not indexed; we cannot prove the anchor is
390
+ * broken, so callers must not emit an issue.
391
+ * - Matching follows the entry's `caseSensitive` policy (HTML ids exact,
392
+ * markdown slugs case-folded). The index carries the policy, so this never
393
+ * re-derives the folding rule from the file extension.
394
+ * - For HTML targets the empty fragment (`#`) and `top` (ASCII
395
+ * case-insensitive) are always valid: per the HTML fragment-navigation
396
+ * algorithm both scroll to the top of the document regardless of whether a
397
+ * matching element exists, so `href="#"` / `href="#top"` are not broken.
377
398
  *
378
- * @example
379
- * ```typescript
380
- * const found = findHeadingBySlug(headings, 'my-heading');
381
- * ```
399
+ * @param anchor - Fragment without the leading `#`.
400
+ * @param targetFilePath - Absolute path of the file the fragment lives in.
401
+ * @param fragmentsByFile - Fragment index carrying each file's matching policy.
382
402
  */
383
- function findHeadingBySlug(
384
- headings: HeadingNode[],
385
- targetSlug: string
386
- ): boolean {
387
- const normalizedTarget = targetSlug.toLowerCase();
388
-
389
- for (const heading of headings) {
390
- // Check current heading
391
- if (heading.slug.toLowerCase() === normalizedTarget) {
392
- return true;
393
- }
394
-
395
- // Recursively check children
396
- if (heading.children && findHeadingBySlug(heading.children, targetSlug)) {
397
- return true;
398
- }
403
+ export function checkAnchor(
404
+ anchor: string,
405
+ targetFilePath: string,
406
+ fragmentsByFile: FragmentIndex,
407
+ ): AnchorCheck {
408
+ const entry = fragmentsByFile.get(targetFilePath);
409
+ if (!entry) {
410
+ return 'skip';
399
411
  }
400
-
401
- return false;
412
+ // HTML spec: the empty fragment and "top" (case-insensitive) always navigate
413
+ // to the top of the document — valid even with no matching id. This is an
414
+ // HTML-format navigation rule, distinct from the case-folding policy above.
415
+ if (isHtmlPath(targetFilePath) && (anchor === '' || anchor.toLowerCase() === 'top')) {
416
+ return 'valid';
417
+ }
418
+ const found = entry.caseSensitive
419
+ ? entry.fragments.has(anchor)
420
+ : entry.fragments.has(anchor.toLowerCase());
421
+ return found ? 'valid' : 'broken';
402
422
  }