@glossarist/concept-browser 0.7.129 → 0.7.131

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.129",
3
+ "version": "0.7.131",
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.55",
46
+ "glossarist": "^0.4.56",
47
47
  "js-yaml": "^4.1.0",
48
48
  "jszip": "^3.10.1",
49
49
  "n3": "^1.26.0",
@@ -1163,6 +1163,7 @@ function extractInlineRefs(localizedData, refMaps2, conceptSources = []) {
1163
1163
  }
1164
1164
  for (const m of fullText.matchAll(/\{\{([^{}]+?)\}\}/g)) {
1165
1165
  const body = m[1];
1166
+ if (/^(bib|link|image):/i.test(body)) continue;
1166
1167
  let segments;
1167
1168
  try {
1168
1169
  segments = parseMentions(`{{${body}}}`);
@@ -1188,6 +1189,8 @@ function extractInlineRefs(localizedData, refMaps2, conceptSources = []) {
1188
1189
  if (pattern) {
1189
1190
  refs.push({ id: buildConceptUri(refMaps2.uriBase, pattern.datasetId, pattern.conceptId), term: seg.label ?? target.urn });
1190
1191
  }
1192
+ } else if (target.type === "entity_id" && target.id) {
1193
+ refs.push({ id: buildConceptUri(refMaps2.uriBase, refMaps2.register, target.id), term: seg.label ?? target.id });
1191
1194
  }
1192
1195
  }
1193
1196
  }
@@ -375,54 +375,46 @@ function extractInlineRefs(localizedData, refMaps, conceptSources = []) {
375
375
  if (pattern) { refs.push(pattern); }
376
376
  }
377
377
 
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.
378
+ // Double-brace mentions: use the strict parser from glossarist@0.4.56.
379
+ // Bare numeric IDs and designations are implicit same-dataset concept refs.
381
380
  for (const m of fullText.matchAll(/\{\{([^{}]+?)\}\}/g)) {
382
381
  const body = m[1];
383
382
 
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.
383
+ // Skip deployment-specific kinds (not concept cross-references)
384
+ if (/^(bib|link|image):/i.test(body)) continue;
385
+
387
386
  let segments;
388
387
  try {
389
388
  segments = parseMentions(`{{${body}}}`);
390
389
  } catch (e) {
391
390
  if (e instanceof InvalidMentionError) {
392
- // Report the error but don't crash — collect for batch reporting.
393
391
  console.warn(` Invalid mention: {{${body}}} — ${e.reason}`);
394
392
  continue;
395
393
  }
396
394
  throw e;
397
395
  }
398
396
 
399
- // Extract mention segments (skip text segments).
400
397
  for (const seg of segments) {
401
398
  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
399
  if (seg.kind === 'bib' || seg.kind === 'link' || seg.kind === 'image') continue;
406
400
 
407
- // For concept/cite/fig/table/formula — resolve to URIs.
408
- // fig/table/formula resolve to entity refs (handled separately).
409
401
  if (seg.kind === 'concept' || seg.kind === 'cite') {
410
402
  const target = seg.target;
411
403
  if (target.type === 'dataset_qualified' && target.dataset && target.id) {
412
- // Only create a concept URI if the dataset is CO-DEPLOYED.
413
- // If the dataset doesn't exist in this deployment's refPrefixMap,
414
- // skip — the runtime resolver will handle it (unresolved text or
415
- // external link via routing). Creating a URI for a non-existent
416
- // dataset produces a 404.
404
+ // Cross-dataset: only if co-deployed
417
405
  const dsId = refMaps.refPrefixMap[target.dataset];
418
406
  if (dsId) {
419
407
  refs.push({ id: buildConceptUri(refMaps.uriBase, dsId, target.id), term: seg.label ?? `${target.dataset}:${target.id}` });
420
408
  }
421
409
  } else if (target.type === 'urn' && target.urn) {
410
+ // URN: resolve via pattern index
422
411
  const pattern = refMaps.patternIndex.resolve(target.urn);
423
412
  if (pattern) {
424
413
  refs.push({ id: buildConceptUri(refMaps.uriBase, pattern.datasetId, pattern.conceptId), term: seg.label ?? target.urn });
425
414
  }
415
+ } else if (target.type === 'entity_id' && target.id) {
416
+ // Same-dataset concept reference (bare numeric ID or designation)
417
+ refs.push({ id: buildConceptUri(refMaps.uriBase, refMaps.register, target.id), term: seg.label ?? target.id });
426
418
  }
427
419
  }
428
420
  }
@@ -0,0 +1,21 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { formatExternalLabel } from '../../utils/external-detection';
3
+
4
+ describe('external concept label formatting', () => {
5
+ it('wraps external concept labels in parentheses', () => {
6
+ expect(formatExternalLabel('precision condition', true)).toBe('(precision condition)');
7
+ });
8
+
9
+ it('does not wrap regular concept labels', () => {
10
+ expect(formatExternalLabel('measurement unit', false)).toBe('measurement unit');
11
+ });
12
+
13
+ it('handles empty labels', () => {
14
+ expect(formatExternalLabel('', true)).toBe('()');
15
+ expect(formatExternalLabel('', false)).toBe('');
16
+ });
17
+
18
+ it('does not double-wrap already parenthesized labels', () => {
19
+ expect(formatExternalLabel('(precision)', true)).toBe('(precision)');
20
+ });
21
+ });
@@ -42,6 +42,51 @@ describe('renderContent', () => {
42
42
  expect(result).not.toContain('|===');
43
43
  });
44
44
 
45
+ it('strips AsciiDoc table attribute lines (cols, options)', () => {
46
+ const input = 'Intro\n\n[cols="3", options="noheader,unnumbered"]\n|===\n| a | b\n|===';
47
+ const result = renderContent(input);
48
+ expect(result).not.toContain('[cols=');
49
+ expect(result).not.toContain('noheader');
50
+ expect(result).toContain('<table class="concept-table">');
51
+ });
52
+
53
+ it('strips cell-span modifiers (.2+, 2+) from cell content', () => {
54
+ const input = '|===\n.2+| cell-a | cell-b\n| cell-c\n|===';
55
+ const result = renderContent(input);
56
+ expect(result).not.toMatch(/\b\.2\+/);
57
+ expect(result).toContain('cell-a');
58
+ expect(result).toContain('cell-b');
59
+ });
60
+
61
+ it('joins + continuation lines into the same cell with <br>', () => {
62
+ const input = '|===\n| english line +\nfrench line | cell-b\n|===';
63
+ const result = renderContent(input);
64
+ expect(result).toContain('english line<br>french line');
65
+ expect(result).toContain('<th>cell-b</th>');
66
+ expect(result).not.toMatch(/\s\+\s*\|/);
67
+ });
68
+
69
+ it('groups cells of the same row when continuation is used', () => {
70
+ // VIM-style bilingual layout: each `|` after a `+` is a new cell in
71
+ // the SAME row, not a new row.
72
+ const input = '|===\n.2+| length +\nlongueur | radius +\nrayon\n|===';
73
+ const result = renderContent(input);
74
+ expect(result).toContain('length');
75
+ expect(result).toContain('longueur');
76
+ expect(result).toContain('radius');
77
+ expect(result).toContain('rayon');
78
+ expect(result).toContain('length<br>longueur');
79
+ expect(result).toContain('radius<br>rayon');
80
+ });
81
+
82
+ it('preserves rendered math spans inside table cells (no double-escaping)', () => {
83
+ const input = '|===\n| length, stem:[l] | radius, stem:[r]\n|===';
84
+ const result = renderContent(input);
85
+ expect(result).toContain('data-expr="l"');
86
+ expect(result).toContain('data-expr="r"');
87
+ expect(result).not.toContain('&lt;span');
88
+ });
89
+
45
90
  it('resolves URN inline refs via xrefResolver', () => {
46
91
  const resolver = (uri: string, term: string) => `[${term}→${uri}]`;
47
92
  const result = renderContent(
@@ -4,16 +4,7 @@
4
4
  * decompositions as ISO 704 rake diagrams. Mirror of
5
5
  * PartitiveRelationList with type label swapped.
6
6
  *
7
- * Each relation card shows:
8
- * - completeness badge (Complete / Partial)
9
- * - the comprehensive (genus) designation as the card title
10
- * - optional criterion as italic text
11
- * - the rake diagram itself (PartitiveRelationDiagram, reused —
12
- * the line notation is identical for generic rakes)
13
- *
14
- * OIML V 2-200:2010 has ~8 generic hyperedges; 5.1 measurement
15
- * standard alone has 6 criterion groups. This component is the
16
- * primary rendering surface for those datasets.
7
+ * External concepts are parenthesized per ISO 704 §5.5.4.3.1.
17
8
  */
18
9
  import type { GenericRelationWire, Manifest } from '../adapters/types';
19
10
  import { useRouter } from 'vue-router';
@@ -23,6 +14,7 @@ import { useI18n, locale } from '../i18n';
23
14
  import { completenessLabel } from '../utils/partitive-relation-styling';
24
15
  import { presenceLabel, countLabel } from '../utils/partitive-multiplicity';
25
16
  import { resolveDesignation } from '../utils/resolve-designation';
17
+ import { isExternalMember, isExternalComprehensive, formatExternalLabel } from '../utils/external-detection';
26
18
  import PartitiveRelationDiagram, {
27
19
  type PartitiveMemberLabeled,
28
20
  } from './PartitiveRelationDiagram.vue';
@@ -38,8 +30,41 @@ const store = useVocabularyStore();
38
30
  const factory = getFactory();
39
31
  const { t } = useI18n();
40
32
 
41
- function designationFor(uri: string): string {
42
- return resolveDesignation(uri, store, factory, locale.value);
33
+ const externalCache = new Map<string, boolean>();
34
+
35
+ const externalStore = {
36
+ lookup(ref: { source?: string | null; id?: string | null }) {
37
+ if (!ref?.source && !ref?.id) return null;
38
+ for (const adapter of store.datasets.values()) {
39
+ for (const entry of adapter.getConcepts() ?? []) {
40
+ if (entry && entry.id === ref.id) {
41
+ return {
42
+ status: entry.status,
43
+ related: (entry as any).relatedConcepts ?? (entry as any).related ?? [],
44
+ };
45
+ }
46
+ }
47
+ }
48
+ return null;
49
+ },
50
+ };
51
+
52
+ function isExternalUri(uri: string, kind: 'member' | 'comprehensive'): boolean {
53
+ if (externalCache.has(uri)) return externalCache.get(uri)!;
54
+ const ref = { source: uri, id: uri.split('/').pop() ?? uri };
55
+ let isExt: boolean;
56
+ if (kind === 'comprehensive') {
57
+ isExt = isExternalComprehensive({ comprehensive: ref, members: [] }, externalStore);
58
+ } else {
59
+ isExt = isExternalMember({ ref }, externalStore);
60
+ }
61
+ externalCache.set(uri, isExt);
62
+ return isExt;
63
+ }
64
+
65
+ function designationFor(uri: string, kind: 'member' | 'comprehensive' = 'member'): string {
66
+ const label = resolveDesignation(uri, store, factory, locale.value);
67
+ return formatExternalLabel(label, isExternalUri(uri, kind));
43
68
  }
44
69
 
45
70
  /** Resolve a LocalizedString delimitingCharacteristic to the current locale. */
@@ -53,7 +78,7 @@ function memberLabel(member: GenericRelationWire['members'][number]): PartitiveM
53
78
  presence: member.presence,
54
79
  count: member.count,
55
80
  isDelimiting: false,
56
- label: designationFor(member.uri),
81
+ label: designationFor(member.uri, 'member'),
57
82
  };
58
83
  }
59
84
  </script>
@@ -70,7 +95,7 @@ function memberLabel(member: GenericRelationWire['members'][number]): PartitiveM
70
95
  {{ completenessLabel(rel.completeness) }}
71
96
  </span>
72
97
  <span class="badge badge-type">{{ t('relations.generic') }}</span>
73
- <h4 class="comprehensive">{{ designationFor(rel.comprehensive) }}</h4>
98
+ <h4 class="comprehensive">{{ designationFor(rel.comprehensive, 'comprehensive') }}</h4>
74
99
  </header>
75
100
 
76
101
  <p v-if="rel.criterion" class="criterion">
@@ -79,13 +104,13 @@ function memberLabel(member: GenericRelationWire['members'][number]): PartitiveM
79
104
 
80
105
  <PartitiveRelationDiagram
81
106
  :partitives="rel.members.map(memberLabel)"
82
- :comprehensive-label="designationFor(rel.comprehensive)"
107
+ :comprehensive-label="designationFor(rel.comprehensive, 'comprehensive')"
83
108
  :completeness="rel.completeness"
84
109
  />
85
110
 
86
111
  <ul class="member-presence-list" aria-label="Member delimiting characteristics">
87
112
  <li v-for="(m, j) in rel.members" :key="j">
88
- <span class="member-label">{{ designationFor(m.uri) }}</span>
113
+ <span class="member-label">{{ designationFor(m.uri, 'member') }}</span>
89
114
  <span
90
115
  v-if="m.delimitingCharacteristic"
91
116
  class="member-delimiting-characteristic"
@@ -98,17 +98,138 @@ function mathPlaceholder(expr: string, format: string, bold: boolean): string {
98
98
 
99
99
  // ── Block transforms ─────────────────────────────────────────────────────
100
100
 
101
+ /**
102
+ * AsciiDoc table attribute line, e.g.
103
+ * [cols="3", options="noheader,unnumbered"]
104
+ * [format="dsv"]
105
+ * Lives immediately before the `|===` opener. The renderer does not honour
106
+ * these attributes (no column sizing, no header suppression), so we strip
107
+ * them to avoid leaking as literal text in the output.
108
+ */
109
+ const ASCII_DOC_TABLE_ATTR_RE = /\n?\[[^\]\n]+\]\s*\n(?=\n?\|===)/g;
110
+
111
+ /**
112
+ * Pre-row span/alignment modifier prefix that can appear before a leading
113
+ * pipe:
114
+ * .2+ → cell spans 2 columns
115
+ * .3+ → cell spans 3 columns
116
+ * 2+ → cell spans 2 rows
117
+ * a> → horizontal alignment
118
+ * The renderer does not honour spans, so we strip the modifier.
119
+ */
120
+ const ASCII_DOC_LEADING_SPAN_PIPE_RE = /^[.\d+]+[+>]+\s*\|/;
121
+
122
+ /**
123
+ * Cell-span modifier prefix that can appear inside a cell (after the pipe):
124
+ * .2+ cell content → cell content
125
+ * Kept separate from ASCII_DOC_LEADING_SPAN_PIPE_RE because the leading
126
+ * variant consumes the pipe whereas this one only consumes the modifier.
127
+ */
128
+ const ASCII_DOC_CELL_SPAN_RE = /^[.]\d+[+]|[.]\d+>[>]?\s*/g;
129
+
130
+ /**
131
+ * Trailing cell-continuation marker ` +` at the end of a row line means
132
+ * "the next physical line continues this cell". The renderer joins such
133
+ * lines into the same cell with a `<br>` separator.
134
+ */
135
+ const ASCII_DOC_CELL_CONTINUATION_RE = /\s\+\s*$/;
136
+
137
+ function stripAsciiDocTableAttributes(text: string): string {
138
+ return text.replace(ASCII_DOC_TABLE_ATTR_RE, '\n');
139
+ }
140
+
101
141
  function convertAsciiDocTables(text: string): string {
102
- return text.replace(/\n?\|===\n([\s\S]*?)\n\|===/g, (_: string, body: string) => {
103
- const rows: string[] = body.split('\n').filter((line: string) => line.trim() !== '');
104
- if (!rows.length) return '';
142
+ const prepared = stripAsciiDocTableAttributes(text);
143
+ return prepared.replace(/\n?\|===\n([\s\S]*?)\n\|===/g, (_: string, body: string) => {
144
+ const lines: string[] = body.split('\n');
145
+ if (!lines.some((l: string) => l.trim() !== '')) return '';
146
+
147
+ // State machine for joining `+` continuation lines into the same cell
148
+ // and grouping cells into rows. Two formats are supported:
149
+ //
150
+ // 1. Simple one-row-per-line:
151
+ // | a | b | c
152
+ // | d | e | f
153
+ // Each leading `|` starts a new row.
154
+ //
155
+ // 2. Continuation-based multi-cell rows (used by VIM and other
156
+ // bilingual sources):
157
+ // | english-line +
158
+ // french line | next-cell +
159
+ // more of next-cell
160
+ // A `|` at the start of a line, AFTER the current row has used
161
+ // a `+` continuation, starts a new CELL in the SAME row.
162
+ //
163
+ // The disambiguation state is `rowHasContinuation`: once any line in
164
+ // the current row has used `+`, subsequent leading `|` lines are new
165
+ // cells in that row. Otherwise (simple format), they are new rows.
166
+ const rows: string[][] = [];
167
+ let currentRow: string[] | null = null;
168
+ let rowHasContinuation = false;
169
+ let prevLineEndedWithContinuation = false;
170
+
171
+ const flushRow = () => {
172
+ if (currentRow !== null && currentRow.length > 0) rows.push(currentRow);
173
+ currentRow = null;
174
+ rowHasContinuation = false;
175
+ prevLineEndedWithContinuation = false;
176
+ };
177
+
178
+ for (const line of lines) {
179
+ if (line.trim() === '') {
180
+ flushRow();
181
+ continue;
182
+ }
105
183
 
106
- const parsedRows: string[][] = rows.map((row: string) => {
107
- const cellText = row.replace(/^\s*\|/, '').trim();
108
- const cells = cellText.split(/\s*\|\s*/).map((c: string) => c.trim()).filter((c: string) => c !== '');
109
- return cells;
110
- }).filter((r: string[]) => r.length > 0);
184
+ const startsWithPipe = ASCII_DOC_LEADING_SPAN_PIPE_RE.test(line) || /^\s*\|/.test(line);
185
+ const endsWithContinuation = ASCII_DOC_CELL_CONTINUATION_RE.test(line);
186
+ const cleaned = line
187
+ .replace(ASCII_DOC_LEADING_SPAN_PIPE_RE, '|')
188
+ .replace(ASCII_DOC_CELL_CONTINUATION_RE, '')
189
+ .trim();
190
+
191
+ if (startsWithPipe && !rowHasContinuation) {
192
+ // New row in simple format.
193
+ flushRow();
194
+ const cellText = cleaned.replace(/^\s*\|/, '').trim();
195
+ const newCells = cellText.split(/\s*\|\s*/).map(stripCellSpanModifier).filter(c => c !== '');
196
+ currentRow = newCells;
197
+ } else if (startsWithPipe && rowHasContinuation) {
198
+ // New cell in the same row (continuation format).
199
+ if (currentRow === null) currentRow = [];
200
+ const cellText = cleaned.replace(/^\s*\|/, '').trim();
201
+ const newCells = cellText.split(/\s*\|\s*/).map(stripCellSpanModifier).filter(c => c !== '');
202
+ currentRow.push(...newCells);
203
+ } else if (currentRow !== null && prevLineEndedWithContinuation) {
204
+ // Continuation of the previous cell — append with <br>.
205
+ // The continuation line may itself contain `|`-separated cells,
206
+ // so split it; the first piece joins the last cell, the rest
207
+ // become new cells in the same row.
208
+ const pieces = cleaned.split(/\s*\|\s*/).map(stripCellSpanModifier).filter(c => c !== '');
209
+ if (pieces.length > 0 && currentRow.length > 0) {
210
+ currentRow[currentRow.length - 1] = `${currentRow[currentRow.length - 1]}<br>${pieces[0]}`;
211
+ if (pieces.length > 1) currentRow.push(...pieces.slice(1));
212
+ } else if (pieces.length > 0) {
213
+ currentRow.push(pieces[0]);
214
+ if (pieces.length > 1) currentRow.push(...pieces.slice(1));
215
+ }
216
+ } else {
217
+ // Stray non-pipe line outside any continuation — treat as a new
218
+ // single-cell row so the content is not lost.
219
+ flushRow();
220
+ currentRow = [cleaned];
221
+ }
111
222
 
223
+ if (endsWithContinuation) {
224
+ rowHasContinuation = true;
225
+ prevLineEndedWithContinuation = true;
226
+ } else {
227
+ prevLineEndedWithContinuation = false;
228
+ }
229
+ }
230
+ flushRow();
231
+
232
+ const parsedRows: string[][] = rows.filter((r: string[]) => r.length > 0);
112
233
  if (!parsedRows.length) return '';
113
234
 
114
235
  const maxCols = Math.max(...parsedRows.map((r: string[]) => r.length));
@@ -117,15 +238,24 @@ function convertAsciiDocTables(text: string): string {
117
238
  return r;
118
239
  });
119
240
 
120
- const thead = normalized[0].map((c: string) => `<th>${escapeHtml(c)}</th>`).join('');
241
+ // Cells may already contain rendered math spans (from stage 1) and
242
+ // inline formatting (italic, subscript) from stage 3. escapeHtml
243
+ // would double-escape the angle brackets inside those spans and
244
+ // cause the browser to render them as literal text. Pass cell
245
+ // content through unchanged.
246
+ const thead = normalized[0].map((c: string) => `<th>${c}</th>`).join('');
121
247
  const tbody = normalized.slice(1).map((r: string[]) =>
122
- `<tr>${r.map((c: string) => `<td>${escapeHtml(c)}</td>`).join('')}</tr>`
248
+ `<tr>${r.map((c: string) => `<td>${c}</td>`).join('')}</tr>`
123
249
  ).join('');
124
250
 
125
251
  return `\n<table class="concept-table"><thead><tr>${thead}</tr></thead><tbody>${tbody}</tbody></table>`;
126
252
  });
127
253
  }
128
254
 
255
+ function stripCellSpanModifier(cell: string): string {
256
+ return cell.replace(ASCII_DOC_CELL_SPAN_RE, '').trim();
257
+ }
258
+
129
259
  function convertLists(text: string): string {
130
260
  let result = text.replace(/(?:^|\n)((?:[ \t]*\* [^\n]+)(?:\n[ \t]*\* [^\n]+)*)/g, (_, block) => {
131
261
  if (/^\*stem:\[/.test(block.trimStart())) return _;
@@ -97,5 +97,7 @@ export function formatExternalLabel(
97
97
  label: string,
98
98
  isExternal: boolean,
99
99
  ): string {
100
- return isExternal ? `(${label})` : label;
100
+ if (!isExternal) return label;
101
+ if (label.startsWith('(') && label.endsWith(')')) return label;
102
+ return `(${label})`;
101
103
  }