@vibe-agent-toolkit/resources 0.1.0-rc.10

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 (42) hide show
  1. package/README.md +646 -0
  2. package/dist/index.d.ts +33 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +37 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/link-parser.d.ts +37 -0
  7. package/dist/link-parser.d.ts.map +1 -0
  8. package/dist/link-parser.js +327 -0
  9. package/dist/link-parser.js.map +1 -0
  10. package/dist/link-validator.d.ts +30 -0
  11. package/dist/link-validator.d.ts.map +1 -0
  12. package/dist/link-validator.js +217 -0
  13. package/dist/link-validator.js.map +1 -0
  14. package/dist/resource-registry.d.ts +278 -0
  15. package/dist/resource-registry.d.ts.map +1 -0
  16. package/dist/resource-registry.js +468 -0
  17. package/dist/resource-registry.js.map +1 -0
  18. package/dist/schemas/resource-metadata.d.ts +137 -0
  19. package/dist/schemas/resource-metadata.d.ts.map +1 -0
  20. package/dist/schemas/resource-metadata.js +61 -0
  21. package/dist/schemas/resource-metadata.js.map +1 -0
  22. package/dist/schemas/validation-result.d.ts +124 -0
  23. package/dist/schemas/validation-result.d.ts.map +1 -0
  24. package/dist/schemas/validation-result.js +47 -0
  25. package/dist/schemas/validation-result.js.map +1 -0
  26. package/dist/types.d.ts +15 -0
  27. package/dist/types.d.ts.map +1 -0
  28. package/dist/types.js +14 -0
  29. package/dist/types.js.map +1 -0
  30. package/dist/utils.d.ts +18 -0
  31. package/dist/utils.d.ts.map +1 -0
  32. package/dist/utils.js +26 -0
  33. package/dist/utils.js.map +1 -0
  34. package/package.json +60 -0
  35. package/src/index.ts +66 -0
  36. package/src/link-parser.ts +371 -0
  37. package/src/link-validator.ts +275 -0
  38. package/src/resource-registry.ts +559 -0
  39. package/src/schemas/resource-metadata.ts +86 -0
  40. package/src/schemas/validation-result.ts +55 -0
  41. package/src/types.ts +27 -0
  42. package/src/utils.ts +27 -0
@@ -0,0 +1,371 @@
1
+ /**
2
+ * Markdown link parser and analyzer.
3
+ *
4
+ * Parses markdown files to extract:
5
+ * - Links (regular, reference-style, autolinks)
6
+ * - Headings (with GitHub-style slugs and nested tree structure)
7
+ * - File size and token estimates
8
+ *
9
+ * Uses unified/remark for robust markdown parsing with GFM support.
10
+ */
11
+
12
+ import { readFile, stat } from 'node:fs/promises';
13
+
14
+ import type { Heading, Link, LinkReference, Root } from 'mdast';
15
+ import remarkFrontmatter from 'remark-frontmatter';
16
+ import remarkGfm from 'remark-gfm';
17
+ import remarkParse from 'remark-parse';
18
+ import { unified } from 'unified';
19
+ import { visit } from 'unist-util-visit';
20
+
21
+ import type { HeadingNode, LinkType, ResourceLink } from './types.js';
22
+
23
+ /**
24
+ * Result of parsing a markdown file.
25
+ */
26
+ export interface ParseResult {
27
+ links: ResourceLink[];
28
+ headings: HeadingNode[];
29
+ content: string;
30
+ sizeBytes: number;
31
+ estimatedTokenCount: number;
32
+ }
33
+
34
+ /**
35
+ * Parse a markdown file and extract all links, headings, and metadata.
36
+ *
37
+ * @param filePath - Absolute path to the markdown file
38
+ * @returns Parsed markdown data including links, headings, size, and token estimate
39
+ * @throws Error if file cannot be read or parsed
40
+ *
41
+ * @example
42
+ * ```typescript
43
+ * const result = await parseMarkdown('/path/to/document.md');
44
+ * console.log(`Found ${result.links.length} links`);
45
+ * console.log(`Document has ${result.headings.length} top-level headings`);
46
+ * ```
47
+ */
48
+ export async function parseMarkdown(filePath: string): Promise<ParseResult> {
49
+ // Read file content and stats
50
+ const [content, stats] = await Promise.all([
51
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- filePath is user-provided path parameter
52
+ readFile(filePath, 'utf-8'),
53
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- filePath is user-provided path parameter
54
+ stat(filePath),
55
+ ]);
56
+
57
+ const sizeBytes = stats.size;
58
+ const estimatedTokenCount = Math.ceil(content.length / 4);
59
+
60
+ // Parse markdown with unified/remark
61
+ const processor = unified()
62
+ .use(remarkParse)
63
+ .use(remarkGfm)
64
+ .use(remarkFrontmatter);
65
+
66
+ const tree = processor.parse(content) as Root;
67
+
68
+ // Extract links
69
+ const links = extractLinks(tree);
70
+
71
+ // Extract headings with tree structure
72
+ const headings = extractHeadings(tree);
73
+
74
+ return {
75
+ links,
76
+ headings,
77
+ content,
78
+ sizeBytes,
79
+ estimatedTokenCount,
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Extract all links from the markdown AST.
85
+ *
86
+ * Handles:
87
+ * - Regular links: [text](href)
88
+ * - Reference-style links: [text][ref]
89
+ * - Autolinks: <url>
90
+ *
91
+ * @param tree - Markdown AST from unified/remark
92
+ * @returns Array of classified links with line numbers
93
+ */
94
+ function extractLinks(tree: Root): ResourceLink[] {
95
+ const links: ResourceLink[] = [];
96
+
97
+ // Visit link nodes (regular links and autolinks)
98
+ visit(tree, 'link', (node: Link) => {
99
+ const link: ResourceLink = {
100
+ text: extractLinkText(node),
101
+ href: node.url,
102
+ type: classifyLink(node.url),
103
+ line: node.position?.start.line,
104
+ };
105
+ links.push(link);
106
+ });
107
+
108
+ // Visit linkReference nodes (reference-style links)
109
+ visit(tree, 'linkReference', (node: LinkReference) => {
110
+ // For reference-style links, we use the identifier as href
111
+ // In a full implementation, we'd resolve the definition, but for now
112
+ // we'll classify based on the identifier pattern
113
+ const href = node.identifier;
114
+ const link: ResourceLink = {
115
+ text: extractLinkText(node),
116
+ href,
117
+ type: 'unknown', // Reference links need definition resolution
118
+ line: node.position?.start.line,
119
+ };
120
+ links.push(link);
121
+ });
122
+
123
+ return links;
124
+ }
125
+
126
+ /**
127
+ * Extract text content from a link node.
128
+ *
129
+ * @param node - Link or LinkReference node
130
+ * @returns Text content of the link
131
+ */
132
+ function extractLinkText(node: Link | LinkReference): string {
133
+ return extractTextFromChildren(node.children);
134
+ }
135
+
136
+ /**
137
+ * Classify a link based on its href.
138
+ *
139
+ * @param href - The href attribute from the link
140
+ * @returns Classified link type
141
+ *
142
+ * @example
143
+ * ```typescript
144
+ * classifyLink('https://example.com') // 'external'
145
+ * classifyLink('mailto:user@example.com') // 'email'
146
+ * classifyLink('#heading') // 'anchor'
147
+ * classifyLink('./file.md') // 'local_file'
148
+ * classifyLink('./file.md#anchor') // 'local_file'
149
+ * ```
150
+ */
151
+ function classifyLink(href: string): LinkType {
152
+ if (href.startsWith('http://') || href.startsWith('https://')) {
153
+ return 'external';
154
+ }
155
+ if (href.startsWith('mailto:')) {
156
+ return 'email';
157
+ }
158
+ if (href.startsWith('#')) {
159
+ return 'anchor';
160
+ }
161
+ // Links with anchors are still local file links
162
+ if (href.includes('#')) {
163
+ return 'local_file';
164
+ }
165
+ // .md files are always local files
166
+ if (href.endsWith('.md')) {
167
+ return 'local_file';
168
+ }
169
+ // Paths that look like file paths (start with ./ or ../ or /) or have no extension
170
+ if (href.startsWith('./') || href.startsWith('../') || href.startsWith('/')) {
171
+ return 'local_file';
172
+ }
173
+ // Paths without extensions (no dot or last dot is before a slash)
174
+ const lastSlash = href.lastIndexOf('/');
175
+ const lastDot = href.lastIndexOf('.');
176
+ if (lastDot === -1 || lastDot < lastSlash) {
177
+ return 'local_file';
178
+ }
179
+ return 'unknown';
180
+ }
181
+
182
+ /**
183
+ * Extract headings from the markdown AST and build a nested tree structure.
184
+ *
185
+ * Builds a hierarchical structure where:
186
+ * - h2 nodes are children of the preceding h1
187
+ * - h3 nodes are children of the preceding h2
188
+ * - etc.
189
+ *
190
+ * @param tree - Markdown AST from unified/remark
191
+ * @returns Array of top-level heading nodes with nested children
192
+ *
193
+ * @example
194
+ * For markdown:
195
+ * ```
196
+ * # Main
197
+ * ## Sub
198
+ * ### Deep
199
+ * ## Sub2
200
+ * ```
201
+ *
202
+ * Returns:
203
+ * ```
204
+ * [
205
+ * {
206
+ * level: 1,
207
+ * text: 'Main',
208
+ * slug: 'main',
209
+ * children: [
210
+ * { level: 2, text: 'Sub', slug: 'sub', children: [
211
+ * { level: 3, text: 'Deep', slug: 'deep', children: [] }
212
+ * ]},
213
+ * { level: 2, text: 'Sub2', slug: 'sub2', children: [] }
214
+ * ]
215
+ * }
216
+ * ]
217
+ * ```
218
+ */
219
+ function extractHeadings(tree: Root): HeadingNode[] {
220
+ const flatHeadings: HeadingNode[] = [];
221
+
222
+ // First pass: collect all headings in document order
223
+ visit(tree, 'heading', (node: Heading) => {
224
+ const text = extractHeadingText(node);
225
+ const heading: HeadingNode = {
226
+ level: node.depth,
227
+ text,
228
+ slug: generateSlug(text),
229
+ line: node.position?.start.line,
230
+ };
231
+ flatHeadings.push(heading);
232
+ });
233
+
234
+ // Second pass: build tree structure using a stack
235
+ return buildHeadingTree(flatHeadings);
236
+ }
237
+
238
+ /**
239
+ * Extract text content from a heading node.
240
+ *
241
+ * @param node - Heading node
242
+ * @returns Text content of the heading
243
+ */
244
+ function extractHeadingText(node: Heading): string {
245
+ return extractTextFromChildren(node.children);
246
+ }
247
+
248
+ /**
249
+ * Extract text content from inline children nodes.
250
+ *
251
+ * Handles text nodes, inline code, emphasis, and other inline elements.
252
+ *
253
+ * @param children - Array of child nodes or undefined
254
+ * @returns Concatenated text content
255
+ */
256
+ function extractTextFromChildren(
257
+ children: Array<{ type: string; value?: unknown }> | undefined
258
+ ): string {
259
+ if (!children || children.length === 0) {
260
+ return '';
261
+ }
262
+
263
+ return children
264
+ .map((child) => {
265
+ if (child.type === 'text') {
266
+ return child.value as string;
267
+ }
268
+ // Handle other inline elements (code, emphasis, etc.)
269
+ if ('value' in child) {
270
+ return String(child.value);
271
+ }
272
+ return '';
273
+ })
274
+ .join('');
275
+ }
276
+
277
+ /**
278
+ * Generate a GitHub-style slug from heading text.
279
+ *
280
+ * Rules:
281
+ * - Convert to lowercase
282
+ * - Replace spaces with hyphens
283
+ * - Remove special characters
284
+ * - Collapse multiple hyphens
285
+ *
286
+ * @param text - Heading text
287
+ * @returns GitHub-style slug for anchor links
288
+ *
289
+ * @example
290
+ * ```typescript
291
+ * generateSlug('Hello World') // 'hello-world'
292
+ * generateSlug('Section 1.1') // 'section-11'
293
+ * generateSlug('API Reference (v2)') // 'api-reference-v2'
294
+ * ```
295
+ */
296
+ function generateSlug(text: string): string {
297
+ return text
298
+ .toLowerCase()
299
+ .trim()
300
+ .replaceAll(/[^\w\s-]/g, '') // Remove special chars
301
+ .replaceAll(/\s+/g, '-') // Replace spaces with hyphens
302
+ .replaceAll(/-+/g, '-'); // Collapse multiple hyphens
303
+ }
304
+
305
+ /**
306
+ * Build a nested heading tree from a flat list of headings.
307
+ *
308
+ * Uses a stack-based algorithm to correctly nest headings:
309
+ * - When encountering a higher-level heading, pop stack until we find the parent
310
+ * - Add the heading as a child of the top of stack
311
+ * - Push the heading onto the stack
312
+ *
313
+ * @param flatHeadings - Array of headings in document order
314
+ * @returns Array of top-level headings with nested children
315
+ */
316
+ function buildHeadingTree(flatHeadings: HeadingNode[]): HeadingNode[] {
317
+ if (flatHeadings.length === 0) {
318
+ return [];
319
+ }
320
+
321
+ const roots: HeadingNode[] = [];
322
+ const stack: HeadingNode[] = [];
323
+
324
+ for (const heading of flatHeadings) {
325
+ // Initialize children array
326
+ const headingWithChildren: HeadingNode = {
327
+ ...heading,
328
+ children: [],
329
+ };
330
+
331
+ // Pop stack until we find a heading with lower level (the parent)
332
+ while (stack.length > 0 && (stack.at(-1)?.level ?? 0) >= heading.level) {
333
+ stack.pop();
334
+ }
335
+
336
+ if (stack.length === 0) {
337
+ // This is a root-level heading
338
+ roots.push(headingWithChildren);
339
+ } else {
340
+ // Add as child of the top of stack
341
+ const parent = stack.at(-1);
342
+ if (parent) {
343
+ parent.children ??= [];
344
+ parent.children.push(headingWithChildren);
345
+ }
346
+ }
347
+
348
+ // Push current heading onto stack
349
+ stack.push(headingWithChildren);
350
+ }
351
+
352
+ // Clean up empty children arrays (convert to undefined)
353
+ cleanupEmptyChildren(roots);
354
+
355
+ return roots;
356
+ }
357
+
358
+ /**
359
+ * Remove empty children arrays from heading tree (convert to undefined).
360
+ *
361
+ * @param headings - Array of headings to clean up
362
+ */
363
+ function cleanupEmptyChildren(headings: HeadingNode[]): void {
364
+ for (const heading of headings) {
365
+ if (heading.children?.length === 0) {
366
+ heading.children = undefined;
367
+ } else if (heading.children && heading.children.length > 0) {
368
+ cleanupEmptyChildren(heading.children);
369
+ }
370
+ }
371
+ }
@@ -0,0 +1,275 @@
1
+ /**
2
+ * Link validation for markdown resources.
3
+ *
4
+ * Validates different types of links:
5
+ * - local_file: Checks if file exists, validates anchors if present
6
+ * - anchor: Validates heading exists in current or target file
7
+ * - external: Returns info (not validated)
8
+ * - email: Returns null (valid by default)
9
+ * - unknown: Returns warning
10
+ */
11
+
12
+ import fs from 'node:fs/promises';
13
+ import path from 'node:path';
14
+
15
+ import { isGitignored } from '@vibe-agent-toolkit/utils';
16
+
17
+ import type { ValidationIssue } from './schemas/validation-result.js';
18
+ import type { HeadingNode, ResourceLink } from './types.js';
19
+ import { splitHrefAnchor } from './utils.js';
20
+
21
+ /**
22
+ * Validate a single link in a markdown resource.
23
+ *
24
+ * @param link - The link to validate
25
+ * @param sourceFilePath - Absolute path to the file containing the link
26
+ * @param headingsByFile - Map of file paths to their heading trees
27
+ * @returns ValidationIssue if link is broken, null if valid
28
+ *
29
+ * @example
30
+ * ```typescript
31
+ * const issue = await validateLink(link, '/project/docs/guide.md', headingsMap);
32
+ * if (issue) {
33
+ * console.log(`${issue.severity}: ${issue.message}`);
34
+ * }
35
+ * ```
36
+ */
37
+ export async function validateLink(
38
+ link: ResourceLink,
39
+ sourceFilePath: string,
40
+ headingsByFile: Map<string, HeadingNode[]>
41
+ ): Promise<ValidationIssue | null> {
42
+ switch (link.type) {
43
+ case 'local_file':
44
+ return await validateLocalFileLink(link, sourceFilePath, headingsByFile);
45
+
46
+ case 'anchor':
47
+ return await validateAnchorLink(link, sourceFilePath, headingsByFile);
48
+
49
+ case 'external':
50
+ // External URLs are not validated - return info
51
+ return {
52
+ severity: 'info',
53
+ resourcePath: sourceFilePath,
54
+ line: link.line,
55
+ type: 'external_url',
56
+ link: link.href,
57
+ message: 'External URL not validated',
58
+ };
59
+
60
+ case 'email':
61
+ // Email links are valid by default
62
+ return null;
63
+
64
+ case 'unknown':
65
+ return {
66
+ severity: 'warning',
67
+ resourcePath: sourceFilePath,
68
+ line: link.line,
69
+ type: 'unknown_link',
70
+ link: link.href,
71
+ message: 'Unknown link type',
72
+ };
73
+
74
+ default: {
75
+ // TypeScript exhaustiveness check
76
+ const _exhaustive: never = link.type;
77
+ return _exhaustive;
78
+ }
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Validate a local file link (with optional anchor).
84
+ */
85
+ async function validateLocalFileLink(
86
+ link: ResourceLink,
87
+ sourceFilePath: string,
88
+ headingsByFile: Map<string, HeadingNode[]>
89
+ ): Promise<ValidationIssue | null> {
90
+ // Extract file path and anchor from href
91
+ const [filePath, anchor] = splitHrefAnchor(link.href);
92
+
93
+ // Validate the file exists
94
+ const fileResult = await validateLocalFile(filePath, sourceFilePath);
95
+
96
+ if (!fileResult.exists) {
97
+ return {
98
+ severity: 'error',
99
+ resourcePath: sourceFilePath,
100
+ line: link.line,
101
+ type: 'broken_file',
102
+ link: link.href,
103
+ message: `File not found: ${fileResult.resolvedPath}`,
104
+ suggestion: 'Check that the file path is correct and the file exists',
105
+ };
106
+ }
107
+
108
+ // Check if the file is gitignored
109
+ if (fileResult.isGitignored) {
110
+ return {
111
+ severity: 'error',
112
+ resourcePath: sourceFilePath,
113
+ line: link.line,
114
+ type: 'broken_file',
115
+ link: link.href,
116
+ message: `File is gitignored: ${fileResult.resolvedPath}`,
117
+ suggestion:
118
+ 'Gitignored files are local-only and will not exist in the repository. Remove this link or unignore the target file.',
119
+ };
120
+ }
121
+
122
+ // If there's an anchor, validate it too
123
+ if (anchor) {
124
+ const anchorValid = await validateAnchor(
125
+ anchor,
126
+ fileResult.resolvedPath,
127
+ headingsByFile
128
+ );
129
+
130
+ if (!anchorValid) {
131
+ return {
132
+ severity: 'error',
133
+ resourcePath: sourceFilePath,
134
+ line: link.line,
135
+ type: 'broken_anchor',
136
+ link: link.href,
137
+ message: `Anchor not found: #${anchor} in ${fileResult.resolvedPath}`,
138
+ suggestion: 'Check that the heading exists in the target file',
139
+ };
140
+ }
141
+ }
142
+
143
+ return null;
144
+ }
145
+
146
+ /**
147
+ * Validate an anchor link (within current file).
148
+ */
149
+ async function validateAnchorLink(
150
+ link: ResourceLink,
151
+ sourceFilePath: string,
152
+ headingsByFile: Map<string, HeadingNode[]>
153
+ ): Promise<ValidationIssue | null> {
154
+ // Extract anchor (strip leading #)
155
+ const anchor = link.href.startsWith('#') ? link.href.slice(1) : link.href;
156
+
157
+ // Validate anchor exists in current file
158
+ const isValid = await validateAnchor(anchor, sourceFilePath, headingsByFile);
159
+
160
+ if (!isValid) {
161
+ return {
162
+ severity: 'error',
163
+ resourcePath: sourceFilePath,
164
+ line: link.line,
165
+ type: 'broken_anchor',
166
+ link: link.href,
167
+ message: `Anchor not found: ${link.href}`,
168
+ suggestion: 'Check that the heading exists in this file',
169
+ };
170
+ }
171
+
172
+ return null;
173
+ }
174
+
175
+
176
+ /**
177
+ * Validate that a local file exists and is not gitignored.
178
+ *
179
+ * @param href - The href to the file (relative or absolute)
180
+ * @param sourceFilePath - Absolute path to the source file
181
+ * @returns Object with exists flag, resolved absolute path, and gitignored flag
182
+ *
183
+ * @example
184
+ * ```typescript
185
+ * const result = await validateLocalFile('./docs/guide.md', '/project/README.md');
186
+ * if (result.exists && !result.isGitignored) {
187
+ * console.log('File exists at:', result.resolvedPath);
188
+ * }
189
+ * ```
190
+ */
191
+ async function validateLocalFile(
192
+ href: string,
193
+ sourceFilePath: string
194
+ ): Promise<{ exists: boolean; resolvedPath: string; isGitignored: boolean }> {
195
+ // Resolve the path relative to the source file's directory
196
+ const sourceDir = path.dirname(sourceFilePath);
197
+ const resolvedPath = path.resolve(sourceDir, href);
198
+
199
+ // Check if file exists
200
+ let exists = false;
201
+ try {
202
+ await fs.access(resolvedPath, fs.constants.F_OK);
203
+ exists = true;
204
+ } catch {
205
+ exists = false;
206
+ }
207
+
208
+ // Check if file is gitignored (only if it exists)
209
+ const gitignored = exists && isGitignored(resolvedPath);
210
+
211
+ return { exists, resolvedPath, isGitignored: gitignored };
212
+ }
213
+
214
+ /**
215
+ * Validate that an anchor (heading slug) exists in a file.
216
+ *
217
+ * @param anchor - The heading slug to find (without leading #)
218
+ * @param targetFilePath - Absolute path to the file containing the heading
219
+ * @param headingsByFile - Map of file paths to their heading trees
220
+ * @returns True if anchor exists, false otherwise
221
+ *
222
+ * @example
223
+ * ```typescript
224
+ * const valid = await validateAnchor('my-heading', '/project/docs/guide.md', headingsMap);
225
+ * ```
226
+ */
227
+ async function validateAnchor(
228
+ anchor: string,
229
+ targetFilePath: string,
230
+ headingsByFile: Map<string, HeadingNode[]>
231
+ ): Promise<boolean> {
232
+ // Get headings for target file
233
+ const headings = headingsByFile.get(targetFilePath);
234
+ if (!headings) {
235
+ return false;
236
+ }
237
+
238
+ // Search for matching slug (case-insensitive)
239
+ return findHeadingBySlug(headings, anchor);
240
+ }
241
+
242
+ /**
243
+ * Recursively search heading tree for a matching slug.
244
+ *
245
+ * Performs case-insensitive comparison of slugs.
246
+ *
247
+ * @param headings - Array of heading nodes to search
248
+ * @param targetSlug - The slug to find
249
+ * @returns True if slug found, false otherwise
250
+ *
251
+ * @example
252
+ * ```typescript
253
+ * const found = findHeadingBySlug(headings, 'my-heading');
254
+ * ```
255
+ */
256
+ function findHeadingBySlug(
257
+ headings: HeadingNode[],
258
+ targetSlug: string
259
+ ): boolean {
260
+ const normalizedTarget = targetSlug.toLowerCase();
261
+
262
+ for (const heading of headings) {
263
+ // Check current heading
264
+ if (heading.slug.toLowerCase() === normalizedTarget) {
265
+ return true;
266
+ }
267
+
268
+ // Recursively check children
269
+ if (heading.children && findHeadingBySlug(heading.children, targetSlug)) {
270
+ return true;
271
+ }
272
+ }
273
+
274
+ return false;
275
+ }