glossarist 0.3.4 → 0.3.6

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",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "JavaScript SDK for Glossarist GCR packages — read, write, validate, and manage terminology concepts",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -5,7 +5,6 @@ const _items = Symbol('items');
5
5
  export class ConceptCollection {
6
6
  constructor(concepts = []) {
7
7
  this[_items] = Array.from(concepts);
8
- return new Proxy(this, _handler);
9
8
  }
10
9
 
11
10
  get length() { return this[_items].length; }
@@ -23,10 +22,35 @@ export class ConceptCollection {
23
22
  splice(...args) { return this[_items].splice(...args); }
24
23
  set(index, item) { this[_items][index] = item; }
25
24
 
25
+ toArray() { return [...this[_items]]; }
26
+
26
27
  byId(id) {
27
28
  return this[_items].find(c => c.id === id || c.termid === id);
28
29
  }
29
30
 
31
+ /**
32
+ * Find a concept by id and version.
33
+ *
34
+ * The version is matched against a top-level `version` field on
35
+ * the concept instance (e.g. `concept.version`). The deployment
36
+ * is responsible for setting this field on bibliographic records;
37
+ * the data model does not enforce its presence or type.
38
+ *
39
+ * If `version` is null, the method falls back to `byId(id)`. This
40
+ * supports the "version-agnostic" lookup for datasets where
41
+ * version is not tracked.
42
+ *
43
+ * @param {string} id
44
+ * @param {string | null} version
45
+ * @returns {Concept | null}
46
+ */
47
+ byIdAnd(id, version) {
48
+ if (version == null) return this.byId(id);
49
+ return this[_items].find(c =>
50
+ (c.id === id || c.termid === id) && c.version === version
51
+ ) ?? null;
52
+ }
53
+
30
54
  byPrefix(prefix) {
31
55
  return new ConceptCollection(this[_items].filter(c => c.id.startsWith(prefix)));
32
56
  }
@@ -90,27 +114,3 @@ export class ConceptCollection {
90
114
  slice(...args) { return new ConceptCollection(this[_items].slice(...args)); }
91
115
  concat(...args) { return new ConceptCollection(this[_items].concat(...args)); }
92
116
  }
93
-
94
- const _handler = {
95
- get(target, prop, receiver) {
96
- if (typeof prop === 'string' && /^\d+$/.test(prop)) {
97
- return target[_items][Number(prop)];
98
- }
99
- if (prop === 'length') return target[_items].length;
100
- const value = Reflect.get(target, prop, receiver);
101
- return typeof value === 'function' ? value.bind(target) : value;
102
- },
103
- set(target, prop, value) {
104
- if (typeof prop === 'string' && /^\d+$/.test(prop)) {
105
- target[_items][Number(prop)] = value;
106
- return true;
107
- }
108
- return Reflect.set(target, prop, value);
109
- },
110
- has(target, prop) {
111
- if (typeof prop === 'string' && /^\d+$/.test(prop)) {
112
- return Number(prop) in target[_items];
113
- }
114
- return Reflect.has(target, prop);
115
- },
116
- };
package/src/gcr-reader.js CHANGED
@@ -367,12 +367,42 @@ export class GcrPackage {
367
367
  return map;
368
368
  }
369
369
 
370
- /** @private @param {string} filePath @returns {Promise<string | null>} */
371
- async _readText(filePath) {
370
+ /**
371
+ * Read a text file from the package.
372
+ * @param {string} filePath
373
+ * @returns {Promise<string | null>} null if the file doesn't exist
374
+ */
375
+ async readText(filePath) {
372
376
  const entry = this._zip.file(filePath);
373
377
  if (!entry) return null;
374
378
  return entry.async('text');
375
379
  }
380
+
381
+ /**
382
+ * List all entry paths in the package.
383
+ * @returns {Array<{ path: string, dir: boolean }>}
384
+ */
385
+ entryPaths() {
386
+ const entries = [];
387
+ this._zip.forEach((relativePath, entry) => {
388
+ entries.push({ path: relativePath, dir: !!entry.dir });
389
+ });
390
+ return entries;
391
+ }
392
+
393
+ /**
394
+ * Check whether a specific entry exists in the package.
395
+ * @param {string} filePath
396
+ * @returns {boolean}
397
+ */
398
+ hasEntry(filePath) {
399
+ return this._zip.file(filePath) != null;
400
+ }
401
+
402
+ /** @private @deprecated Use readText instead */
403
+ async _readText(filePath) {
404
+ return this.readText(filePath);
405
+ }
376
406
  }
377
407
 
378
408
  // --- Concept YAML parsing ---
package/src/index.d.ts CHANGED
@@ -34,6 +34,16 @@ export { conceptUuid, localizedConceptUuid, uuidV5 } from './uuid';
34
34
  // Reference resolution
35
35
  export { ReferenceResolver, Reference, referenceResolver } from './reference-resolver';
36
36
 
37
+ export type MentionParseResult = {
38
+ kind: 'cite-ref' | 'numeric' | 'designation' | 'unresolved';
39
+ key?: string;
40
+ label?: string | null;
41
+ id?: string;
42
+ raw: string;
43
+ };
44
+
45
+ export function parseMention(raw: string): MentionParseResult;
46
+
37
47
  // V1 support
38
48
  export { V1Reader, migrateV1ToV2 } from './v1-reader';
39
49
 
package/src/index.js CHANGED
@@ -8,6 +8,8 @@ export { ManagedConceptCollection } from './managed-concept-collection.js';
8
8
  export { validateConcept, validateRegister, validateGcrPackage, createConceptValidator, ValidationError, ValidationRule, ValidationResult, RegisterValidator, GcrValidator } from './validators/index.js';
9
9
  export { conceptUuid, localizedConceptUuid, uuidV5 } from './uuid.js';
10
10
  export { ReferenceResolver, Reference, referenceResolver } from './reference-resolver.js';
11
+ export { parseMention } from './reference-mention.js';
12
+ export { ReferenceClassifier } from './render-classification.js';
11
13
  export { V1Reader, migrateV1ToV2 } from './v1-reader.js';
12
14
  export { GlossaristError, InvalidInputError, YamlParseError } from './errors.js';
13
15
 
@@ -4,6 +4,7 @@ import { Citation } from './citation.js';
4
4
  export class ConceptSource extends GlossaristModel {
5
5
  constructor(data = {}) {
6
6
  super();
7
+ this.id = data.id ?? null;
7
8
  this.status = data.status ?? null;
8
9
  this.type = data.type ?? null;
9
10
  this.origin = data.origin
@@ -14,6 +15,7 @@ export class ConceptSource extends GlossaristModel {
14
15
 
15
16
  toJSON() {
16
17
  const obj = {};
18
+ if (this.id != null) obj.id = this.id;
17
19
  if (this.status != null) obj.status = this.status;
18
20
  if (this.type != null) obj.type = this.type;
19
21
  if (this.origin != null) obj.origin = this.origin.toJSON();
@@ -30,6 +30,11 @@ export class Concept extends GlossaristModel {
30
30
  return Object.keys(this._rawLocalizations);
31
31
  }
32
32
 
33
+ get languageCodes() {
34
+ return this.languages;
35
+ }
36
+
37
+ /** @deprecated Use localization(lang) for model access, or toJSON().localizations for raw data */
33
38
  get localizations() {
34
39
  return this._rawLocalizations;
35
40
  }
@@ -68,6 +73,45 @@ export class Concept extends GlossaristModel {
68
73
  return lang in this._rawLocalizations;
69
74
  }
70
75
 
76
+ /**
77
+ * Find a source by its local id within this concept.
78
+ *
79
+ * The lookup walks concept-level, localization-level, and
80
+ * designation-level sources in that order, returning the first
81
+ * source whose `id` matches. Sources without an `id` are
82
+ * skipped. The id is local to the concept; uniqueness is
83
+ * enforced by the validator (see CiteRefIntegrityRule).
84
+ *
85
+ * @param {string} id
86
+ * @returns {ConceptSource | null}
87
+ */
88
+ findSourceById(id) {
89
+ if (typeof id !== 'string' || id.length === 0) return null;
90
+
91
+ // 1. Concept-level sources.
92
+ for (const source of this.sources) {
93
+ if (source.id === id) return source;
94
+ }
95
+
96
+ // 2. Localization-level sources.
97
+ for (const lang of this.languages) {
98
+ const lc = this.localization(lang);
99
+ if (!lc) continue;
100
+ for (const source of lc.sources) {
101
+ if (source.id === id) return source;
102
+ }
103
+
104
+ // 3. Designation-level sources.
105
+ for (const designation of lc.terms) {
106
+ for (const source of designation.sources) {
107
+ if (source.id === id) return source;
108
+ }
109
+ }
110
+ }
111
+
112
+ return null;
113
+ }
114
+
71
115
  toJSON() {
72
116
  const obj = { id: this.id };
73
117
  if (this.term != null) obj.term = this.term;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Mention parser for {{...}} inline references in concept text.
3
+ *
4
+ * Pure function: takes a raw mention body (the text inside
5
+ * {{...}}) and returns a structured MentionParseResult.
6
+ *
7
+ * Convention: the ID always comes first, the display (render) text
8
+ * always comes last. Every comma-separated form follows this:
9
+ *
10
+ * {{id}} bare ID
11
+ * {{id, display text}} ID + display text
12
+ * {{cite:key}} cite-key (source id)
13
+ * {{cite:key, display text}} cite-key + display text
14
+ *
15
+ * @typedef {Object} MentionParseResult
16
+ * @property {'cite-ref' | 'numeric' | 'designation' | 'unresolved'} kind
17
+ * @property {string} [key] — for 'cite-ref': the local key
18
+ * @property {string} [label] — display text (always last)
19
+ * @property {string} [id] — for 'numeric' / 'designation': the id
20
+ * @property {string} raw — the original mention body
21
+ */
22
+
23
+ const NUMERIC_RE = /^\d+(?:[.-]\d+)+$/;
24
+
25
+ /**
26
+ * Parse the body of a {{...}} mention (without the braces).
27
+ *
28
+ * The function is pure: no I/O, no model lookups, no state.
29
+ * Resolution of the parsed result is the extractor's job.
30
+ *
31
+ * @param {string} raw — the trimmed text inside {{...}}
32
+ * @returns {MentionParseResult}
33
+ */
34
+ export function parseMention(raw) {
35
+ const body = raw.trim();
36
+
37
+ // 1. cite:<key>[,display] — explicit citation reference.
38
+ // Key is the source id; display text is optional.
39
+ const citeMatch = body.match(/^cite:([^,}]+)(?:,(.*))?$/);
40
+ if (citeMatch) {
41
+ const label = citeMatch[2] !== undefined ? unquoteLabel(citeMatch[2].trim()) : null;
42
+ return {
43
+ kind: 'cite-ref',
44
+ key: citeMatch[1].trim(),
45
+ label,
46
+ raw: body,
47
+ };
48
+ }
49
+
50
+ // 2. Comma-separated form: {{id, display}}.
51
+ // ID always comes first, display text always comes last.
52
+ const commaIdx = body.indexOf(',');
53
+ if (commaIdx !== -1) {
54
+ const id = body.slice(0, commaIdx).trim();
55
+ const label = unquoteLabel(body.slice(commaIdx + 1).trim());
56
+
57
+ if (NUMERIC_RE.test(id)) {
58
+ return { kind: 'numeric', id, label, raw: body };
59
+ }
60
+ return { kind: 'designation', id, label, raw: body };
61
+ }
62
+
63
+ // 3. Bare numeric id.
64
+ if (NUMERIC_RE.test(body)) {
65
+ return { kind: 'numeric', id: body, label: null, raw: body };
66
+ }
67
+
68
+ // 4. Anything else is unresolved at the parse layer.
69
+ return { kind: 'unresolved', raw: body };
70
+ }
71
+
72
+ /**
73
+ * Strip surrounding double quotes from a label, unescaping
74
+ * CSV-style "" to a single ". If the input is not quoted,
75
+ * return it unchanged.
76
+ */
77
+ function unquoteLabel(label) {
78
+ if (label.length >= 2 && label.startsWith('"') && label.endsWith('"')) {
79
+ return label.slice(1, -1).replace(/""/g, '"');
80
+ }
81
+ return label;
82
+ }
@@ -1,11 +1,35 @@
1
1
  import { ConceptRef } from './models/concept-ref.js';
2
+ import { parseMention } from './reference-mention.js';
2
3
 
3
4
  export class Reference {
4
- constructor(type, target, relationship, source) {
5
+ /**
6
+ * @param {string} type — the structural kind of the reference
7
+ * ('concept', 'dataset', 'bibliography', 'typed-ref',
8
+ * 'standard').
9
+ * @param {string | null} target — the legacy flat display
10
+ * string. Kept for backward compat with callers that only
11
+ * read `r.target`.
12
+ * @param {string | null} [relationship] — the type of the
13
+ * relationship that produced this reference (e.g. 'see',
14
+ * 'supersedes', 'source').
15
+ * @param {string | null} [source] — a JSON-pointer-ish path
16
+ * indicating where in the concept the reference was
17
+ * extracted from.
18
+ * @param {object} [extras] — additional fields (v8+):
19
+ * `citation`, `sourceId`, `resolution`, `lookupKey`,
20
+ * `label`, `quoted`, `uri`. All optional.
21
+ */
22
+ constructor(type, target, relationship, source, extras = {}) {
5
23
  this.type = type;
6
24
  this.target = target;
7
25
  this.relationship = relationship ?? null;
8
26
  this.source = source ?? null;
27
+
28
+ this.uri = extras.uri ?? null;
29
+ this.citation = extras.citation ?? null;
30
+ this.sourceId = extras.sourceId ?? null;
31
+ this.resolution = extras.resolution ?? null;
32
+ this.lookupKey = extras.lookupKey ?? null;
9
33
  }
10
34
  }
11
35
 
@@ -18,6 +42,21 @@ function refTarget(rc) {
18
42
  }
19
43
 
20
44
  export class ReferenceResolver {
45
+ /**
46
+ * Extract all embedded references from a concept's localizations.
47
+ *
48
+ * Walks definitions, notes, examples, and annotations text.
49
+ * For each `{{...}}` mention, runs `parseMention` to
50
+ * classify the form, then dispatches:
51
+ * - 'cite-ref' → look up the key in concept.sources; emit
52
+ * Bibliography Reference with the Citation.
53
+ * - 'numeric' → emit Concept Reference with the bare id
54
+ * (existing behavior).
55
+ * - 'unresolved' → do not emit a Reference.
56
+ *
57
+ * @param {Concept} concept
58
+ * @returns {Reference[]}
59
+ */
21
60
  extractReferences(concept) {
22
61
  const refs = [];
23
62
 
@@ -42,47 +81,272 @@ export class ReferenceResolver {
42
81
  }
43
82
  }
44
83
 
45
- const texts = [
46
- ...(lc.definitions?.map(d => d.content ?? '') ?? []),
47
- ...(lc.notes?.map(n => typeof n === 'object' ? (n.content ?? '') : String(n)) ?? []),
48
- ];
49
- for (const text of texts) {
50
- refs.push(..._extractEmbedded(text));
84
+ const texts = this._collectTexts(lc, lang);
85
+ for (const { text, source } of texts) {
86
+ for (const ref of this._extractFromText(text, source, concept)) {
87
+ refs.push(ref);
88
+ }
51
89
  }
52
90
  }
53
91
 
54
92
  return refs;
55
93
  }
56
94
 
57
- resolveReference(ref, collection) {
58
- if (ref.type !== 'concept') return null;
59
- return collection.byId(ref.target);
95
+ /**
96
+ * Collect all text fields from a localized concept, paired
97
+ * with diagnostic source paths.
98
+ *
99
+ * @param {LocalizedConcept} lc
100
+ * @param {string} lang
101
+ * @returns {{text: string, source: string}[]}
102
+ */
103
+ _collectTexts(lc, lang) {
104
+ const out = [];
105
+ for (let i = 0; (lc.definitions ?? [])[i]; i++) {
106
+ const content = lc.definitions[i]?.content;
107
+ if (typeof content === 'string') {
108
+ out.push({ text: content, source: `localizations.${lang}.definitions[${i}].content` });
109
+ }
110
+ }
111
+ for (let i = 0; (lc.notes ?? [])[i]; i++) {
112
+ const content = typeof lc.notes[i] === 'object'
113
+ ? (lc.notes[i]?.content ?? '')
114
+ : String(lc.notes[i] ?? '');
115
+ if (content) {
116
+ out.push({ text: content, source: `localizations.${lang}.notes[${i}].content` });
117
+ }
118
+ }
119
+ for (let i = 0; (lc.examples ?? [])[i]; i++) {
120
+ const content = lc.examples[i]?.content;
121
+ if (typeof content === 'string') {
122
+ out.push({ text: content, source: `localizations.${lang}.examples[${i}].content` });
123
+ }
124
+ }
125
+ for (let i = 0; (lc.annotations ?? [])[i]; i++) {
126
+ const content = lc.annotations[i]?.content;
127
+ if (typeof content === 'string') {
128
+ out.push({ text: content, source: `localizations.${lang}.annotations[${i}].content` });
129
+ }
130
+ }
131
+ return out;
132
+ }
133
+
134
+ /**
135
+ * Walk a single text string and emit References for each
136
+ * `{{...}}` mention.
137
+ *
138
+ * @param {string} text
139
+ * @param {string} source — diagnostic path
140
+ * @param {Concept} concept — the owning concept (for cite-ref lookup)
141
+ * @returns {Reference[]}
142
+ */
143
+ _extractFromText(text, source, concept) {
144
+ const refs = [];
145
+ const re = /\{\{([^{}]*?)\}\}/g;
146
+ let m;
147
+ while ((m = re.exec(text)) !== null) {
148
+ const parsed = parseMention(m[1]);
149
+ switch (parsed.kind) {
150
+ case 'cite-ref':
151
+ refs.push(this._resolveCiteRef(parsed, source, concept));
152
+ break;
153
+ case 'numeric':
154
+ refs.push(new Reference('concept', parsed.label ?? parsed.id, 'embedded', source, {
155
+ lookupKey: { id: parsed.id },
156
+ }));
157
+ break;
158
+ case 'designation':
159
+ refs.push(new Reference('concept', parsed.label ?? parsed.id, 'embedded', source, {
160
+ lookupKey: { designation: parsed.id },
161
+ }));
162
+ break;
163
+ case 'unresolved':
164
+ break;
165
+ }
166
+ }
167
+ return refs;
168
+ }
169
+
170
+ /**
171
+ * Resolve a `cite-ref` parser result against the concept's
172
+ * sources list. Emits a Bibliography Reference with the
173
+ * resolved Citation (if found) or an unresolved Reference
174
+ * (if not).
175
+ *
176
+ * @param {MentionParseResult} parsed
177
+ * @param {string} source — diagnostic path
178
+ * @param {Concept} concept — the owning concept
179
+ * @returns {Reference}
180
+ */
181
+ _resolveCiteRef(parsed, source, concept) {
182
+ const sourceEntry = concept?.findSourceById(parsed.key) ?? null;
183
+ if (!sourceEntry) {
184
+ return new Reference(
185
+ 'bibliography',
186
+ parsed.label ?? parsed.key,
187
+ null,
188
+ source,
189
+ {
190
+ sourceId: parsed.key,
191
+ citation: null,
192
+ resolution: { kind: 'unresolved', reason: 'no-source' },
193
+ },
194
+ );
195
+ }
196
+ const displayTarget = parsed.label
197
+ ?? sourceEntry.origin?.toString()
198
+ ?? sourceEntry.id;
199
+ return new Reference(
200
+ 'bibliography',
201
+ displayTarget,
202
+ null,
203
+ source,
204
+ {
205
+ sourceId: sourceEntry.id,
206
+ citation: sourceEntry.origin,
207
+ resolution: { kind: 'resolved', sourceId: sourceEntry.id },
208
+ },
209
+ );
210
+ }
211
+
212
+ /**
213
+ * Resolve a single reference against a registry (a map of
214
+ * datasetId → { concepts, register? }). The registry may also
215
+ * include 'bibliography:<source>' keys for bibliographic
216
+ * datasets.
217
+ *
218
+ * For a `type: 'bibliography'` Reference with an inline
219
+ * `citation`, the resolver first tries the bibliography
220
+ * registry (matching `citation.ref` by source/id/version);
221
+ * if not found, returns the inline Citation as a
222
+ * self-contained fallback.
223
+ *
224
+ * For a `type: 'bibliography'` Reference with a `uri` and
225
+ * `resolution.kind === 'bibliography-namespace'`, the
226
+ * resolver tries the bibliography registry by
227
+ * `resolution.source/id/version`.
228
+ *
229
+ * For `type: 'concept'` References with a `lookupKey.id`
230
+ * (id-match, short-id, or numeric), the resolver looks up
231
+ * the id in `lookupKey.dataset`'s ConceptCollection.
232
+ *
233
+ * Backward compat: when the second argument is a
234
+ * ConceptCollection (has `byId` but no `concepts` field), it
235
+ * is treated as a one-key registry of one default dataset.
236
+ */
237
+ resolveReference(ref, registry) {
238
+ if (ref == null) return null;
239
+
240
+ // Backward-compat: single ConceptCollection becomes a
241
+ // one-key registry.
242
+ if (isConceptCollection(registry)) {
243
+ registry = { _default: { concepts: registry } };
244
+ }
245
+ if (registry == null) return null;
246
+
247
+ // 1. cite:key form (Bibliography with inline Citation).
248
+ if (ref.type === 'bibliography' && ref.citation) {
249
+ const bioRecord = this._resolveBibliographyRecord(
250
+ ref.citation.ref,
251
+ registry,
252
+ );
253
+ if (bioRecord) return bioRecord;
254
+ return ref.citation;
255
+ }
256
+
257
+ // 2. URI form (urn:... or https:...) with
258
+ // bibliography-namespace resolution.
259
+ if (ref.uri) {
260
+ if (ref.resolution?.kind === 'bibliography-namespace'
261
+ || (ref.resolution?.source && !ref.resolution?.datasetId)) {
262
+ const bioRecord = this._resolveBibliographyRecord(
263
+ ref.resolution,
264
+ registry,
265
+ );
266
+ if (bioRecord) return bioRecord;
267
+ }
268
+ // Concept URI lookup (for non-bibliography URIs).
269
+ if (ref.resolution?.datasetId) {
270
+ const coll = registry[ref.resolution.datasetId]?.concepts;
271
+ if (coll) {
272
+ return coll.byId(ref.resolution.conceptId);
273
+ }
274
+ }
275
+ return null;
276
+ }
277
+
278
+ // 3. Same-dataset concept id (numeric mention, id-match, etc.).
279
+ if (ref.lookupKey?.id) {
280
+ const coll = registry[ref.lookupKey.dataset]?.concepts;
281
+ if (coll) return coll.byId(ref.lookupKey.id);
282
+ return null;
283
+ }
284
+
285
+ // 3b. Backward-compat: a concept ref with a `target` (id)
286
+ // but no `lookupKey` is looked up in the single
287
+ // collection (backward-compat one-key registry).
288
+ if (ref.type === 'concept' && ref.target) {
289
+ const defaultColl = registry._default?.concepts;
290
+ if (defaultColl) return defaultColl.byId(ref.target);
291
+ // Try every dataset in the registry as a fallback.
292
+ for (const entry of Object.values(registry)) {
293
+ if (entry?.concepts?.byId(ref.target)) {
294
+ return entry.concepts.byId(ref.target);
295
+ }
296
+ }
297
+ return null;
298
+ }
299
+
300
+ // 4. Unanchored designation — search is a separate concern
301
+ // (plan 06). For v8, return null.
302
+ if (ref.lookupKey?.designation) {
303
+ return null;
304
+ }
305
+
306
+ return null;
307
+ }
308
+
309
+ /**
310
+ * Try to resolve a Citation::Ref against the bibliography
311
+ * registry. The ref has shape { source, id, version? }.
312
+ *
313
+ * Returns the matching bibliographic record (a Concept), or
314
+ * null if no match.
315
+ */
316
+ _resolveBibliographyRecord(citationRef, registry) {
317
+ if (!citationRef?.source || !citationRef?.id) return null;
318
+ const bioColl = registry[`bibliography:${citationRef.source}`]?.concepts;
319
+ if (!bioColl) return null;
320
+ if (citationRef.version) {
321
+ return bioColl.byIdAnd(citationRef.id, citationRef.version);
322
+ }
323
+ return bioColl.byId(citationRef.id);
60
324
  }
61
325
 
62
- resolveAll(concept, collection) {
326
+ resolveAll(concept, registry) {
63
327
  const resolved = new Map();
64
328
  for (const ref of this.extractReferences(concept)) {
65
- if (ref.type === 'concept') {
66
- resolved.set(ref.target, this.resolveReference(ref, collection));
329
+ if (ref.type === 'concept' || ref.type === 'bibliography') {
330
+ const target = this.resolveReference(ref, registry);
331
+ if (target != null) {
332
+ const key = ref.target ?? ref.uri ?? ref.sourceId;
333
+ if (key != null) resolved.set(key, target);
334
+ }
67
335
  }
68
336
  }
69
337
  return resolved;
70
338
  }
71
339
  }
72
340
 
73
- const CONCEPT_REF_RE = /\{\{([^}]+)\}\}|\b(\d+(?:\.\d+)+)\b/g;
74
-
75
- function _extractEmbedded(text) {
76
- const refs = [];
77
- let m;
78
- CONCEPT_REF_RE.lastIndex = 0;
79
- while ((m = CONCEPT_REF_RE.exec(text)) !== null) {
80
- const target = m[1] ?? m[2];
81
- if (target && /^\d+(\.\d+)+$/.test(target)) {
82
- refs.push(new Reference('concept', target, 'embedded', null));
83
- }
84
- }
85
- return refs;
341
+ /**
342
+ * Type-guard for the single-collection case (backward compat).
343
+ * A ConceptCollection has `byId` but no `concepts` field.
344
+ */
345
+ function isConceptCollection(x) {
346
+ return x != null
347
+ && typeof x === 'object'
348
+ && typeof x.byId === 'function'
349
+ && !('concepts' in x);
86
350
  }
87
351
 
88
352
  export const referenceResolver = new ReferenceResolver();