@glossarist/concept-browser 0.7.119 → 0.7.121

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.119",
3
+ "version": "0.7.121",
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",
@@ -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,31 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { RelatedConcept } from 'glossarist';
3
+ import { resolveRefTarget } from '../../adapters/GraphDataSource';
4
+
5
+ describe('GraphDataSource.resolveRefTarget — prefers native target field', () => {
6
+ it('returns rc.target when set (cross-dataset resolved URI)', () => {
7
+ const rc = new RelatedConcept({
8
+ type: 'superseded_by',
9
+ target: 'https://example.com/cie-2020/concept/17-21-097',
10
+ ref: { source: 'CIE S 017:2020', id: '17-21-097' },
11
+ });
12
+ const target = resolveRefTarget(rc, 'https://example.com/cie-eilv', 'cie-2011', undefined);
13
+ expect(target).toBe('https://example.com/cie-2020/concept/17-21-097');
14
+ });
15
+
16
+ it('falls back to ref resolution when target is null', () => {
17
+ const rc = new RelatedConcept({
18
+ type: 'supersedes',
19
+ ref: { source: 'cie-2020', id: '42' },
20
+ });
21
+ const urnMap = new Map([['cie-2020', 'cie-2020']]);
22
+ const target = resolveRefTarget(rc, 'https://example.com', 'cie-2011', urnMap);
23
+ expect(target).toBe('https://example.com/cie-2020/concept/42');
24
+ });
25
+
26
+ it('returns empty string when neither target nor ref is set', () => {
27
+ const rc = new RelatedConcept({ type: 'see' });
28
+ const target = resolveRefTarget(rc, 'https://example.com', 'cie-2011', undefined);
29
+ expect(target).toBe('');
30
+ });
31
+ });
@@ -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
+ });
@@ -16,7 +16,13 @@ interface DomainNodeJson {
16
16
  children?: DomainNodeJson[];
17
17
  }
18
18
 
19
- function resolveRefTarget(rc: RelatedConcept, uriBase: string, registerId: string, urnMap?: ReadonlyMap<string, string>): string {
19
+ export function resolveRefTarget(rc: RelatedConcept, uriBase: string, registerId: string, urnMap?: ReadonlyMap<string, string>): string {
20
+ // Prefer the native target field — already resolved at build time
21
+ // via refPrefixMap in generate-data.ts. This is the author-provided
22
+ // canonical URL for cross-dataset relations.
23
+ if (rc.target) return rc.target;
24
+
25
+ // Fallback: resolve from ref (for data generated before gl:target existed)
20
26
  if (!rc.ref) return '';
21
27
  const ref = rc.ref;
22
28
  if (ref.id) {
@@ -170,7 +170,8 @@ function attachBridges(concept: Concept, localizations: Record<string, unknown>)
170
170
  /**
171
171
  * Attach bridged fields (ref text, sourceId, citation) to RelatedConcept instances
172
172
  * from the raw deserialized data. Called after Concept.fromJSON creates the model
173
- * instances, since RelatedConcept.fromJSON only reads type/content/ref.
173
+ * instances, since RelatedConcept.fromJSON does not read sourceId/citation/text.
174
+ * (It does read type/content/ref/target natively.)
174
175
  */
175
176
  function attachRelatedBridges(
176
177
  modelRelated: ReadonlyArray<{ type?: string | null; content?: Record<string, string> | string | null; ref?: any; related?: ReadonlyArray<any> }>,