@glossarist/concept-browser 0.7.130 → 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.130",
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": {
@@ -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
  }