@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 +1 -1
- package/src/__tests__/components/external-concept-display.test.ts +21 -0
- package/src/__tests__/content-renderer.test.ts +45 -0
- package/src/components/GenericRelationList.vue +41 -16
- package/src/utils/content-renderer.ts +140 -10
- package/src/utils/external-detection.ts +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glossarist/concept-browser",
|
|
3
|
-
"version": "0.7.
|
|
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('<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
|
-
*
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
107
|
-
const
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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>${
|
|
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
|
-
|
|
100
|
+
if (!isExternal) return label;
|
|
101
|
+
if (label.startsWith('(') && label.endsWith(')')) return label;
|
|
102
|
+
return `(${label})`;
|
|
101
103
|
}
|