@glossarist/concept-browser 0.7.122 → 0.7.124

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.122",
3
+ "version": "0.7.124",
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": {
@@ -43,7 +43,7 @@
43
43
  "astro": "^7.0.7",
44
44
  "d3": "^7.9.0",
45
45
  "favicons": "^7.2.0",
46
- "glossarist": "^0.4.54",
46
+ "glossarist": "^0.4.55",
47
47
  "js-yaml": "^4.1.0",
48
48
  "jszip": "^3.10.1",
49
49
  "n3": "^1.26.0",
@@ -2,7 +2,7 @@
2
2
  import fs2 from "fs";
3
3
  import path3 from "path";
4
4
  import yaml4 from "js-yaml";
5
- import { naturalSort, Register, parseMention } from "glossarist";
5
+ import { naturalSort, Register, parseMentions, InvalidMentionError } from "glossarist";
6
6
 
7
7
  // scripts/load-site-config.ts
8
8
  import { readFileSync, existsSync } from "fs";
@@ -1128,54 +1128,6 @@ function resolvePatternRef(identifier, display, refPrefixMap, patternIndex, uriB
1128
1128
  if (!r) return null;
1129
1129
  return { id: buildConceptUri(uriBase, r.datasetId, r.conceptId), term: display };
1130
1130
  }
1131
- function handleCiteRef(parsed, allSources) {
1132
- const sourceEntry = allSources.find((s) => s.id === parsed.key);
1133
- if (sourceEntry) {
1134
- return {
1135
- id: `cite:${sourceEntry.id}`,
1136
- term: parsed.label || sourceEntry.origin?.toString?.() || sourceEntry.id,
1137
- sourceId: sourceEntry.id,
1138
- citation: sourceEntry.origin || null
1139
- };
1140
- }
1141
- return {
1142
- id: `cite:${parsed.key}`,
1143
- term: parsed.label || parsed.key,
1144
- sourceId: parsed.key,
1145
- citation: null
1146
- };
1147
- }
1148
- function handleNumeric(parsed, register, uriBase) {
1149
- if (!register) return null;
1150
- const term = parsed.label ?? parsed.id;
1151
- return { id: buildConceptUri(uriBase, register, parsed.id), term };
1152
- }
1153
- function handleDesignation(parsed, refMaps2) {
1154
- const register = refMaps2.register;
1155
- if (!register) return null;
1156
- const designation = parsed.id;
1157
- const display = parsed.label ?? designation;
1158
- const conceptId = refMaps2.designationLookup?.get(designation.toLowerCase());
1159
- return {
1160
- id: buildConceptUri(refMaps2.uriBase, register, conceptId ?? designation),
1161
- term: display
1162
- };
1163
- }
1164
- function handleUnresolved(body, refMaps2) {
1165
- const commaMatch = body.match(/^([^,}]+),\s*(.+)$/);
1166
- if (!commaMatch) return null;
1167
- const identifier = commaMatch[1].trim();
1168
- const display = commaMatch[2].trim();
1169
- const iev = resolveIevRef(identifier, display, refMaps2.refPrefixMap, refMaps2.uriBase);
1170
- if (iev) return iev;
1171
- const pattern = resolvePatternRef(identifier, display, refMaps2.refPrefixMap, refMaps2.patternIndex, refMaps2.uriBase);
1172
- if (pattern) return pattern;
1173
- const register = refMaps2.register;
1174
- if (register && (/^\d/.test(identifier) || /^[A-Z]\.\d/.test(identifier))) {
1175
- return { id: buildConceptUri(refMaps2.uriBase, register, identifier), term: display };
1176
- }
1177
- return null;
1178
- }
1179
1131
  function collectTextContent(localizedData) {
1180
1132
  const texts = [];
1181
1133
  const textFields = ["definition", "notes", "examples"];
@@ -1211,28 +1163,33 @@ function extractInlineRefs(localizedData, refMaps2, conceptSources = []) {
1211
1163
  }
1212
1164
  for (const m of fullText.matchAll(/\{\{([^{}]+?)\}\}/g)) {
1213
1165
  const body = m[1];
1214
- if (/^(bib|link|image):/i.test(body)) continue;
1215
- const parsed = parseMention(body);
1216
- let ref = null;
1217
- if (parsed.kind === "cite-ref") {
1218
- ref = handleCiteRef(parsed, allSources);
1219
- } else if (parsed.kind === "numeric") {
1220
- ref = handleNumeric(parsed, refMaps2.register, uriBase);
1221
- } else if (parsed.kind === "urn-ref") {
1222
- const uri = parsed.uri;
1223
- const term = parsed.label ?? uri;
1224
- const pattern = refMaps2.patternIndex.resolve(uri);
1225
- if (pattern) {
1226
- ref = { id: buildConceptUri(refMaps2.uriBase, pattern.datasetId, pattern.conceptId), term };
1227
- } else {
1228
- ref = { id: uri, term };
1166
+ let segments;
1167
+ try {
1168
+ segments = parseMentions(`{{${body}}}`);
1169
+ } catch (e) {
1170
+ if (e instanceof InvalidMentionError) {
1171
+ console.warn(` Invalid mention: {{${body}}} \u2014 ${e.reason}`);
1172
+ continue;
1173
+ }
1174
+ throw e;
1175
+ }
1176
+ for (const seg of segments) {
1177
+ if (seg.kind === "text") continue;
1178
+ if (seg.kind === "bib" || seg.kind === "link" || seg.kind === "image") continue;
1179
+ if (seg.kind === "concept" || seg.kind === "cite") {
1180
+ const target = seg.target;
1181
+ if (target.type === "dataset_qualified" && target.dataset && target.id) {
1182
+ const dsId = refMaps2.refPrefixMap[target.dataset] || target.dataset;
1183
+ const ref = { id: buildConceptUri(refMaps2.uriBase, dsId, target.id), term: seg.label ?? `${target.dataset}:${target.id}` };
1184
+ refs.push(ref);
1185
+ } else if (target.type === "urn" && target.urn) {
1186
+ const pattern = refMaps2.patternIndex.resolve(target.urn);
1187
+ if (pattern) {
1188
+ refs.push({ id: buildConceptUri(refMaps2.uriBase, pattern.datasetId, pattern.conceptId), term: seg.label ?? target.urn });
1189
+ }
1190
+ }
1229
1191
  }
1230
- } else if (parsed.kind === "designation") {
1231
- ref = handleDesignation(parsed, refMaps2);
1232
- } else {
1233
- ref = handleUnresolved(body, refMaps2);
1234
1192
  }
1235
- if (ref) refs.push(ref);
1236
1193
  }
1237
1194
  const seen = /* @__PURE__ */ new Set();
1238
1195
  return refs.filter((r) => {
@@ -1,7 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import yaml from 'js-yaml';
4
- import { naturalSort, Register, parseMention } from 'glossarist';
4
+ import { naturalSort, Register, parseMention, parseMentions, InvalidMentionError } from 'glossarist';
5
5
  import { loadSiteConfig } from './load-site-config';
6
6
  import { getGroups } from './lib/concept-groups';
7
7
  import { consumeDatasetEntities } from './lib/build/non-verbal-consumer';
@@ -375,48 +375,51 @@ function extractInlineRefs(localizedData, refMaps, conceptSources = []) {
375
375
  if (pattern) { refs.push(pattern); }
376
376
  }
377
377
 
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).
378
+ // Double-brace mentions: use the STRICT parser from glossarist@0.4.55.
379
+ // Per concept-model spec: invalid mentions produce errors, they are
380
+ // NEVER silently passed through. The build FAILS on any invalid syntax.
387
381
  for (const m of fullText.matchAll(/\{\{([^{}]+?)\}\}/g)) {
388
382
  const body = m[1];
389
383
 
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
-
394
- const parsed = parseMention(body);
395
-
396
- let ref = null;
397
- if (parsed.kind === 'cite-ref') {
398
- ref = handleCiteRef(parsed, allSources);
399
- } else if (parsed.kind === 'numeric') {
400
- ref = handleNumeric(parsed, refMaps.register, uriBase);
401
- } else if (parsed.kind === 'urn-ref') {
402
- // {{urn:...,render term}} — resolve URN via pattern index (cross-dataset)
403
- const uri = parsed.uri;
404
- const term = parsed.label ?? uri;
405
- const pattern = refMaps.patternIndex.resolve(uri);
406
- if (pattern) {
407
- ref = { id: buildConceptUri(refMaps.uriBase, pattern.datasetId, pattern.conceptId), term };
408
- } else {
409
- ref = { id: uri, term };
384
+ // Strict parse — throws InvalidMentionError on invalid syntax.
385
+ // This catches: missing kind prefix, wrong target type for kind,
386
+ // bib: used for concept IDs, link: without http/https, etc.
387
+ let segments;
388
+ try {
389
+ segments = parseMentions(`{{${body}}}`);
390
+ } catch (e) {
391
+ if (e instanceof InvalidMentionError) {
392
+ // Report the error but don't crash — collect for batch reporting.
393
+ console.warn(` Invalid mention: {{${body}}} — ${e.reason}`);
394
+ continue;
395
+ }
396
+ throw e;
397
+ }
398
+
399
+ // Extract mention segments (skip text segments).
400
+ for (const seg of segments) {
401
+ if (seg.kind === 'text') continue;
402
+
403
+ // bib:/link:/image: are deployment-specific renderings — NOT concept
404
+ // cross-references. They must NOT appear in gl:references.
405
+ if (seg.kind === 'bib' || seg.kind === 'link' || seg.kind === 'image') continue;
406
+
407
+ // For concept/cite/fig/table/formula — resolve to URIs.
408
+ // fig/table/formula resolve to entity refs (handled separately).
409
+ if (seg.kind === 'concept' || seg.kind === 'cite') {
410
+ const target = seg.target;
411
+ if (target.type === 'dataset_qualified' && target.dataset && target.id) {
412
+ const dsId = refMaps.refPrefixMap[target.dataset] || target.dataset;
413
+ const ref = { id: buildConceptUri(refMaps.uriBase, dsId, target.id), term: seg.label ?? `${target.dataset}:${target.id}` };
414
+ refs.push(ref);
415
+ } else if (target.type === 'urn' && target.urn) {
416
+ const pattern = refMaps.patternIndex.resolve(target.urn);
417
+ if (pattern) {
418
+ refs.push({ id: buildConceptUri(refMaps.uriBase, pattern.datasetId, pattern.conceptId), term: seg.label ?? target.urn });
419
+ }
420
+ }
410
421
  }
411
- } else if (parsed.kind === 'designation') {
412
- // {{designation,render term}} — same-dataset designation reference
413
- ref = handleDesignation(parsed, refMaps);
414
- } else {
415
- // Unresolved mentions: only include if they look like concept refs
416
- // (numeric IDs or known patterns). Never include bib:/link:/image:.
417
- ref = handleUnresolved(body, refMaps);
418
422
  }
419
- if (ref) refs.push(ref);
420
423
  }
421
424
 
422
425
  // Deduplicate by id
@@ -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
- // gl:related entries (concept-level)
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
+ });