@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.
- package/dist/frontmatter-link-validator.d.ts +4 -4
- package/dist/frontmatter-link-validator.d.ts.map +1 -1
- package/dist/frontmatter-link-validator.js +3 -3
- package/dist/frontmatter-link-validator.js.map +1 -1
- package/dist/html-link-parser.d.ts +47 -0
- package/dist/html-link-parser.d.ts.map +1 -0
- package/dist/html-link-parser.js +111 -0
- package/dist/html-link-parser.js.map +1 -0
- package/dist/html-transform.d.ts +45 -0
- package/dist/html-transform.d.ts.map +1 -0
- package/dist/html-transform.js +116 -0
- package/dist/html-transform.js.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/link-parser.d.ts +26 -2
- package/dist/link-parser.d.ts.map +1 -1
- package/dist/link-parser.js +28 -1
- package/dist/link-parser.js.map +1 -1
- package/dist/link-validator.d.ts +50 -3
- package/dist/link-validator.d.ts.map +1 -1
- package/dist/link-validator.js +72 -55
- package/dist/link-validator.js.map +1 -1
- package/dist/resource-registry.d.ts +46 -11
- package/dist/resource-registry.d.ts.map +1 -1
- package/dist/resource-registry.js +140 -34
- package/dist/resource-registry.js.map +1 -1
- package/dist/schemas/link-auth.d.ts +382 -0
- package/dist/schemas/link-auth.d.ts.map +1 -0
- package/dist/schemas/link-auth.js +124 -0
- package/dist/schemas/link-auth.js.map +1 -0
- package/dist/schemas/project-config.d.ts +642 -98
- package/dist/schemas/project-config.d.ts.map +1 -1
- package/dist/schemas/project-config.js +3 -0
- package/dist/schemas/project-config.js.map +1 -1
- package/dist/schemas/resource-metadata.d.ts +46 -10
- package/dist/schemas/resource-metadata.d.ts.map +1 -1
- package/dist/schemas/resource-metadata.js +14 -1
- package/dist/schemas/resource-metadata.js.map +1 -1
- package/package.json +4 -3
- package/src/frontmatter-link-validator.ts +5 -5
- package/src/html-link-parser.ts +140 -0
- package/src/html-transform.ts +157 -0
- package/src/index.ts +6 -1
- package/src/link-parser.ts +35 -2
- package/src/link-validator.ts +102 -82
- package/src/resource-registry.ts +157 -39
- package/src/schemas/link-auth.ts +146 -0
- package/src/schemas/project-config.ts +4 -0
- 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('&', '&');
|
|
78
|
+
return quote === '"' ? amp.replaceAll('"', '"') : amp.replaceAll("'", ''');
|
|
79
|
+
}
|
|
80
|
+
if (newValue.length > 0 && !UNQUOTED_UNSAFE.test(newValue)) {
|
|
81
|
+
return newValue.replaceAll('&', '&');
|
|
82
|
+
}
|
|
83
|
+
const escaped = newValue.replaceAll('&', '&').replaceAll('"', '"');
|
|
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';
|
package/src/link-parser.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Markdown link parser and
|
|
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
|
|
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
|
*
|
package/src/link-validator.ts
CHANGED
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
verifyCaseSensitiveFilename,
|
|
26
26
|
} from '@vibe-agent-toolkit/utils';
|
|
27
27
|
|
|
28
|
-
import type {
|
|
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
|
|
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
|
-
|
|
87
|
+
fragmentsByFile: FragmentIndex,
|
|
88
88
|
options?: ValidateLinkOptions
|
|
89
89
|
): Promise<ValidationIssue | null> {
|
|
90
90
|
switch (link.type) {
|
|
91
91
|
case 'local_file':
|
|
92
|
-
|
|
92
|
+
case 'local_directory':
|
|
93
|
+
return await validateLocalFileLink(link, sourceFilePath, fragmentsByFile, options);
|
|
93
94
|
|
|
94
95
|
case 'anchor':
|
|
95
|
-
return await validateAnchorLink(link, sourceFilePath,
|
|
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
|
-
|
|
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
|
|
267
|
-
if (
|
|
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
|
-
|
|
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
|
|
288
|
+
const check = checkAnchor(anchor, sourceFilePath, fragmentsByFile);
|
|
293
289
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
366
|
-
|
|
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
|
-
*
|
|
371
|
-
*
|
|
372
|
-
*
|
|
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
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
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
|
-
* @
|
|
379
|
-
*
|
|
380
|
-
*
|
|
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
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
|
|
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
|
}
|