@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 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 NFC-normalize YAML files in .datasets/ (use --check for CI gate)
13
- * doctor Diagnose the local environment (deps, datasets, shapes, context)
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.120",
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",
@@ -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") {
@@ -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
+ });