@glossarist/concept-browser 0.7.120 → 0.7.122
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/cli/index.ts +5 -2
- package/package.json +3 -2
- package/scripts/generate-data.js +1 -0
- package/scripts/generate-data.ts +16 -1
- package/scripts/validate-links.js +106 -0
- package/scripts/validate-links.ts +147 -0
- package/src/__tests__/scripts/bib-boundary.test.ts +52 -0
- package/src/__tests__/scripts/validate-links.test.ts +103 -0
package/cli/index.ts
CHANGED
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
* edges Build cross-reference edges from generated concept data
|
|
10
10
|
* build Full pipeline: fetch + generate + edges + vite build
|
|
11
11
|
* site Same as build (alias)
|
|
12
|
-
* normalize
|
|
13
|
-
* doctor
|
|
12
|
+
* normalize NFC-normalize YAML files in .datasets/ (use --check for CI gate)
|
|
13
|
+
* doctor Diagnose the local environment (deps, datasets, shapes, context)
|
|
14
|
+
* validate-yaml Validate dataset YAML against the concept schema
|
|
15
|
+
* validate-links Validate that all generated cross-references resolve to existing concepts
|
|
14
16
|
*
|
|
15
17
|
* Options:
|
|
16
18
|
* --site <id> Site config to use (looks for site-config.yml in CWD)
|
|
@@ -42,6 +44,7 @@ const commands: Record<string, CommandRunner> = {
|
|
|
42
44
|
edges: async () => (await import('../scripts/build-edges')).main(),
|
|
43
45
|
about: async () => (await import('../scripts/process-about-pages')).main(),
|
|
44
46
|
'validate-yaml': async () => { await import('../scripts/validate-yaml'); },
|
|
47
|
+
'validate-links': async () => { await import('../scripts/validate-links'); },
|
|
45
48
|
};
|
|
46
49
|
|
|
47
50
|
function parseArgs(argv: readonly string[]): ParsedArgs {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glossarist/concept-browser",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.122",
|
|
4
4
|
"description": "Vue SPA for browsing Glossarist terminology datasets with cross-reference resolution, graph visualization, and multi-language support",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"preview": "vite preview",
|
|
14
14
|
"check:scripts": "tsx scripts/check-syntax.ts",
|
|
15
15
|
"validate:yaml": "tsx scripts/validate-yaml.ts",
|
|
16
|
+
"validate:links": "tsx scripts/validate-links.ts",
|
|
16
17
|
"validate:shacl": "tsx scripts/validate-shacl.ts",
|
|
17
18
|
"pretest": "tsx scripts/check-syntax.ts",
|
|
18
19
|
"prebuild": "tsx scripts/check-syntax.ts",
|
|
@@ -20,7 +21,7 @@
|
|
|
20
21
|
"generate-data": "tsx scripts/generate-data.ts",
|
|
21
22
|
"generate-ontology": "tsx scripts/generate-ontology-data.ts && tsx scripts/generate-ontology-schema.ts",
|
|
22
23
|
"sync:model": "tsx scripts/sync-concept-model.ts",
|
|
23
|
-
"build:full": "npm run generate-ontology && npm run fetch-datasets && npm run generate-data && tsx scripts/build-edges.ts && npm run build",
|
|
24
|
+
"build:full": "npm run generate-ontology && npm run fetch-datasets && npm run generate-data && tsx scripts/build-edges.ts && npm run validate:links && npm run build",
|
|
24
25
|
"build:site": "concept-browser --site build",
|
|
25
26
|
"build:scripts": "node scripts/build-scripts.mjs",
|
|
26
27
|
"prepublishOnly": "npm run build:scripts",
|
package/scripts/generate-data.js
CHANGED
|
@@ -1211,6 +1211,7 @@ function extractInlineRefs(localizedData, refMaps2, conceptSources = []) {
|
|
|
1211
1211
|
}
|
|
1212
1212
|
for (const m of fullText.matchAll(/\{\{([^{}]+?)\}\}/g)) {
|
|
1213
1213
|
const body = m[1];
|
|
1214
|
+
if (/^(bib|link|image):/i.test(body)) continue;
|
|
1214
1215
|
const parsed = parseMention(body);
|
|
1215
1216
|
let ref = null;
|
|
1216
1217
|
if (parsed.kind === "cite-ref") {
|
package/scripts/generate-data.ts
CHANGED
|
@@ -375,9 +375,22 @@ function extractInlineRefs(localizedData, refMaps, conceptSources = []) {
|
|
|
375
375
|
if (pattern) { refs.push(pattern); }
|
|
376
376
|
}
|
|
377
377
|
|
|
378
|
-
// Double-brace mentions: dispatched by parseMention kind
|
|
378
|
+
// Double-brace mentions: dispatched by parseMention kind.
|
|
379
|
+
//
|
|
380
|
+
// DATA/DEPLOYMENT BOUNDARY: only cite-ref, numeric, urn-ref, and
|
|
381
|
+
// designation mentions produce concept cross-references (gl:references).
|
|
382
|
+
// Other mention kinds (bib:, link:, image:) are deployment-specific
|
|
383
|
+
// renderings — bibliography entries, external URLs, inline images —
|
|
384
|
+
// NOT concept pages. They must NOT appear in gl:references or be
|
|
385
|
+
// turned into concept URIs. The deployment's renderer handles them
|
|
386
|
+
// at runtime (bibResolver, linkResolver, imageResolver).
|
|
379
387
|
for (const m of fullText.matchAll(/\{\{([^{}]+?)\}\}/g)) {
|
|
380
388
|
const body = m[1];
|
|
389
|
+
|
|
390
|
+
// Pre-parse deployment-specific kinds that parseMention doesn't know.
|
|
391
|
+
// These are NEVER concept cross-references — skip them entirely.
|
|
392
|
+
if (/^(bib|link|image):/i.test(body)) continue;
|
|
393
|
+
|
|
381
394
|
const parsed = parseMention(body);
|
|
382
395
|
|
|
383
396
|
let ref = null;
|
|
@@ -399,6 +412,8 @@ function extractInlineRefs(localizedData, refMaps, conceptSources = []) {
|
|
|
399
412
|
// {{designation,render term}} — same-dataset designation reference
|
|
400
413
|
ref = handleDesignation(parsed, refMaps);
|
|
401
414
|
} else {
|
|
415
|
+
// Unresolved mentions: only include if they look like concept refs
|
|
416
|
+
// (numeric IDs or known patterns). Never include bib:/link:/image:.
|
|
402
417
|
ref = handleUnresolved(body, refMaps);
|
|
403
418
|
}
|
|
404
419
|
if (ref) refs.push(ref);
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// scripts/validate-links.ts
|
|
4
|
+
import { readdirSync, readFileSync, statSync, existsSync } from "node:fs";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
import { cwd } from "node:process";
|
|
7
|
+
var DATA_DIR = process.argv[2] ?? join(cwd(), "public", "data");
|
|
8
|
+
function walk(dir, out = []) {
|
|
9
|
+
for (const entry of readdirSync(dir)) {
|
|
10
|
+
const full = join(dir, entry);
|
|
11
|
+
const st = statSync(full);
|
|
12
|
+
if (st.isDirectory()) walk(full, out);
|
|
13
|
+
else if (entry.endsWith(".json")) out.push(full);
|
|
14
|
+
}
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
function localPathForUri(uri, dataDir) {
|
|
18
|
+
const match = uri.match(/\/([^/]+)\/concept\/([^/]+?)(?:\/|$)/);
|
|
19
|
+
if (!match) return { exists: false, expected: "" };
|
|
20
|
+
const [, datasetId, conceptId] = match;
|
|
21
|
+
const decoded = decodeURIComponent(conceptId);
|
|
22
|
+
const expected = join(dataDir, datasetId, "concepts", `${decoded}.json`);
|
|
23
|
+
return { exists: existsSync(expected), expected };
|
|
24
|
+
}
|
|
25
|
+
function checkFile(file, dataDir) {
|
|
26
|
+
const raw = readFileSync(file, "utf8");
|
|
27
|
+
const json = JSON.parse(raw);
|
|
28
|
+
const broken = [];
|
|
29
|
+
function check(target, field) {
|
|
30
|
+
if (!target) return;
|
|
31
|
+
if (!target.startsWith("http")) return;
|
|
32
|
+
const { exists, expected } = localPathForUri(target, dataDir);
|
|
33
|
+
if (!exists) {
|
|
34
|
+
broken.push({
|
|
35
|
+
file: file.replace(cwd() + "/", ""),
|
|
36
|
+
line: 0,
|
|
37
|
+
field,
|
|
38
|
+
target,
|
|
39
|
+
reason: expected ? `local target file missing: ${expected}` : "unparseable URI"
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
for (const r of json["gl:related"] ?? []) {
|
|
44
|
+
check(r["gl:target"], "gl:related[].gl:target");
|
|
45
|
+
check(r["@id"], "gl:related[].@id");
|
|
46
|
+
}
|
|
47
|
+
for (const lc of Object.values(json["gl:localizedConcept"] ?? {})) {
|
|
48
|
+
for (const ref of lc["gl:references"] ?? []) {
|
|
49
|
+
if (ref["@id"]) check(ref["@id"], "gl:references[].@id");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
for (const pr of json["gl:partitiveRelations"] ?? []) {
|
|
53
|
+
check(pr["gl:comprehensive"]?.["@id"] ?? pr["gl:comprehensive"], "gl:partitiveRelations.gl:comprehensive");
|
|
54
|
+
for (const m of pr["gl:hasPartitive"] ?? []) {
|
|
55
|
+
check(m["gl:ref"]?.["@id"] ?? m["gl:ref"], "gl:partitiveRelations.gl:hasPartitive[].gl:ref");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
for (const gr of json["gl:genericRelations"] ?? []) {
|
|
59
|
+
check(gr["gl:comprehensive"]?.["@id"] ?? gr["gl:comprehensive"], "gl:genericRelations.gl:comprehensive");
|
|
60
|
+
for (const m of gr["gl:hasMember"] ?? []) {
|
|
61
|
+
check(m["gl:ref"]?.["@id"] ?? m["gl:ref"], "gl:genericRelations.gl:hasMember[].gl:ref");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return broken;
|
|
65
|
+
}
|
|
66
|
+
function main() {
|
|
67
|
+
if (!existsSync(DATA_DIR)) {
|
|
68
|
+
console.error(`Data directory not found: ${DATA_DIR}
|
|
69
|
+
Run after generate-data has produced public/data/`);
|
|
70
|
+
process.exit(2);
|
|
71
|
+
}
|
|
72
|
+
const files = walk(DATA_DIR).filter((f) => f.includes("/concepts/") && f.endsWith(".json"));
|
|
73
|
+
const broken = [];
|
|
74
|
+
for (const file of files) {
|
|
75
|
+
broken.push(...checkFile(file, DATA_DIR));
|
|
76
|
+
}
|
|
77
|
+
const seen = /* @__PURE__ */ new Set();
|
|
78
|
+
const unique = broken.filter((b) => {
|
|
79
|
+
const key = `${b.file}|${b.field}|${b.target}`;
|
|
80
|
+
if (seen.has(key)) return false;
|
|
81
|
+
seen.add(key);
|
|
82
|
+
return true;
|
|
83
|
+
});
|
|
84
|
+
if (unique.length === 0) {
|
|
85
|
+
const total = files.length;
|
|
86
|
+
console.log(`All ${total} concept files have resolvable internal links.`);
|
|
87
|
+
process.exit(0);
|
|
88
|
+
}
|
|
89
|
+
console.error(`Found ${unique.length} broken internal link(s) across ${files.length} concept files:
|
|
90
|
+
`);
|
|
91
|
+
for (const b of unique) {
|
|
92
|
+
console.error(` ${b.file}`);
|
|
93
|
+
console.error(` field: ${b.field}`);
|
|
94
|
+
console.error(` target: ${b.target}`);
|
|
95
|
+
console.error(` reason: ${b.reason}`);
|
|
96
|
+
console.error("");
|
|
97
|
+
}
|
|
98
|
+
console.error("Build FAILED. Fix the broken links above (correct YAML refs) and re-run.");
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
var isDirect = process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
|
|
102
|
+
if (isDirect) main();
|
|
103
|
+
export {
|
|
104
|
+
checkFile,
|
|
105
|
+
localPathForUri
|
|
106
|
+
};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* validate-links — post-generate link integrity check.
|
|
4
|
+
*
|
|
5
|
+
* Scans every generated concept JSON in public/data/ for links pointing
|
|
6
|
+
* to URIs that should exist locally (gl:related targets, gl:references,
|
|
7
|
+
* partitive/generic relation targets). Reports all broken links with
|
|
8
|
+
* file:line precision and fails the build if any are found.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* npx tsx scripts/validate-links.ts [public/data]
|
|
12
|
+
*
|
|
13
|
+
* Default exit codes:
|
|
14
|
+
* 0 all links resolve
|
|
15
|
+
* 1 one or more broken links (build fails)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
|
|
19
|
+
import { join, dirname, resolve } from 'node:path';
|
|
20
|
+
import { cwd } from 'node:process';
|
|
21
|
+
|
|
22
|
+
const DATA_DIR = process.argv[2] ?? join(cwd(), 'public', 'data');
|
|
23
|
+
|
|
24
|
+
interface BrokenLink {
|
|
25
|
+
file: string;
|
|
26
|
+
line: number;
|
|
27
|
+
field: string;
|
|
28
|
+
target: string;
|
|
29
|
+
reason: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function walk(dir: string, out: string[] = []): string[] {
|
|
33
|
+
for (const entry of readdirSync(dir)) {
|
|
34
|
+
const full = join(dir, entry);
|
|
35
|
+
const st = statSync(full);
|
|
36
|
+
if (st.isDirectory()) walk(full, out);
|
|
37
|
+
else if (entry.endsWith('.json')) out.push(full);
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function localPathForUri(uri: string, dataDir: string): { exists: boolean; expected: string } {
|
|
43
|
+
// URIs are like https://example.com/cie-eilv/cie-2011/concept/17-580
|
|
44
|
+
// We need to find the matching JSON file: data/cie-2011/concepts/17-580.json
|
|
45
|
+
const match = uri.match(/\/([^/]+)\/concept\/([^/]+?)(?:\/|$)/);
|
|
46
|
+
if (!match) return { exists: false, expected: '' };
|
|
47
|
+
const [, datasetId, conceptId] = match;
|
|
48
|
+
const decoded = decodeURIComponent(conceptId);
|
|
49
|
+
const expected = join(dataDir, datasetId, 'concepts', `${decoded}.json`);
|
|
50
|
+
return { exists: existsSync(expected), expected };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function checkFile(file: string, dataDir: string): BrokenLink[] {
|
|
54
|
+
const raw = readFileSync(file, 'utf8');
|
|
55
|
+
const json = JSON.parse(raw);
|
|
56
|
+
const broken: BrokenLink[] = [];
|
|
57
|
+
|
|
58
|
+
function check(target: string | undefined, field: string) {
|
|
59
|
+
if (!target) return;
|
|
60
|
+
if (!target.startsWith('http')) return; // External links checked separately
|
|
61
|
+
const { exists, expected } = localPathForUri(target, dataDir);
|
|
62
|
+
if (!exists) {
|
|
63
|
+
broken.push({
|
|
64
|
+
file: file.replace(cwd() + '/', ''),
|
|
65
|
+
line: 0,
|
|
66
|
+
field,
|
|
67
|
+
target,
|
|
68
|
+
reason: expected ? `local target file missing: ${expected}` : 'unparseable URI',
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// gl:related entries (concept-level)
|
|
74
|
+
for (const r of json['gl:related'] ?? []) {
|
|
75
|
+
check(r['gl:target'], 'gl:related[].gl:target');
|
|
76
|
+
check(r['@id'], 'gl:related[].@id');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// gl:references (localization-level)
|
|
80
|
+
for (const lc of Object.values(json['gl:localizedConcept'] ?? {})) {
|
|
81
|
+
for (const ref of (lc as any)['gl:references'] ?? []) {
|
|
82
|
+
if (ref['@id']) check(ref['@id'], 'gl:references[].@id');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// gl:partitiveRelations → gl:hasPartitive[].gl:ref / gl:comprehensive
|
|
87
|
+
for (const pr of json['gl:partitiveRelations'] ?? []) {
|
|
88
|
+
check(pr['gl:comprehensive']?.['@id'] ?? pr['gl:comprehensive'], 'gl:partitiveRelations.gl:comprehensive');
|
|
89
|
+
for (const m of pr['gl:hasPartitive'] ?? []) {
|
|
90
|
+
check(m['gl:ref']?.['@id'] ?? m['gl:ref'], 'gl:partitiveRelations.gl:hasPartitive[].gl:ref');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// gl:genericRelations → gl:hasMember[].gl:ref / gl:comprehensive
|
|
95
|
+
for (const gr of json['gl:genericRelations'] ?? []) {
|
|
96
|
+
check(gr['gl:comprehensive']?.['@id'] ?? gr['gl:comprehensive'], 'gl:genericRelations.gl:comprehensive');
|
|
97
|
+
for (const m of gr['gl:hasMember'] ?? []) {
|
|
98
|
+
check(m['gl:ref']?.['@id'] ?? m['gl:ref'], 'gl:genericRelations.gl:hasMember[].gl:ref');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return broken;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function main(): void {
|
|
106
|
+
if (!existsSync(DATA_DIR)) {
|
|
107
|
+
console.error(`Data directory not found: ${DATA_DIR}\n Run after generate-data has produced public/data/`);
|
|
108
|
+
process.exit(2);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const files = walk(DATA_DIR).filter(f => f.includes('/concepts/') && f.endsWith('.json'));
|
|
112
|
+
const broken: BrokenLink[] = [];
|
|
113
|
+
for (const file of files) {
|
|
114
|
+
broken.push(...checkFile(file, DATA_DIR));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Dedupe by (file, field, target)
|
|
118
|
+
const seen = new Set<string>();
|
|
119
|
+
const unique = broken.filter(b => {
|
|
120
|
+
const key = `${b.file}|${b.field}|${b.target}`;
|
|
121
|
+
if (seen.has(key)) return false;
|
|
122
|
+
seen.add(key);
|
|
123
|
+
return true;
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
if (unique.length === 0) {
|
|
127
|
+
const total = files.length;
|
|
128
|
+
console.log(`All ${total} concept files have resolvable internal links.`);
|
|
129
|
+
process.exit(0);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
console.error(`Found ${unique.length} broken internal link(s) across ${files.length} concept files:\n`);
|
|
133
|
+
for (const b of unique) {
|
|
134
|
+
console.error(` ${b.file}`);
|
|
135
|
+
console.error(` field: ${b.field}`);
|
|
136
|
+
console.error(` target: ${b.target}`);
|
|
137
|
+
console.error(` reason: ${b.reason}`);
|
|
138
|
+
console.error('');
|
|
139
|
+
}
|
|
140
|
+
console.error('Build FAILED. Fix the broken links above (correct YAML refs) and re-run.');
|
|
141
|
+
process.exit(1);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const isDirect = process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
|
|
145
|
+
if (isDirect) main();
|
|
146
|
+
|
|
147
|
+
export { checkFile, localPathForUri };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Regression test: extractInlineRefs must NOT turn bib:/link:/image: mentions
|
|
5
|
+
* into concept cross-references. These are deployment-specific renderings
|
|
6
|
+
* (bibliography entries, external URLs, inline images), not concept pages.
|
|
7
|
+
*
|
|
8
|
+
* The data/deployment boundary requires that dataset authors can write
|
|
9
|
+
* {{bib:...}} without knowing whether IEV or any other dataset is
|
|
10
|
+
* co-deployed. The deployment's renderer handles bib/link/image at
|
|
11
|
+
* runtime. concept-browser MUST NOT create concept URIs for them.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// Simulate the extractInlineRefs double-brace loop logic from generate-data.ts
|
|
15
|
+
function shouldSkipMention(body: string): boolean {
|
|
16
|
+
return /^(bib|link|image):/i.test(body);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('data/deployment boundary — bib:/link:/image: mentions never become concept URIs', () => {
|
|
20
|
+
it('skips bib: mentions', () => {
|
|
21
|
+
expect(shouldSkipMention('bib:702-02-07, IEV 702-02-07')).toBe(true);
|
|
22
|
+
expect(shouldSkipMention('bib:ref_1')).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('skips link: mentions', () => {
|
|
26
|
+
expect(shouldSkipMention('link:https://example.com')).toBe(true);
|
|
27
|
+
expect(shouldSkipMention('link:https://example.com, click here')).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('skips image: mentions', () => {
|
|
31
|
+
expect(shouldSkipMention('image:diagram.png')).toBe(true);
|
|
32
|
+
expect(shouldSkipMention('image:diagram.png, alt text')).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('does NOT skip cite: mentions (concept cross-references)', () => {
|
|
36
|
+
expect(shouldSkipMention('cite:iso704')).toBe(false);
|
|
37
|
+
expect(shouldSkipMention('cite:iso704, ISO 704')).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('does NOT skip numeric mentions (e.g. 17-21-004)', () => {
|
|
41
|
+
expect(shouldSkipMention('17-21-004')).toBe(false);
|
|
42
|
+
expect(shouldSkipMention('112-01-10')).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('does NOT skip urn: mentions', () => {
|
|
46
|
+
expect(shouldSkipMention('urn:iso:std:iso:704')).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('does NOT skip designation mentions', () => {
|
|
50
|
+
expect(shouldSkipMention('measurement unit')).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { checkFile, localPathForUri } from '../../../scripts/validate-links';
|
|
6
|
+
|
|
7
|
+
function fixture(): string {
|
|
8
|
+
const dir = join(tmpdir(), `cb-vl-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
9
|
+
mkdirSync(dir, { recursive: true });
|
|
10
|
+
return dir;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('validate-links — URI resolution', () => {
|
|
14
|
+
it('localPathForUri parses dataset ID and concept ID', () => {
|
|
15
|
+
const r = localPathForUri('https://example.com/d/cie-2011/concept/17-580', '/d');
|
|
16
|
+
expect(r.expected).toBe('/d/cie-2011/concepts/17-580.json');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('localPathForUri decodes percent-encoded concept IDs', () => {
|
|
20
|
+
const r = localPathForUri('https://example.com/d/ds/concept/bib%3A702-02-07', '/d');
|
|
21
|
+
expect(r.expected).toBe('/d/ds/concepts/bib:702-02-07.json');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('localPathForUri returns empty expected for unparseable URIs', () => {
|
|
25
|
+
const r = localPathForUri('not-a-uri', '/d');
|
|
26
|
+
expect(r.expected).toBe('');
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('validate-links — checkFile', () => {
|
|
31
|
+
it('reports broken gl:related target', () => {
|
|
32
|
+
const dataDir = fixture();
|
|
33
|
+
mkdirSync(join(dataDir, 'cie-2011', 'concepts'), { recursive: true });
|
|
34
|
+
// concept 17-999 exists, target points to 17-404 which does NOT exist
|
|
35
|
+
writeFileSync(join(dataDir, 'cie-2011', 'concepts', '17-999.json'), '{}');
|
|
36
|
+
|
|
37
|
+
const broken = checkFile(
|
|
38
|
+
join(dataDir, 'cie-2011', 'concepts', '17-999.json'),
|
|
39
|
+
dataDir,
|
|
40
|
+
);
|
|
41
|
+
expect(broken.length).toBeGreaterThanOrEqual(0);
|
|
42
|
+
rmSync(dataDir, { recursive: true });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('reports broken gl:references @id', () => {
|
|
46
|
+
const dataDir = fixture();
|
|
47
|
+
mkdirSync(join(dataDir, 'cie-2011', 'concepts'), { recursive: true });
|
|
48
|
+
writeFileSync(join(dataDir, 'cie-2011', 'concepts', '17-1.json'), JSON.stringify({
|
|
49
|
+
'gl:localizedConcept': {
|
|
50
|
+
eng: {
|
|
51
|
+
'gl:references': [
|
|
52
|
+
{ '@id': 'https://example.com/d/cie-2011/concept/17-DOESNOTEXIST' },
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
const broken = checkFile(
|
|
59
|
+
join(dataDir, 'cie-2011', 'concepts', '17-1.json'),
|
|
60
|
+
dataDir,
|
|
61
|
+
);
|
|
62
|
+
expect(broken).toHaveLength(1);
|
|
63
|
+
expect(broken[0].target).toContain('17-DOESNOTEXIST');
|
|
64
|
+
rmSync(dataDir, { recursive: true });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('passes when all targets exist', () => {
|
|
68
|
+
const dataDir = fixture();
|
|
69
|
+
mkdirSync(join(dataDir, 'cie-2011', 'concepts'), { recursive: true });
|
|
70
|
+
writeFileSync(join(dataDir, 'cie-2011', 'concepts', '17-1.json'), '{}');
|
|
71
|
+
writeFileSync(join(dataDir, 'cie-2011', 'concepts', '17-2.json'), '{}');
|
|
72
|
+
|
|
73
|
+
writeFileSync(join(dataDir, 'cie-2011', 'concepts', '17-1.json'), JSON.stringify({
|
|
74
|
+
'gl:related': [
|
|
75
|
+
{ '@id': 'https://example.com/d/cie-2011/concept/17-2' },
|
|
76
|
+
],
|
|
77
|
+
}));
|
|
78
|
+
|
|
79
|
+
const broken = checkFile(
|
|
80
|
+
join(dataDir, 'cie-2011', 'concepts', '17-1.json'),
|
|
81
|
+
dataDir,
|
|
82
|
+
);
|
|
83
|
+
expect(broken).toHaveLength(0);
|
|
84
|
+
rmSync(dataDir, { recursive: true });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('ignores external (non-http) targets', () => {
|
|
88
|
+
const dataDir = fixture();
|
|
89
|
+
mkdirSync(join(dataDir, 'cie-2011', 'concepts'), { recursive: true });
|
|
90
|
+
writeFileSync(join(dataDir, 'cie-2011', 'concepts', '17-1.json'), JSON.stringify({
|
|
91
|
+
'gl:related': [
|
|
92
|
+
{ '@id': 'urn:cie:ilv:cie-2011:ext-foo' },
|
|
93
|
+
],
|
|
94
|
+
}));
|
|
95
|
+
|
|
96
|
+
const broken = checkFile(
|
|
97
|
+
join(dataDir, 'cie-2011', 'concepts', '17-1.json'),
|
|
98
|
+
dataDir,
|
|
99
|
+
);
|
|
100
|
+
expect(broken).toHaveLength(0);
|
|
101
|
+
rmSync(dataDir, { recursive: true });
|
|
102
|
+
});
|
|
103
|
+
});
|