@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
package/src/resource-registry.ts
CHANGED
|
@@ -22,8 +22,9 @@ import {
|
|
|
22
22
|
type FrontmatterExternalUrl,
|
|
23
23
|
} from './frontmatter-link-validator.js';
|
|
24
24
|
import { validateFrontmatter } from './frontmatter-validator.js';
|
|
25
|
+
import { parseHtml } from './html-link-parser.js';
|
|
25
26
|
import { parseMarkdown } from './link-parser.js';
|
|
26
|
-
import { validateLink, type ValidateLinkOptions } from './link-validator.js';
|
|
27
|
+
import { fragmentIndexEntry, validateLink, type FragmentIndex, type ValidateLinkOptions } from './link-validator.js';
|
|
27
28
|
import type { ResourceCollectionInterface } from './resource-collection-interface.js';
|
|
28
29
|
import type { SHA256 } from './schemas/checksum.js';
|
|
29
30
|
import type { ProjectConfig } from './schemas/project-config.js';
|
|
@@ -31,6 +32,27 @@ import type { HeadingNode, ResourceMetadata } from './schemas/resource-metadata.
|
|
|
31
32
|
import type { ValidationResult } from './schemas/validation-result.js';
|
|
32
33
|
import { issueLocation, matchesGlobPattern, splitHrefAnchor } from './utils.js';
|
|
33
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Typed error thrown when two resources produce the same ID.
|
|
37
|
+
*
|
|
38
|
+
* Carries the authoritative id and both paths so callers can record accurate
|
|
39
|
+
* issue data without re-deriving the id from the file path (which would be
|
|
40
|
+
* wrong when the id came from a frontmatter `idField` value).
|
|
41
|
+
*/
|
|
42
|
+
export class DuplicateResourceIdError extends Error {
|
|
43
|
+
readonly id: string;
|
|
44
|
+
readonly existingPath: string;
|
|
45
|
+
readonly conflictingPath: string;
|
|
46
|
+
|
|
47
|
+
constructor(id: string, conflictingPath: string, existingPath: string) {
|
|
48
|
+
super(`Duplicate resource ID '${id}': '${conflictingPath}' conflicts with '${existingPath}'`);
|
|
49
|
+
this.name = 'DuplicateResourceIdError';
|
|
50
|
+
this.id = id;
|
|
51
|
+
this.existingPath = existingPath;
|
|
52
|
+
this.conflictingPath = conflictingPath;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
34
56
|
/**
|
|
35
57
|
* Options for crawling directories to add resources.
|
|
36
58
|
*/
|
|
@@ -169,6 +191,12 @@ export class ResourceRegistry implements ResourceCollectionInterface {
|
|
|
169
191
|
*/
|
|
170
192
|
private readonly frontmatterExternalUrlsByResource: Map<string, FrontmatterExternalUrl[]> = new Map();
|
|
171
193
|
|
|
194
|
+
/**
|
|
195
|
+
* Collisions recorded by addResources() when two files produce the same resource id.
|
|
196
|
+
* Cleared by clear(). Surfaced as DUPLICATE_RESOURCE_ID issues in validate().
|
|
197
|
+
*/
|
|
198
|
+
private duplicateIdCollisions: Array<{ id: string; existingPath: string; conflictingPath: string }> = [];
|
|
199
|
+
|
|
172
200
|
constructor(options?: ResourceRegistryOptions) {
|
|
173
201
|
if (options?.baseDir !== undefined) {
|
|
174
202
|
this.baseDir = options.baseDir;
|
|
@@ -233,9 +261,7 @@ export class ResourceRegistry implements ResourceCollectionInterface {
|
|
|
233
261
|
// Check for duplicate ID
|
|
234
262
|
const existingById = registry.resourcesById.get(resource.id);
|
|
235
263
|
if (existingById) {
|
|
236
|
-
throw new
|
|
237
|
-
`Duplicate resource ID '${resource.id}': '${resource.filePath}' conflicts with '${existingById.filePath}'`
|
|
238
|
-
);
|
|
264
|
+
throw new DuplicateResourceIdError(resource.id, resource.filePath, existingById.filePath);
|
|
239
265
|
}
|
|
240
266
|
|
|
241
267
|
// Add to path index
|
|
@@ -304,8 +330,12 @@ export class ResourceRegistry implements ResourceCollectionInterface {
|
|
|
304
330
|
// Normalize path to absolute
|
|
305
331
|
const absolutePath = safePath.resolve(filePath);
|
|
306
332
|
|
|
307
|
-
// Parse the
|
|
308
|
-
const
|
|
333
|
+
// Parse the file — HTML or markdown depending on extension
|
|
334
|
+
const lowerPath = absolutePath.toLowerCase();
|
|
335
|
+
const isHtml = lowerPath.endsWith('.html') || lowerPath.endsWith('.htm');
|
|
336
|
+
const parseResult = isHtml
|
|
337
|
+
? await parseHtml(absolutePath)
|
|
338
|
+
: await parseMarkdown(absolutePath);
|
|
309
339
|
|
|
310
340
|
// Generate ID using priority chain: frontmatter field → relative path → filename stem
|
|
311
341
|
const id = this.generateId(absolutePath, parseResult.frontmatter);
|
|
@@ -313,9 +343,7 @@ export class ResourceRegistry implements ResourceCollectionInterface {
|
|
|
313
343
|
// Check for duplicate ID (allow re-adding same file path)
|
|
314
344
|
const existingById = this.resourcesById.get(id);
|
|
315
345
|
if (existingById && existingById.filePath !== absolutePath) {
|
|
316
|
-
throw new
|
|
317
|
-
`Duplicate resource ID '${id}': '${absolutePath}' conflicts with '${existingById.filePath}'`
|
|
318
|
-
);
|
|
346
|
+
throw new DuplicateResourceIdError(id, absolutePath, existingById.filePath);
|
|
319
347
|
}
|
|
320
348
|
|
|
321
349
|
// Get file modified time
|
|
@@ -336,6 +364,8 @@ export class ResourceRegistry implements ResourceCollectionInterface {
|
|
|
336
364
|
filePath: absolutePath,
|
|
337
365
|
links: parseResult.links,
|
|
338
366
|
headings: parseResult.headings,
|
|
367
|
+
...(parseResult.anchors !== undefined && { anchors: parseResult.anchors }),
|
|
368
|
+
...(parseResult.parseErrors !== undefined && { parseErrors: parseResult.parseErrors }),
|
|
339
369
|
...(parseResult.frontmatter !== undefined && { frontmatter: parseResult.frontmatter }),
|
|
340
370
|
...(parseResult.frontmatterError !== undefined && { frontmatterError: parseResult.frontmatterError }),
|
|
341
371
|
sizeBytes: parseResult.sizeBytes,
|
|
@@ -372,7 +402,20 @@ export class ResourceRegistry implements ResourceCollectionInterface {
|
|
|
372
402
|
async addResources(filePaths: string[]): Promise<ResourceMetadata[]> {
|
|
373
403
|
const results: ResourceMetadata[] = [];
|
|
374
404
|
for (const fp of filePaths) {
|
|
375
|
-
|
|
405
|
+
try {
|
|
406
|
+
results.push(await this.addResource(fp));
|
|
407
|
+
} catch (error) {
|
|
408
|
+
if (error instanceof DuplicateResourceIdError) {
|
|
409
|
+
this.duplicateIdCollisions.push({
|
|
410
|
+
id: error.id,
|
|
411
|
+
existingPath: error.existingPath,
|
|
412
|
+
conflictingPath: error.conflictingPath,
|
|
413
|
+
});
|
|
414
|
+
// First-added wins; skip conflicting file and continue crawling.
|
|
415
|
+
} else {
|
|
416
|
+
throw error;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
376
419
|
}
|
|
377
420
|
return results;
|
|
378
421
|
}
|
|
@@ -396,7 +439,7 @@ export class ResourceRegistry implements ResourceCollectionInterface {
|
|
|
396
439
|
async crawl(options: CrawlOptions): Promise<ResourceMetadata[]> {
|
|
397
440
|
const {
|
|
398
441
|
baseDir,
|
|
399
|
-
include = ['**/*.md'],
|
|
442
|
+
include = ['**/*.md', '**/*.html', '**/*.htm'],
|
|
400
443
|
exclude = ['**/node_modules/**', '**/.git/**', '**/dist/**'],
|
|
401
444
|
followSymlinks = false,
|
|
402
445
|
} = options;
|
|
@@ -442,12 +485,48 @@ export class ResourceRegistry implements ResourceCollectionInterface {
|
|
|
442
485
|
return issues;
|
|
443
486
|
}
|
|
444
487
|
|
|
488
|
+
/**
|
|
489
|
+
* Emit MALFORMED_HTML issues from each resource's HTML parse errors.
|
|
490
|
+
* @private
|
|
491
|
+
*/
|
|
492
|
+
private collectHtmlParseErrors(): ValidationIssue[] {
|
|
493
|
+
const issues: ValidationIssue[] = [];
|
|
494
|
+
for (const resource of this.resourcesByPath.values()) {
|
|
495
|
+
for (const parseError of resource.parseErrors ?? []) {
|
|
496
|
+
issues.push(
|
|
497
|
+
createRegistryIssue(
|
|
498
|
+
'MALFORMED_HTML',
|
|
499
|
+
`Malformed HTML: ${parseError.message}`,
|
|
500
|
+
{
|
|
501
|
+
location: issueLocation(resource.filePath, this.baseDir),
|
|
502
|
+
...(parseError.line !== undefined && { line: parseError.line }),
|
|
503
|
+
},
|
|
504
|
+
),
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return issues;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Emit DUPLICATE_RESOURCE_ID errors for collisions recorded by addResources().
|
|
513
|
+
* @private
|
|
514
|
+
*/
|
|
515
|
+
private collectDuplicateIdErrors(): ValidationIssue[] {
|
|
516
|
+
return this.duplicateIdCollisions.map(({ id, existingPath, conflictingPath }) =>
|
|
517
|
+
createRegistryIssue(
|
|
518
|
+
'DUPLICATE_RESOURCE_ID',
|
|
519
|
+
`Two files resolve to the same resource id '${id}': '${issueLocation(existingPath, this.baseDir)}' and '${issueLocation(conflictingPath, this.baseDir)}'. Rename one of the files so they produce distinct resource ids.`,
|
|
520
|
+
),
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
|
|
445
524
|
/**
|
|
446
525
|
* Validate all links in all resources.
|
|
447
526
|
* @private
|
|
448
527
|
*/
|
|
449
528
|
private async validateAllLinks(
|
|
450
|
-
|
|
529
|
+
fragmentsByFile: FragmentIndex,
|
|
451
530
|
skipGitIgnoreCheck: boolean
|
|
452
531
|
): Promise<ValidationIssue[]> {
|
|
453
532
|
const issues: ValidationIssue[] = [];
|
|
@@ -463,7 +542,7 @@ export class ResourceRegistry implements ResourceCollectionInterface {
|
|
|
463
542
|
...(this.gitTracker !== undefined && { gitTracker: this.gitTracker })
|
|
464
543
|
};
|
|
465
544
|
|
|
466
|
-
const issue = await validateLink(link, resource.filePath,
|
|
545
|
+
const issue = await validateLink(link, resource.filePath, fragmentsByFile, validateOptions);
|
|
467
546
|
if (issue) {
|
|
468
547
|
issues.push(issue);
|
|
469
548
|
}
|
|
@@ -501,7 +580,7 @@ export class ResourceRegistry implements ResourceCollectionInterface {
|
|
|
501
580
|
* @private
|
|
502
581
|
*/
|
|
503
582
|
private async validateCollectionFrontmatter(
|
|
504
|
-
|
|
583
|
+
fragmentsByFile: FragmentIndex,
|
|
505
584
|
skipGitIgnoreCheck: boolean,
|
|
506
585
|
): Promise<ValidationIssue[]> {
|
|
507
586
|
const issues: ValidationIssue[] = [];
|
|
@@ -523,7 +602,7 @@ export class ResourceRegistry implements ResourceCollectionInterface {
|
|
|
523
602
|
const collectionIssues = await this.validateResourceCollectionSchemas(
|
|
524
603
|
resource,
|
|
525
604
|
fsPromises,
|
|
526
|
-
|
|
605
|
+
fragmentsByFile,
|
|
527
606
|
skipGitIgnoreCheck,
|
|
528
607
|
);
|
|
529
608
|
issues.push(...collectionIssues);
|
|
@@ -539,7 +618,7 @@ export class ResourceRegistry implements ResourceCollectionInterface {
|
|
|
539
618
|
private async validateResourceCollectionSchemas(
|
|
540
619
|
resource: ResourceMetadata,
|
|
541
620
|
fsModule: typeof fs,
|
|
542
|
-
|
|
621
|
+
fragmentsByFile: FragmentIndex,
|
|
543
622
|
skipGitIgnoreCheck: boolean,
|
|
544
623
|
): Promise<ValidationIssue[]> {
|
|
545
624
|
const issues: ValidationIssue[] = [];
|
|
@@ -560,7 +639,7 @@ export class ResourceRegistry implements ResourceCollectionInterface {
|
|
|
560
639
|
resource,
|
|
561
640
|
collection.validation,
|
|
562
641
|
fsModule,
|
|
563
|
-
|
|
642
|
+
fragmentsByFile,
|
|
564
643
|
skipGitIgnoreCheck,
|
|
565
644
|
);
|
|
566
645
|
issues.push(...collectionIssues);
|
|
@@ -577,7 +656,7 @@ export class ResourceRegistry implements ResourceCollectionInterface {
|
|
|
577
656
|
resource: ResourceMetadata,
|
|
578
657
|
validation: NonNullable<NonNullable<ProjectConfig['resources']>['collections']>[string]['validation'],
|
|
579
658
|
fsModule: typeof fs,
|
|
580
|
-
|
|
659
|
+
fragmentsByFile: FragmentIndex,
|
|
581
660
|
skipGitIgnoreCheck: boolean,
|
|
582
661
|
): Promise<ValidationIssue[]> {
|
|
583
662
|
if (!validation?.frontmatterSchema) {
|
|
@@ -620,7 +699,7 @@ export class ResourceRegistry implements ResourceCollectionInterface {
|
|
|
620
699
|
resource.frontmatter,
|
|
621
700
|
schema,
|
|
622
701
|
resource.filePath,
|
|
623
|
-
|
|
702
|
+
fragmentsByFile,
|
|
624
703
|
linkOptions,
|
|
625
704
|
);
|
|
626
705
|
issues.push(...linkIssues);
|
|
@@ -679,8 +758,8 @@ export class ResourceRegistry implements ResourceCollectionInterface {
|
|
|
679
758
|
async validate(options?: ValidateOptions): Promise<ValidationResult> {
|
|
680
759
|
const startTime = Date.now();
|
|
681
760
|
|
|
682
|
-
// Build
|
|
683
|
-
const
|
|
761
|
+
// Build fragment index for anchor validation
|
|
762
|
+
const fragmentsByFile = this.buildFragmentIndex();
|
|
684
763
|
|
|
685
764
|
// Reset frontmatter external URL state for this validation run
|
|
686
765
|
this.frontmatterExternalUrlsByResource.clear();
|
|
@@ -688,19 +767,21 @@ export class ResourceRegistry implements ResourceCollectionInterface {
|
|
|
688
767
|
// Collect all validation issues
|
|
689
768
|
const issues: ValidationIssue[] = [];
|
|
690
769
|
|
|
691
|
-
//
|
|
692
|
-
|
|
770
|
+
// Surface parse-time diagnostics: YAML frontmatter errors first, then HTML
|
|
771
|
+
// well-formedness, then duplicate-id collisions. Combined into one push() call
|
|
772
|
+
// (SonarCloud S7778).
|
|
773
|
+
issues.push(...this.collectYamlErrors(), ...this.collectHtmlParseErrors(), ...this.collectDuplicateIdErrors());
|
|
693
774
|
|
|
694
775
|
// Validate each link in each resource
|
|
695
776
|
const linkIssues = await this.validateAllLinks(
|
|
696
|
-
|
|
777
|
+
fragmentsByFile,
|
|
697
778
|
options?.skipGitIgnoreCheck ?? false
|
|
698
779
|
);
|
|
699
780
|
issues.push(...linkIssues);
|
|
700
781
|
|
|
701
782
|
// Per-collection frontmatter validation
|
|
702
783
|
const collectionFrontmatterIssues = await this.validateCollectionFrontmatter(
|
|
703
|
-
|
|
784
|
+
fragmentsByFile,
|
|
704
785
|
options?.skipGitIgnoreCheck ?? false,
|
|
705
786
|
);
|
|
706
787
|
issues.push(...collectionFrontmatterIssues);
|
|
@@ -1044,6 +1125,7 @@ export class ResourceRegistry implements ResourceCollectionInterface {
|
|
|
1044
1125
|
this.resourcesById.clear();
|
|
1045
1126
|
this.resourcesByName.clear();
|
|
1046
1127
|
this.resourcesByChecksum.clear();
|
|
1128
|
+
this.duplicateIdCollisions = [];
|
|
1047
1129
|
}
|
|
1048
1130
|
|
|
1049
1131
|
/**
|
|
@@ -1253,14 +1335,20 @@ export class ResourceRegistry implements ResourceCollectionInterface {
|
|
|
1253
1335
|
}
|
|
1254
1336
|
|
|
1255
1337
|
/**
|
|
1256
|
-
* Build a
|
|
1257
|
-
*
|
|
1258
|
-
*
|
|
1338
|
+
* Build a format-neutral fragment index for anchor validation: each file's
|
|
1339
|
+
* absolute path → the set of valid fragment targets. Markdown contributes
|
|
1340
|
+
* heading slugs (lowercased); HTML contributes its `id`/`name` anchors.
|
|
1259
1341
|
*/
|
|
1260
|
-
private
|
|
1261
|
-
const map = new Map
|
|
1342
|
+
private buildFragmentIndex(): FragmentIndex {
|
|
1343
|
+
const map: FragmentIndex = new Map();
|
|
1262
1344
|
for (const resource of this.resourcesByPath.values()) {
|
|
1263
|
-
|
|
1345
|
+
const fragments = new Set<string>();
|
|
1346
|
+
collectHeadingSlugs(resource.headings, fragments);
|
|
1347
|
+
for (const anchor of resource.anchors ?? []) {
|
|
1348
|
+
fragments.add(anchor);
|
|
1349
|
+
}
|
|
1350
|
+
// Policy (case-sensitive ids vs folded slugs) is derived from the file type.
|
|
1351
|
+
map.set(resource.filePath, fragmentIndexEntry(resource.filePath, fragments));
|
|
1264
1352
|
}
|
|
1265
1353
|
return map;
|
|
1266
1354
|
}
|
|
@@ -1318,29 +1406,47 @@ export class ResourceRegistry implements ResourceCollectionInterface {
|
|
|
1318
1406
|
}
|
|
1319
1407
|
}
|
|
1320
1408
|
|
|
1409
|
+
/** Recursively collect lowercased heading slugs into `out`. */
|
|
1410
|
+
function collectHeadingSlugs(headings: HeadingNode[], out: Set<string>): void {
|
|
1411
|
+
for (const heading of headings) {
|
|
1412
|
+
out.add(heading.slug.toLowerCase());
|
|
1413
|
+
if (heading.children) {
|
|
1414
|
+
collectHeadingSlugs(heading.children, out);
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1321
1419
|
/**
|
|
1322
1420
|
* Generate an ID from a file path.
|
|
1323
1421
|
*
|
|
1422
|
+
* Every resource id includes a `-<ext>` suffix derived from the file extension
|
|
1423
|
+
* (dot stripped, lowercased). This makes ids from different file types distinct
|
|
1424
|
+
* even when the stem is identical (e.g. `foo.md` → `foo-md`, `foo.html` → `foo-html`).
|
|
1425
|
+
* Extensionless files (e.g. `Makefile`) receive no suffix.
|
|
1426
|
+
*
|
|
1324
1427
|
* When `baseDir` is provided, computes a relative path from baseDir and uses the full
|
|
1325
1428
|
* directory structure in the ID. When no `baseDir`, uses the filename stem only.
|
|
1326
1429
|
*
|
|
1327
1430
|
* @param filePath - Absolute file path
|
|
1328
1431
|
* @param baseDir - Base directory for relative path computation (optional)
|
|
1329
|
-
* @returns Generated ID in kebab-case
|
|
1432
|
+
* @returns Generated ID in kebab-case with `-<ext>` suffix
|
|
1330
1433
|
*
|
|
1331
1434
|
* @example
|
|
1332
1435
|
* ```typescript
|
|
1333
|
-
* // Without baseDir: filename stem
|
|
1334
|
-
* generateIdFromPath('/project/docs/User Guide.md') // 'user-guide'
|
|
1335
|
-
* generateIdFromPath('/project/README.md') // 'readme'
|
|
1436
|
+
* // Without baseDir: filename stem + extension suffix
|
|
1437
|
+
* generateIdFromPath('/project/docs/User Guide.md') // 'user-guide-md'
|
|
1438
|
+
* generateIdFromPath('/project/README.md') // 'readme-md'
|
|
1439
|
+
* generateIdFromPath('/project/page.html') // 'page-html'
|
|
1440
|
+
* generateIdFromPath('/project/Makefile') // 'makefile'
|
|
1336
1441
|
*
|
|
1337
|
-
* // With baseDir: relative path
|
|
1338
|
-
* generateIdFromPath('/project/docs/concepts/core/overview.md', '/project/docs') // 'concepts-core-overview'
|
|
1339
|
-
* generateIdFromPath('/project/docs/guide.md', '/project/docs') // 'guide'
|
|
1442
|
+
* // With baseDir: relative path + extension suffix
|
|
1443
|
+
* generateIdFromPath('/project/docs/concepts/core/overview.md', '/project/docs') // 'concepts-core-overview-md'
|
|
1444
|
+
* generateIdFromPath('/project/docs/guide.md', '/project/docs') // 'guide-md'
|
|
1340
1445
|
* ```
|
|
1341
1446
|
*/
|
|
1342
1447
|
export function generateIdFromPath(filePath: string, baseDir?: string): string {
|
|
1343
1448
|
let rawId: string;
|
|
1449
|
+
let extSuffix = '';
|
|
1344
1450
|
|
|
1345
1451
|
if (baseDir) {
|
|
1346
1452
|
// Compute relative path from baseDir, remove extension
|
|
@@ -1349,11 +1455,23 @@ export function generateIdFromPath(filePath: string, baseDir?: string): string {
|
|
|
1349
1455
|
const withoutExt = ext ? relativePath.slice(0, -ext.length) : relativePath;
|
|
1350
1456
|
// Normalize path separators to forward slashes (cross-platform), then replace with hyphens
|
|
1351
1457
|
rawId = toForwardSlash(withoutExt).replaceAll('/', '-');
|
|
1458
|
+
if (ext) {
|
|
1459
|
+
// Strip the leading dot; lowercasing happens in the kebab pipeline below
|
|
1460
|
+
extSuffix = `-${ext.slice(1)}`;
|
|
1461
|
+
}
|
|
1352
1462
|
} else {
|
|
1353
1463
|
// Fallback: basename only (no directory context)
|
|
1354
|
-
|
|
1464
|
+
const ext = path.extname(filePath);
|
|
1465
|
+
rawId = path.basename(filePath, ext);
|
|
1466
|
+
if (ext) {
|
|
1467
|
+
extSuffix = `-${ext.slice(1)}`;
|
|
1468
|
+
}
|
|
1355
1469
|
}
|
|
1356
1470
|
|
|
1471
|
+
// Append extension suffix before the kebab pipeline so hyphen-collapse and
|
|
1472
|
+
// trim apply uniformly (e.g. suffixed-.md → 'suffixed--md' → 'suffixed-md')
|
|
1473
|
+
rawId = `${rawId}${extSuffix}`;
|
|
1474
|
+
|
|
1357
1475
|
// Convert to kebab-case:
|
|
1358
1476
|
// 1. Replace underscores and spaces with hyphens
|
|
1359
1477
|
// 2. Convert to lowercase
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zod schema for `resources.linkAuth` config.
|
|
3
|
+
*
|
|
4
|
+
* Validates the shape of an adopter's linkAuth configuration: providers
|
|
5
|
+
* (each either a `use: <macro>` reference or a full inline provider) plus
|
|
6
|
+
* an optional cache config. Strict — typos in provider field names are
|
|
7
|
+
* errors, per Postel's law ("writing our output → conservative").
|
|
8
|
+
*
|
|
9
|
+
* Provider entries accept EITHER:
|
|
10
|
+
* - `{ use: <name>, ...overrides }` — reference a shipped macro by name;
|
|
11
|
+
* override fields are deep-merged on top at expansion time. Overrides
|
|
12
|
+
* are NOT strictly typed at this layer because they must match the
|
|
13
|
+
* macro's shape, which we cannot see until expansion. A typo in an
|
|
14
|
+
* override silently no-ops; the full provider shape is validated
|
|
15
|
+
* after expansion (slice 2 wires that).
|
|
16
|
+
* - A full inline `{ match, rewrite, auth, token, check }` — every field
|
|
17
|
+
* required, every nested object strict.
|
|
18
|
+
*
|
|
19
|
+
* Mirrors the runtime types in `@vibe-agent-toolkit/utils`'s `link-auth/`
|
|
20
|
+
* module. Keep these aligned: a config that parses here must satisfy the
|
|
21
|
+
* `Provider` / `LinkAuthConfig` interfaces over there.
|
|
22
|
+
*
|
|
23
|
+
* Per design issue #113 §4 (vocabulary) and §5 (macros).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { z } from 'zod';
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Leaf types
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
export const ProviderMatchSchema = z
|
|
33
|
+
.object({
|
|
34
|
+
host: z.string().min(1).describe('Glob matched against URL hostname'),
|
|
35
|
+
excludeHost: z
|
|
36
|
+
.array(z.string().min(1))
|
|
37
|
+
.optional()
|
|
38
|
+
.describe('Globs that, if any match the hostname, exclude this provider'),
|
|
39
|
+
})
|
|
40
|
+
.strict()
|
|
41
|
+
.describe('Provider selection — match.host + optional excludeHost globs');
|
|
42
|
+
|
|
43
|
+
export const RewriteRuleSchema = z
|
|
44
|
+
.object({
|
|
45
|
+
when: z.string().min(1).describe('Regex source (RFC-style, JavaScript-compatible) matched against the URL'),
|
|
46
|
+
vars: z
|
|
47
|
+
.record(z.string(), z.string())
|
|
48
|
+
.optional()
|
|
49
|
+
.describe('Computed variables, each a template that references captures from `when`'),
|
|
50
|
+
to: z.string().min(1).describe('URL template — interpolates ${capture} and ${var}'),
|
|
51
|
+
})
|
|
52
|
+
.strict()
|
|
53
|
+
.describe('A single rewrite rule (one entry in a provider\'s ordered rewrite list)');
|
|
54
|
+
|
|
55
|
+
export const ProviderAuthSchema = z
|
|
56
|
+
.object({
|
|
57
|
+
headers: z
|
|
58
|
+
.record(z.string(), z.string())
|
|
59
|
+
.describe('Header name → value template. Values interpolate ${token} and any capture/var.'),
|
|
60
|
+
})
|
|
61
|
+
.strict()
|
|
62
|
+
.describe('Auth headers attached to the authenticated fetch');
|
|
63
|
+
|
|
64
|
+
export const TokenSourceSchema = z
|
|
65
|
+
.union([
|
|
66
|
+
z.object({ env: z.string().min(1) }).strict(),
|
|
67
|
+
z
|
|
68
|
+
.object({
|
|
69
|
+
command: z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]),
|
|
70
|
+
})
|
|
71
|
+
.strict(),
|
|
72
|
+
])
|
|
73
|
+
.describe('A single token source — env var, or argv command (preferred), or convenience string');
|
|
74
|
+
|
|
75
|
+
export const ProviderCheckSchema = z
|
|
76
|
+
.object({
|
|
77
|
+
method: z.enum(['GET', 'HEAD']),
|
|
78
|
+
aliveStatus: z
|
|
79
|
+
.array(z.number().int().min(100).max(599))
|
|
80
|
+
.min(1)
|
|
81
|
+
.describe('HTTP status codes meaning the URL is alive (per host semantics)'),
|
|
82
|
+
notFoundMeaning: z
|
|
83
|
+
.enum(['ambiguous', 'dead'])
|
|
84
|
+
.describe(
|
|
85
|
+
'Per-host: does 404 mean "dead" (honest 404 host like Graph) or "ambiguous" (masking host like GitHub)?',
|
|
86
|
+
),
|
|
87
|
+
})
|
|
88
|
+
.strict()
|
|
89
|
+
.describe('Status-to-outcome classifier (consumed by the resources layer, not the pure engine)');
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Provider entry (inline OR macro reference)
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
const InlineProviderSchema = z
|
|
96
|
+
.object({
|
|
97
|
+
match: ProviderMatchSchema,
|
|
98
|
+
rewrite: z.array(RewriteRuleSchema).min(1).describe('Ordered rewrite rules — first matching wins'),
|
|
99
|
+
auth: ProviderAuthSchema,
|
|
100
|
+
token: z.array(TokenSourceSchema).describe('Ordered token sources — first non-empty wins'),
|
|
101
|
+
check: ProviderCheckSchema,
|
|
102
|
+
})
|
|
103
|
+
.strict();
|
|
104
|
+
|
|
105
|
+
const MacroProviderSchema = z
|
|
106
|
+
.object({
|
|
107
|
+
use: z.string().min(1).describe('Macro name (e.g. "github", "sharepoint")'),
|
|
108
|
+
})
|
|
109
|
+
.passthrough()
|
|
110
|
+
.describe(
|
|
111
|
+
'Reference a shipped macro by name. Override fields (match/rewrite/auth/token/check) deep-merge on top at expansion time.',
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
export const ProviderEntrySchema = z
|
|
115
|
+
.union([MacroProviderSchema, InlineProviderSchema])
|
|
116
|
+
.describe('A provider entry — either { use: <macro>, ...overrides } or a full inline provider');
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Top-level linkAuth config
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
export const LinkAuthCacheSchema = z
|
|
123
|
+
.object({
|
|
124
|
+
ttlMinutes: z
|
|
125
|
+
.number()
|
|
126
|
+
.int()
|
|
127
|
+
.positive()
|
|
128
|
+
.optional()
|
|
129
|
+
.describe('Content cache TTL in minutes (default 30 — used by slice 3 content-fetch)'),
|
|
130
|
+
})
|
|
131
|
+
.strict();
|
|
132
|
+
|
|
133
|
+
export const LinkAuthConfigSchema = z
|
|
134
|
+
.object({
|
|
135
|
+
cache: LinkAuthCacheSchema.optional(),
|
|
136
|
+
providers: z
|
|
137
|
+
.array(ProviderEntrySchema)
|
|
138
|
+
.describe('Ordered list of providers — first claiming-by-host wins'),
|
|
139
|
+
})
|
|
140
|
+
.strict()
|
|
141
|
+
.describe('`resources.linkAuth` — authenticated external link resolution config');
|
|
142
|
+
|
|
143
|
+
export type LinkAuthConfig = z.infer<typeof LinkAuthConfigSchema>;
|
|
144
|
+
export type ProviderEntry = z.infer<typeof ProviderEntrySchema>;
|
|
145
|
+
export type RewriteRule = z.infer<typeof RewriteRuleSchema>;
|
|
146
|
+
export type TokenSource = z.infer<typeof TokenSourceSchema>;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { ValidationConfigSchema } from '@vibe-agent-toolkit/agent-schema';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
|
|
4
|
+
import { LinkAuthConfigSchema } from './link-auth.js';
|
|
5
|
+
|
|
4
6
|
/**
|
|
5
7
|
* Official semver regex from https://semver.org/ (anchored).
|
|
6
8
|
*
|
|
@@ -98,6 +100,8 @@ export const ResourcesConfigSchema = z.object({
|
|
|
98
100
|
.describe('Named collections of resources'),
|
|
99
101
|
validation: ValidationConfigSchema.optional()
|
|
100
102
|
.describe('Validation framework config: severity overrides and per-code allow entries (applied inside ResourceRegistry.validate)'),
|
|
103
|
+
linkAuth: LinkAuthConfigSchema.optional()
|
|
104
|
+
.describe('Authenticated external link resolution config (issue #113 / link-auth engine)'),
|
|
101
105
|
}).describe('Resources section of project configuration');
|
|
102
106
|
|
|
103
107
|
export type ResourcesConfig = z.infer<typeof ResourcesConfigSchema>;
|
|
@@ -6,6 +6,7 @@ import { SHA256Schema } from './checksum.js';
|
|
|
6
6
|
* Type of link found in markdown resources.
|
|
7
7
|
*
|
|
8
8
|
* - `local_file`: Link to a local file (relative or absolute path)
|
|
9
|
+
* - `local_directory`: Link to a local directory, e.g. a ref ending in `/`
|
|
9
10
|
* - `anchor`: Link to a heading anchor (e.g., #heading-slug)
|
|
10
11
|
* - `external`: HTTP/HTTPS URL to external resource
|
|
11
12
|
* - `email`: Mailto link
|
|
@@ -13,6 +14,7 @@ import { SHA256Schema } from './checksum.js';
|
|
|
13
14
|
*/
|
|
14
15
|
export const LinkTypeSchema = z.enum([
|
|
15
16
|
'local_file',
|
|
17
|
+
'local_directory',
|
|
16
18
|
'anchor',
|
|
17
19
|
'external',
|
|
18
20
|
'email',
|
|
@@ -36,6 +38,16 @@ export const LinkNodeTypeSchema = z.enum([
|
|
|
36
38
|
|
|
37
39
|
export type LinkNodeType = z.infer<typeof LinkNodeTypeSchema>;
|
|
38
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Zod schema for an HTML well-formedness diagnostic.
|
|
43
|
+
*/
|
|
44
|
+
export const HtmlParseErrorSchema = z.object({
|
|
45
|
+
message: z.string().describe('parse5 error code (e.g. "missing-end-tag")'),
|
|
46
|
+
line: z.number().int().positive().optional().describe('1-based source line, when known'),
|
|
47
|
+
}).describe('HTML well-formedness diagnostic');
|
|
48
|
+
|
|
49
|
+
export type HtmlParseError = z.infer<typeof HtmlParseErrorSchema>;
|
|
50
|
+
|
|
39
51
|
/**
|
|
40
52
|
* Represents a heading node in the document's table of contents.
|
|
41
53
|
*
|
|
@@ -96,6 +108,10 @@ export const ResourceMetadataSchema = z.object({
|
|
|
96
108
|
filePath: z.string().describe('Absolute path to the resource file'),
|
|
97
109
|
links: z.array(ResourceLinkSchema).describe('All links found in the resource'),
|
|
98
110
|
headings: z.array(HeadingNodeSchema).describe('Document table of contents (top-level headings only; children are nested)'),
|
|
111
|
+
anchors: z.array(z.string()).optional()
|
|
112
|
+
.describe('Fragment targets for anchor validation (HTML id/name attributes)'),
|
|
113
|
+
parseErrors: z.array(HtmlParseErrorSchema).optional()
|
|
114
|
+
.describe('HTML well-formedness diagnostics (populated for HTML resources only)'),
|
|
99
115
|
frontmatter: z.record(z.string(), z.unknown()).optional()
|
|
100
116
|
.describe('Parsed YAML frontmatter (if present in markdown file)'),
|
|
101
117
|
frontmatterError: z.string().optional()
|
|
@@ -106,6 +122,6 @@ export const ResourceMetadataSchema = z.object({
|
|
|
106
122
|
checksum: SHA256Schema.describe('SHA-256 checksum of file content'),
|
|
107
123
|
collections: z.array(z.string()).optional()
|
|
108
124
|
.describe('Collection names this resource belongs to (populated when using config-based discovery)'),
|
|
109
|
-
}).describe('Complete metadata for a markdown resource');
|
|
125
|
+
}).strict().describe('Complete metadata for a markdown resource');
|
|
110
126
|
|
|
111
127
|
export type ResourceMetadata = z.infer<typeof ResourceMetadataSchema>;
|