@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 +2 -2
- package/scripts/generate-data.js +3 -0
- package/scripts/generate-data.ts +10 -18
- 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": {
|
|
@@ -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.
|
|
46
|
+
"glossarist": "^0.4.56",
|
|
47
47
|
"js-yaml": "^4.1.0",
|
|
48
48
|
"jszip": "^3.10.1",
|
|
49
49
|
"n3": "^1.26.0",
|
package/scripts/generate-data.js
CHANGED
|
@@ -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
|
}
|
package/scripts/generate-data.ts
CHANGED
|
@@ -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
|
|
379
|
-
//
|
|
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
|
-
//
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
//
|
|
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('<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
|
}
|