@glossarist/concept-browser 0.7.122 → 0.7.123
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glossarist/concept-browser",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.123",
|
|
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": {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// scripts/validate-links.ts
|
|
4
4
|
import { readdirSync, readFileSync, statSync, existsSync } from "node:fs";
|
|
5
|
-
import { join, resolve } from "node:path";
|
|
5
|
+
import { join, dirname, resolve } from "node:path";
|
|
6
6
|
import { cwd } from "node:process";
|
|
7
7
|
var DATA_DIR = process.argv[2] ?? join(cwd(), "public", "data");
|
|
8
8
|
function walk(dir, out = []) {
|
|
@@ -61,6 +61,101 @@ function checkFile(file, dataDir) {
|
|
|
61
61
|
check(m["gl:ref"]?.["@id"] ?? m["gl:ref"], "gl:genericRelations.gl:hasMember[].gl:ref");
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
|
+
const datasetDir = file.includes("/concepts/") ? dirname(dirname(file)) : "";
|
|
65
|
+
const bibPath = datasetDir ? join(datasetDir, "bibliography.json") : "";
|
|
66
|
+
let bibliography = null;
|
|
67
|
+
if (bibPath && existsSync(bibPath)) {
|
|
68
|
+
try {
|
|
69
|
+
bibliography = JSON.parse(readFileSync(bibPath, "utf8"));
|
|
70
|
+
} catch {
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const sourceIds = /* @__PURE__ */ new Set();
|
|
74
|
+
for (const s of json["gl:sources"] ?? []) {
|
|
75
|
+
if (s["gl:id"]) sourceIds.add(s["gl:id"]);
|
|
76
|
+
}
|
|
77
|
+
const textChunks = [];
|
|
78
|
+
for (const lc of Object.values(json["gl:localizedConcept"] ?? {})) {
|
|
79
|
+
const lcObj = lc;
|
|
80
|
+
for (const field of ["gl:definition", "gl:notes", "gl:examples", "gl:annotations"]) {
|
|
81
|
+
for (const entry of lcObj[field] ?? []) {
|
|
82
|
+
if (entry["gl:content"]) textChunks.push(entry["gl:content"]);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const fullText = textChunks.join("\n");
|
|
87
|
+
for (const m of fullText.matchAll(/\{\{([^{}]+?)\}\}/g)) {
|
|
88
|
+
const body = m[1].trim();
|
|
89
|
+
const relFile = file.replace(cwd() + "/", "");
|
|
90
|
+
const bibMatch = body.match(/^bib:(.+)$/i);
|
|
91
|
+
if (bibMatch) {
|
|
92
|
+
const rest = bibMatch[1];
|
|
93
|
+
const commaIdx = rest.indexOf(",");
|
|
94
|
+
const id = commaIdx > 0 ? rest.slice(0, commaIdx).trim() : rest.trim();
|
|
95
|
+
if (sourceIds.has(id)) {
|
|
96
|
+
broken.push({
|
|
97
|
+
file: relFile,
|
|
98
|
+
line: 0,
|
|
99
|
+
field: "{{bib:...}}",
|
|
100
|
+
target: id,
|
|
101
|
+
reason: `"${id}" is a ConceptSource (concept), not a bibliography entry. Use {{cite:${id}}} instead. bib: is ONLY for pure bibliographic records (papers, external docs) that are NOT concepts in any dataset.`
|
|
102
|
+
});
|
|
103
|
+
} else if (bibliography) {
|
|
104
|
+
const entries = bibliography.bibliography ?? bibliography;
|
|
105
|
+
if (!entries[id] && !(Array.isArray(entries) && entries.some((e) => e.id === id))) {
|
|
106
|
+
broken.push({
|
|
107
|
+
file: relFile,
|
|
108
|
+
line: 0,
|
|
109
|
+
field: "{{bib:...}}",
|
|
110
|
+
target: id,
|
|
111
|
+
reason: `bibliography entry "${id}" not found in bibliography.json. If this is a concept, use {{cite:...}} or {{urn:...}} instead.`
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
broken.push({
|
|
116
|
+
file: relFile,
|
|
117
|
+
line: 0,
|
|
118
|
+
field: "{{bib:...}}",
|
|
119
|
+
target: id,
|
|
120
|
+
reason: `no bibliography.json for this dataset. If "${id}" is a concept, use {{cite:...}} or {{urn:...}} instead.`
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const linkMatch = body.match(/^link:(.+)$/i);
|
|
126
|
+
if (linkMatch) {
|
|
127
|
+
const rest = linkMatch[1];
|
|
128
|
+
const commaIdx = rest.indexOf(",");
|
|
129
|
+
const url = commaIdx > 0 ? rest.slice(0, commaIdx).trim() : rest.trim();
|
|
130
|
+
if (!/^https?:\/\//.test(url)) {
|
|
131
|
+
broken.push({ file: relFile, line: 0, field: "{{link:...}}", target: url, reason: "link must be http: or https: URL" });
|
|
132
|
+
}
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const imageMatch = body.match(/^image:(.+)$/i);
|
|
136
|
+
if (imageMatch) {
|
|
137
|
+
const rest = imageMatch[1];
|
|
138
|
+
const commaIdx = rest.indexOf(",");
|
|
139
|
+
const src = commaIdx > 0 ? rest.slice(0, commaIdx).trim() : rest.trim();
|
|
140
|
+
if (!/^https?:\/\//.test(src) && !src.startsWith("data:")) {
|
|
141
|
+
const imgPath = join(cwd(), "public", src.replace(/^\//, ""));
|
|
142
|
+
if (!existsSync(imgPath)) {
|
|
143
|
+
broken.push({ file: relFile, line: 0, field: "{{image:...}}", target: src, reason: `image file not found: public/${src.replace(/^\//, "")}` });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const citeMatch = body.match(/^cite:(.+)$/i);
|
|
149
|
+
if (citeMatch) {
|
|
150
|
+
const rest = citeMatch[1];
|
|
151
|
+
const commaIdx = rest.indexOf(",");
|
|
152
|
+
const id = commaIdx > 0 ? rest.slice(0, commaIdx).trim() : rest.trim();
|
|
153
|
+
if (!sourceIds.has(id)) {
|
|
154
|
+
broken.push({ file: relFile, line: 0, field: "{{cite:...}}", target: id, reason: `source "${id}" not found in this concept's sources[]` });
|
|
155
|
+
}
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
64
159
|
return broken;
|
|
65
160
|
}
|
|
66
161
|
function main() {
|
|
@@ -70,7 +70,8 @@ function checkFile(file: string, dataDir: string): BrokenLink[] {
|
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
//
|
|
73
|
+
// ── Concept-level cross-references ─────────────────────────────────────
|
|
74
|
+
// gl:related entries
|
|
74
75
|
for (const r of json['gl:related'] ?? []) {
|
|
75
76
|
check(r['gl:target'], 'gl:related[].gl:target');
|
|
76
77
|
check(r['@id'], 'gl:related[].@id');
|
|
@@ -99,6 +100,111 @@ function checkFile(file: string, dataDir: string): BrokenLink[] {
|
|
|
99
100
|
}
|
|
100
101
|
}
|
|
101
102
|
|
|
103
|
+
// ── Inline mention validation ─────────────────────────────────────────
|
|
104
|
+
// Scan ALL text content (definitions, notes, examples, annotations) for
|
|
105
|
+
// {{kind:target}} mentions and validate each by kind. No mention is
|
|
106
|
+
// silently skipped — every xref must resolve.
|
|
107
|
+
const datasetDir = file.includes('/concepts/') ? dirname(dirname(file)) : '';
|
|
108
|
+
const bibPath = datasetDir ? join(datasetDir, 'bibliography.json') : '';
|
|
109
|
+
let bibliography: Record<string, any> | null = null;
|
|
110
|
+
if (bibPath && existsSync(bibPath)) {
|
|
111
|
+
try { bibliography = JSON.parse(readFileSync(bibPath, 'utf8')); } catch {}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const sourceIds = new Set<string>();
|
|
115
|
+
for (const s of json['gl:sources'] ?? []) {
|
|
116
|
+
if (s['gl:id']) sourceIds.add(s['gl:id']);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const textChunks: string[] = [];
|
|
120
|
+
for (const lc of Object.values(json['gl:localizedConcept'] ?? {})) {
|
|
121
|
+
const lcObj = lc as any;
|
|
122
|
+
for (const field of ['gl:definition', 'gl:notes', 'gl:examples', 'gl:annotations']) {
|
|
123
|
+
for (const entry of lcObj[field] ?? []) {
|
|
124
|
+
if (entry['gl:content']) textChunks.push(entry['gl:content']);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const fullText = textChunks.join('\n');
|
|
129
|
+
|
|
130
|
+
for (const m of fullText.matchAll(/\{\{([^{}]+?)\}\}/g)) {
|
|
131
|
+
const body = m[1].trim();
|
|
132
|
+
const relFile = file.replace(cwd() + '/', '');
|
|
133
|
+
|
|
134
|
+
// bib:id — must be a PURE bibliographic record, NOT a concept.
|
|
135
|
+
// If the id matches a source in sources[], the author used bib: for
|
|
136
|
+
// a concept citation — they should use {{cite:...}} instead.
|
|
137
|
+
// Per concept-model spec: IEV entries, ISO standards with concept IDs,
|
|
138
|
+
// anything that IS a concept in any dataset → must use cite:, not bib:.
|
|
139
|
+
const bibMatch = body.match(/^bib:(.+)$/i);
|
|
140
|
+
if (bibMatch) {
|
|
141
|
+
const rest = bibMatch[1];
|
|
142
|
+
const commaIdx = rest.indexOf(',');
|
|
143
|
+
const id = (commaIdx > 0 ? rest.slice(0, commaIdx).trim() : rest.trim());
|
|
144
|
+
if (sourceIds.has(id)) {
|
|
145
|
+
broken.push({
|
|
146
|
+
file: relFile, line: 0, field: '{{bib:...}}', target: id,
|
|
147
|
+
reason: `"${id}" is a ConceptSource (concept), not a bibliography entry. Use {{cite:${id}}} instead. bib: is ONLY for pure bibliographic records (papers, external docs) that are NOT concepts in any dataset.`,
|
|
148
|
+
});
|
|
149
|
+
} else if (bibliography) {
|
|
150
|
+
const entries = bibliography.bibliography ?? bibliography;
|
|
151
|
+
if (!entries[id] && !(Array.isArray(entries) && entries.some((e: any) => e.id === id))) {
|
|
152
|
+
broken.push({
|
|
153
|
+
file: relFile, line: 0, field: '{{bib:...}}', target: id,
|
|
154
|
+
reason: `bibliography entry "${id}" not found in bibliography.json. If this is a concept, use {{cite:...}} or {{urn:...}} instead.`,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
broken.push({
|
|
159
|
+
file: relFile, line: 0, field: '{{bib:...}}', target: id,
|
|
160
|
+
reason: `no bibliography.json for this dataset. If "${id}" is a concept, use {{cite:...}} or {{urn:...}} instead.`,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// link:url — must be a valid http/https URL
|
|
167
|
+
const linkMatch = body.match(/^link:(.+)$/i);
|
|
168
|
+
if (linkMatch) {
|
|
169
|
+
const rest = linkMatch[1];
|
|
170
|
+
const commaIdx = rest.indexOf(',');
|
|
171
|
+
const url = (commaIdx > 0 ? rest.slice(0, commaIdx).trim() : rest.trim());
|
|
172
|
+
if (!/^https?:\/\//.test(url)) {
|
|
173
|
+
broken.push({ file: relFile, line: 0, field: '{{link:...}}', target: url, reason: 'link must be http: or https: URL' });
|
|
174
|
+
}
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// image:src — if local path, must exist in public/
|
|
179
|
+
const imageMatch = body.match(/^image:(.+)$/i);
|
|
180
|
+
if (imageMatch) {
|
|
181
|
+
const rest = imageMatch[1];
|
|
182
|
+
const commaIdx = rest.indexOf(',');
|
|
183
|
+
const src = (commaIdx > 0 ? rest.slice(0, commaIdx).trim() : rest.trim());
|
|
184
|
+
if (!/^https?:\/\//.test(src) && !src.startsWith('data:')) {
|
|
185
|
+
const imgPath = join(cwd(), 'public', src.replace(/^\//, ''));
|
|
186
|
+
if (!existsSync(imgPath)) {
|
|
187
|
+
broken.push({ file: relFile, line: 0, field: '{{image:...}}', target: src, reason: `image file not found: public/${src.replace(/^\//, '')}` });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// cite:id — source must exist in this concept's sources[]
|
|
194
|
+
const citeMatch = body.match(/^cite:(.+)$/i);
|
|
195
|
+
if (citeMatch) {
|
|
196
|
+
const rest = citeMatch[1];
|
|
197
|
+
const commaIdx = rest.indexOf(',');
|
|
198
|
+
const id = (commaIdx > 0 ? rest.slice(0, commaIdx).trim() : rest.trim());
|
|
199
|
+
if (!sourceIds.has(id)) {
|
|
200
|
+
broken.push({ file: relFile, line: 0, field: '{{cite:...}}', target: id, reason: `source "${id}" not found in this concept's sources[]` });
|
|
201
|
+
}
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// numeric/designation/urn — checked via the concept-level cross-ref checks above
|
|
206
|
+
}
|
|
207
|
+
|
|
102
208
|
return broken;
|
|
103
209
|
}
|
|
104
210
|
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { checkFile } from '../../../scripts/validate-links';
|
|
6
|
+
|
|
7
|
+
function fixture(conceptJson: object): string {
|
|
8
|
+
const dir = join(tmpdir(), `cb-vl-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
9
|
+
mkdirSync(join(dir, 'cie-2020', 'concepts'), { recursive: true });
|
|
10
|
+
const file = join(dir, 'cie-2020', 'concepts', '17-1.json');
|
|
11
|
+
writeFileSync(file, JSON.stringify(conceptJson));
|
|
12
|
+
return file;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('validate-links — bib: misuse detection (concept cited as bibliography)', () => {
|
|
16
|
+
it('flags {{bib:id}} when id matches a ConceptSource (should be cite:)', () => {
|
|
17
|
+
const file = fixture({
|
|
18
|
+
'gl:sources': [{ 'gl:id': '702-02-07' }],
|
|
19
|
+
'gl:localizedConcept': {
|
|
20
|
+
eng: {
|
|
21
|
+
'gl:definition': [{
|
|
22
|
+
'gl:content': 'See {{bib:702-02-07, IEV 702-02-07}}',
|
|
23
|
+
}],
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
const broken = checkFile(file, join(file, '..', '..', '..'));
|
|
28
|
+
const bibError = broken.find(b => b.field === '{{bib:...}}');
|
|
29
|
+
expect(bibError).toBeDefined();
|
|
30
|
+
expect(bibError!.target).toBe('702-02-07');
|
|
31
|
+
expect(bibError!.reason).toContain('Use {{cite:');
|
|
32
|
+
rmSync(join(file, '..', '..', '..'), { recursive: true });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('passes {{bib:id}} when id is in bibliography.json (pure bib record)', () => {
|
|
36
|
+
const dir = join(tmpdir(), `cb-vl-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
37
|
+
mkdirSync(join(dir, 'cie-2020', 'concepts'), { recursive: true });
|
|
38
|
+
writeFileSync(join(dir, 'cie-2020', 'bibliography.json'), JSON.stringify({
|
|
39
|
+
bibliography: { 'iso704_doc': { reference: 'ISO 704:2022' } },
|
|
40
|
+
}));
|
|
41
|
+
const file = join(dir, 'cie-2020', 'concepts', '17-1.json');
|
|
42
|
+
writeFileSync(file, JSON.stringify({
|
|
43
|
+
'gl:localizedConcept': {
|
|
44
|
+
eng: { 'gl:definition': [{ 'gl:content': 'See {{bib:iso704_doc, ISO 704}}' }] },
|
|
45
|
+
},
|
|
46
|
+
}));
|
|
47
|
+
const broken = checkFile(file, dir);
|
|
48
|
+
expect(broken.find(b => b.field === '{{bib:...}}')).toBeUndefined();
|
|
49
|
+
rmSync(dir, { recursive: true });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('flags {{bib:id}} when id is not in bibliography and not a source', () => {
|
|
53
|
+
const file = fixture({
|
|
54
|
+
'gl:localizedConcept': {
|
|
55
|
+
eng: { 'gl:definition': [{ 'gl:content': 'See {{bib:nonexistent_ref}}' }] },
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
const broken = checkFile(file, join(file, '..', '..', '..'));
|
|
59
|
+
const bibError = broken.find(b => b.field === '{{bib:...}}');
|
|
60
|
+
expect(bibError).toBeDefined();
|
|
61
|
+
expect(bibError!.reason).toContain('bibliography');
|
|
62
|
+
rmSync(join(file, '..', '..', '..'), { recursive: true });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('passes {{cite:id}} when id matches a source', () => {
|
|
66
|
+
const file = fixture({
|
|
67
|
+
'gl:sources': [{ 'gl:id': 'iev-702-02-07' }],
|
|
68
|
+
'gl:localizedConcept': {
|
|
69
|
+
eng: { 'gl:definition': [{ 'gl:content': 'See {{cite:iev-702-02-07, IEV}}' }] },
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
const broken = checkFile(file, join(file, '..', '..', '..'));
|
|
73
|
+
expect(broken.find(b => b.field === '{{cite:...}}')).toBeUndefined();
|
|
74
|
+
rmSync(join(file, '..', '..', '..'), { recursive: true });
|
|
75
|
+
});
|
|
76
|
+
});
|