@glossarist/concept-browser 0.7.34 → 0.7.37
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/build-edges.js +16 -8
- package/scripts/generate-data.mjs +284 -86
- package/src/__tests__/citation-display.test.ts +165 -3
- package/src/__tests__/cite-ref.test.ts +112 -0
- package/src/__tests__/concept-detail-interaction.test.ts +1 -5
- package/src/__tests__/{math.test.ts → content-renderer.test.ts} +113 -29
- package/src/__tests__/escape.test.ts +76 -0
- package/src/__tests__/graph-data-source.test.ts +155 -0
- package/src/__tests__/model-bridge-bridges.test.ts +150 -0
- package/src/__tests__/model-bridge-citation.test.ts +163 -0
- package/src/__tests__/reference-resolver-cite.test.ts +122 -0
- package/src/__tests__/reference-resolver.test.ts +12 -7
- package/src/__tests__/resolve-view.test.ts +1 -1
- package/src/__tests__/sidebar-nav-highlighting.test.ts +178 -0
- package/src/__tests__/source-refs.test.ts +9 -6
- package/src/__tests__/test-helpers.ts +20 -0
- package/src/__tests__/uri-router.test.ts +39 -12
- package/src/adapters/DatasetAdapter.ts +35 -143
- package/src/adapters/GraphDataSource.ts +178 -0
- package/src/adapters/ReferenceResolver.ts +101 -47
- package/src/adapters/UriRouter.ts +82 -10
- package/src/adapters/factory.ts +35 -28
- package/src/adapters/model-bridge.ts +121 -71
- package/src/adapters/types.ts +3 -0
- package/src/components/AppSidebar.vue +7 -4
- package/src/components/CitationDisplay.vue +86 -30
- package/src/components/ConceptDetail.vue +24 -126
- package/src/components/LanguageDetail.vue +6 -6
- package/src/composables/use-concept-content.ts +8 -8
- package/src/composables/use-ontology-nav.ts +129 -130
- package/src/composables/use-render-options.ts +1 -1
- package/src/graph/GraphEngine.ts +65 -0
- package/src/stores/vocabulary.ts +12 -73
- package/src/utils/content-renderer.ts +312 -0
- package/src/utils/markdown-lite.ts +2 -2
- package/src/utils/math.ts +0 -189
package/src/graph/GraphEngine.ts
CHANGED
|
@@ -161,6 +161,71 @@ export class GraphEngine {
|
|
|
161
161
|
return [...this.nodes.values()];
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
// ── Bulk seeding: accept domain-level data, construct nodes internally ──────
|
|
165
|
+
|
|
166
|
+
addGraphNodes(uriPrefix: string, registerId: string, nodes: [string, Record<string, string>, string][]): void {
|
|
167
|
+
for (const [id, designations, status] of nodes) {
|
|
168
|
+
this.addNode({
|
|
169
|
+
uri: uriPrefix + id,
|
|
170
|
+
register: registerId,
|
|
171
|
+
conceptId: id,
|
|
172
|
+
designations: designations || {},
|
|
173
|
+
status: status || 'unknown',
|
|
174
|
+
loaded: false,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
addEdges(edges: GraphEdge[]): void {
|
|
180
|
+
for (const edge of edges) {
|
|
181
|
+
this.addEdge(edge);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
addDomainNodes(nodes: GraphNode[]): void {
|
|
186
|
+
for (const node of nodes) {
|
|
187
|
+
this.addNode(node);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
seedConceptNode(uri: string, registerId: string, conceptId: string, designations: Record<string, string>, status: string): void {
|
|
192
|
+
this.addNode({
|
|
193
|
+
uri,
|
|
194
|
+
register: registerId,
|
|
195
|
+
conceptId,
|
|
196
|
+
designations,
|
|
197
|
+
status,
|
|
198
|
+
loaded: true,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
addDomainEdgesWithNodes(edges: GraphEdge[], registerId: string): void {
|
|
203
|
+
for (const edge of edges) {
|
|
204
|
+
// Create domain target node before addEdge so addEdge finds it and skips stub creation
|
|
205
|
+
if (!this.nodes.has(edge.target)) {
|
|
206
|
+
this.nodes.set(edge.target, {
|
|
207
|
+
uri: edge.target,
|
|
208
|
+
register: registerId,
|
|
209
|
+
conceptId: '',
|
|
210
|
+
designations: edge.label ? { eng: edge.label } : {},
|
|
211
|
+
status: 'domain',
|
|
212
|
+
loaded: true,
|
|
213
|
+
nodeType: 'domain',
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
this.addEdge(edge);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
getRelated(uri: string): { outgoing: GraphEdge[]; incoming: GraphEdge[] } {
|
|
221
|
+
return {
|
|
222
|
+
outgoing: this.getUniqueEdges(uri, 'outgoing', 'target')
|
|
223
|
+
.filter(e => e.type !== 'domain' && e.type !== 'section'),
|
|
224
|
+
incoming: this.getUniqueEdges(uri, 'incoming', 'source')
|
|
225
|
+
.filter(e => e.type !== 'domain' && e.type !== 'section'),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
164
229
|
get nodeCount(): number {
|
|
165
230
|
return this.nodes.size;
|
|
166
231
|
}
|
package/src/stores/vocabulary.ts
CHANGED
|
@@ -107,31 +107,17 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
|
|
|
107
107
|
adapter.loadDomainNodes(),
|
|
108
108
|
]);
|
|
109
109
|
|
|
110
|
-
if (nodeResult.status === 'fulfilled') {
|
|
111
|
-
|
|
112
|
-
for (const [id, designations, status] of nodes) {
|
|
113
|
-
engine.addNode({
|
|
114
|
-
uri: uriPrefix + id,
|
|
115
|
-
register: adapter.registerId,
|
|
116
|
-
conceptId: id,
|
|
117
|
-
designations: designations || {},
|
|
118
|
-
status: status || 'unknown',
|
|
119
|
-
loaded: false,
|
|
120
|
-
});
|
|
121
|
-
}
|
|
110
|
+
if (nodeResult.status === 'fulfilled' && nodeResult.value.uriPrefix) {
|
|
111
|
+
engine.addGraphNodes(nodeResult.value.uriPrefix, adapter.registerId, nodeResult.value.nodes);
|
|
122
112
|
}
|
|
123
113
|
|
|
124
114
|
if (edgeResult.status === 'fulfilled' && Array.isArray(edgeResult.value)) {
|
|
125
|
-
|
|
126
|
-
engine.addEdge(edge);
|
|
127
|
-
}
|
|
115
|
+
engine.addEdges(edgeResult.value);
|
|
128
116
|
edgeStatus.value[adapter.registerId] = { loaded: true, count: edgeResult.value.length };
|
|
129
117
|
}
|
|
130
118
|
|
|
131
119
|
if (domainResult.status === 'fulfilled') {
|
|
132
|
-
|
|
133
|
-
engine.addNode(dn);
|
|
134
|
-
}
|
|
120
|
+
engine.addDomainNodes(domainResult.value);
|
|
135
121
|
}
|
|
136
122
|
} catch {
|
|
137
123
|
// Individual adapter failures are non-critical for graph view
|
|
@@ -149,24 +135,11 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
|
|
|
149
135
|
adapter.loadGraphNodes(),
|
|
150
136
|
]);
|
|
151
137
|
const engine = graph.value;
|
|
152
|
-
|
|
153
|
-
engine.addNode(dn);
|
|
154
|
-
}
|
|
138
|
+
engine.addDomainNodes(domainNodes);
|
|
155
139
|
if (graphNodes.uriPrefix) {
|
|
156
|
-
|
|
157
|
-
engine.addNode({
|
|
158
|
-
uri: graphNodes.uriPrefix + id,
|
|
159
|
-
register: adapter.registerId,
|
|
160
|
-
conceptId: id,
|
|
161
|
-
designations: designations || {},
|
|
162
|
-
status: status || 'unknown',
|
|
163
|
-
loaded: false,
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
for (const edge of edges) {
|
|
168
|
-
engine.addEdge(edge);
|
|
140
|
+
engine.addGraphNodes(graphNodes.uriPrefix, adapter.registerId, graphNodes.nodes);
|
|
169
141
|
}
|
|
142
|
+
engine.addEdges(edges);
|
|
170
143
|
edgeStatus.value[adapter.registerId] = { loaded: true, count: edges.length };
|
|
171
144
|
return edges;
|
|
172
145
|
} catch {
|
|
@@ -197,17 +170,7 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
|
|
|
197
170
|
try {
|
|
198
171
|
const gn = await targetAdapter.loadGraphNodes();
|
|
199
172
|
if (gn.uriPrefix) {
|
|
200
|
-
|
|
201
|
-
for (const [id, designations, status] of gn.nodes) {
|
|
202
|
-
engine.addNode({
|
|
203
|
-
uri: gn.uriPrefix + id,
|
|
204
|
-
register: targetId,
|
|
205
|
-
conceptId: id,
|
|
206
|
-
designations: designations || {},
|
|
207
|
-
status: status || 'unknown',
|
|
208
|
-
loaded: false,
|
|
209
|
-
});
|
|
210
|
-
}
|
|
173
|
+
graph.value.addGraphNodes(gn.uriPrefix, targetId, gn.nodes);
|
|
211
174
|
}
|
|
212
175
|
} catch { /* non-critical */ }
|
|
213
176
|
}));
|
|
@@ -261,36 +224,12 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
|
|
|
261
224
|
}
|
|
262
225
|
|
|
263
226
|
const engine = graph.value;
|
|
264
|
-
engine.
|
|
265
|
-
|
|
266
|
-
register: registerId,
|
|
267
|
-
conceptId,
|
|
268
|
-
designations,
|
|
269
|
-
status: indexEntry?.status ?? 'unknown',
|
|
270
|
-
loaded: true,
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
for (const edge of domainEdges) {
|
|
274
|
-
engine.addEdge(edge);
|
|
275
|
-
const existing = engine.getNode(edge.target);
|
|
276
|
-
if (!existing || !existing.loaded) {
|
|
277
|
-
engine.addNode({
|
|
278
|
-
uri: edge.target,
|
|
279
|
-
register: registerId,
|
|
280
|
-
conceptId: '',
|
|
281
|
-
designations: edge.label ? { eng: edge.label } : {},
|
|
282
|
-
status: 'domain',
|
|
283
|
-
loaded: true,
|
|
284
|
-
nodeType: 'domain',
|
|
285
|
-
});
|
|
286
|
-
}
|
|
287
|
-
}
|
|
227
|
+
engine.seedConceptNode(uri, registerId, conceptId, designations, indexEntry?.status ?? 'unknown');
|
|
228
|
+
engine.addDomainEdgesWithNodes(domainEdges, registerId);
|
|
288
229
|
|
|
289
230
|
touchGraph();
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
...engine.getIncomingEdges(uri),
|
|
293
|
-
];
|
|
231
|
+
const related = engine.getRelated(uri);
|
|
232
|
+
conceptEdges.value = [...related.outgoing, ...related.incoming];
|
|
294
233
|
} catch (e: unknown) {
|
|
295
234
|
error.value = `Failed to load concept ${conceptId}: ${e instanceof Error ? e.message : String(e)}`;
|
|
296
235
|
currentConcept.value = null;
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content renderer: transforms Glossarist inline content notation to HTML.
|
|
3
|
+
*
|
|
4
|
+
* Handles ALL inline rendering — mentions, cross-references, citations,
|
|
5
|
+
* math placeholders, tables, lists, and text formatting. This is the single
|
|
6
|
+
* source of truth for content rendering in the browser.
|
|
7
|
+
*
|
|
8
|
+
* Math-specific helpers (replaceBracketed, mathPlaceholder) are internal.
|
|
9
|
+
* The v-math directive upgrades the placeholders to KaTeX at runtime.
|
|
10
|
+
*/
|
|
11
|
+
import { escapeHtml, escapeAttr } from './escape';
|
|
12
|
+
import { parseMention } from 'glossarist';
|
|
13
|
+
|
|
14
|
+
// ── Resolver types ────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export type XrefResolver = (uri: string, term: string) => string;
|
|
17
|
+
export type BibResolver = (refId: string, title: string) => string;
|
|
18
|
+
export type FigResolver = (figId: string) => string;
|
|
19
|
+
export type CiteResolver = (key: string, label: string | null) => string;
|
|
20
|
+
export type ConceptRefResolver = (conceptId: string, term: string) => string;
|
|
21
|
+
export type UrnRefResolver = (uri: string, term: string) => string;
|
|
22
|
+
|
|
23
|
+
export interface RenderOptions {
|
|
24
|
+
xrefResolver?: XrefResolver;
|
|
25
|
+
bibResolver?: BibResolver;
|
|
26
|
+
figResolver?: FigResolver;
|
|
27
|
+
conceptRefResolver?: ConceptRefResolver;
|
|
28
|
+
citeResolver?: CiteResolver;
|
|
29
|
+
urnRefResolver?: UrnRefResolver;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── Math placeholders ────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
function replaceBracketed(text: string, prefix: string, handler: (content: string, bold: boolean) => string): string {
|
|
35
|
+
let result = '';
|
|
36
|
+
let i = 0;
|
|
37
|
+
const boldPrefix = '*' + prefix;
|
|
38
|
+
while (i < text.length) {
|
|
39
|
+
if (text.startsWith(boldPrefix + '[', i)) {
|
|
40
|
+
i += boldPrefix.length + 1;
|
|
41
|
+
let j = i;
|
|
42
|
+
let d = 1;
|
|
43
|
+
while (j < text.length && d > 0) {
|
|
44
|
+
if (text[j] === '[') d++;
|
|
45
|
+
else if (text[j] === ']') d--;
|
|
46
|
+
j++;
|
|
47
|
+
}
|
|
48
|
+
const content = text.slice(i, j - 1);
|
|
49
|
+
let end = j;
|
|
50
|
+
if (end < text.length && text[end] === '*') end++;
|
|
51
|
+
result += handler(content, true);
|
|
52
|
+
i = end;
|
|
53
|
+
} else if (text.startsWith(prefix + '[', i)) {
|
|
54
|
+
i += prefix.length + 1;
|
|
55
|
+
let j = i;
|
|
56
|
+
let d = 1;
|
|
57
|
+
while (j < text.length && d > 0) {
|
|
58
|
+
if (text[j] === '[') d++;
|
|
59
|
+
else if (text[j] === ']') d--;
|
|
60
|
+
j++;
|
|
61
|
+
}
|
|
62
|
+
const content = text.slice(i, j - 1);
|
|
63
|
+
result += handler(content, false);
|
|
64
|
+
i = j;
|
|
65
|
+
} else {
|
|
66
|
+
result += text[i];
|
|
67
|
+
i++;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function mathPlaceholder(expr: string, format: string, bold: boolean): string {
|
|
74
|
+
return `<span class="math-pending${bold ? ' math-bold' : ''}" data-expr="${escapeAttr(expr)}" data-format="${format}">${escapeAttr(expr)}</span>`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Block transforms ─────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
function convertAsciiDocTables(text: string): string {
|
|
80
|
+
return text.replace(/\n?\|===\n([\s\S]*?)\n\|===/g, (_: string, body: string) => {
|
|
81
|
+
const rows: string[] = body.split('\n').filter((line: string) => line.trim() !== '');
|
|
82
|
+
if (!rows.length) return '';
|
|
83
|
+
|
|
84
|
+
const parsedRows: string[][] = rows.map((row: string) => {
|
|
85
|
+
const cellText = row.replace(/^\s*\|/, '').trim();
|
|
86
|
+
const cells = cellText.split(/\s*\|\s*/).map((c: string) => c.trim()).filter((c: string) => c !== '');
|
|
87
|
+
return cells;
|
|
88
|
+
}).filter((r: string[]) => r.length > 0);
|
|
89
|
+
|
|
90
|
+
if (!parsedRows.length) return '';
|
|
91
|
+
|
|
92
|
+
const maxCols = Math.max(...parsedRows.map((r: string[]) => r.length));
|
|
93
|
+
const normalized = parsedRows.map((r: string[]) => {
|
|
94
|
+
while (r.length < maxCols) r.push('');
|
|
95
|
+
return r;
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const thead = normalized[0].map((c: string) => `<th>${escapeHtml(c)}</th>`).join('');
|
|
99
|
+
const tbody = normalized.slice(1).map((r: string[]) =>
|
|
100
|
+
`<tr>${r.map((c: string) => `<td>${escapeHtml(c)}</td>`).join('')}</tr>`
|
|
101
|
+
).join('');
|
|
102
|
+
|
|
103
|
+
return `\n<table class="concept-table"><thead><tr>${thead}</tr></thead><tbody>${tbody}</tbody></table>`;
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function convertLists(text: string): string {
|
|
108
|
+
let result = text.replace(/(?:^|\n)((?:[ \t]*\* [^\n]+)(?:\n[ \t]*\* [^\n]+)*)/g, (_, block) => {
|
|
109
|
+
if (/^\*stem:\[/.test(block.trimStart())) return _;
|
|
110
|
+
const items: string[] = [];
|
|
111
|
+
const re = /[ \t]*\* ([^\n]+)/g;
|
|
112
|
+
let m;
|
|
113
|
+
while ((m = re.exec(block)) !== null) {
|
|
114
|
+
items.push(m[1].trim());
|
|
115
|
+
}
|
|
116
|
+
if (!items.length) return _;
|
|
117
|
+
const lis = items.map(item => `<li>${item}</li>`).join('');
|
|
118
|
+
return `\n<ul class="concept-list">${lis}</ul>`;
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
result = result.replace(/(?:^|\n)((?:[ \t]*\d+[).][ \t]+[^\n]+)(?:\n[ \t]*\d+[).][ \t]+[^\n]+)*)/g, (_, block) => {
|
|
122
|
+
const items: string[] = [];
|
|
123
|
+
const re = /[ \t]*\d+[).][ \t]+([^\n]+)/g;
|
|
124
|
+
let m;
|
|
125
|
+
while ((m = re.exec(block)) !== null) {
|
|
126
|
+
items.push(m[1].trim());
|
|
127
|
+
}
|
|
128
|
+
if (!items.length) return _;
|
|
129
|
+
const lis = items.map(item => `<li>${item}</li>`).join('');
|
|
130
|
+
return `\n<ol class="concept-list concept-list-ordered">${lis}</ol>`;
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── Inline reference resolution ──────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
function resolveBibRefs(text: string, opts: RenderOptions): string {
|
|
139
|
+
return text.replace(/<<([^,>]+),([^>]+)>>/g, (_, refId, title) => {
|
|
140
|
+
if (opts.bibResolver) {
|
|
141
|
+
return opts.bibResolver(refId.trim(), title.trim());
|
|
142
|
+
}
|
|
143
|
+
return `<span class="bib-ref">${escapeHtml(title.trim())}</span>`;
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function resolveFigRefs(text: string, opts: RenderOptions): string {
|
|
148
|
+
return text.replace(/<<(fig_[^>]+)>>/g, (_, figId) => {
|
|
149
|
+
if (opts.figResolver) {
|
|
150
|
+
return opts.figResolver(figId.trim());
|
|
151
|
+
}
|
|
152
|
+
return `<span class="fig-ref">${escapeHtml(figId.trim())}</span>`;
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function resolveUrnRefs(text: string, opts: RenderOptions): string {
|
|
157
|
+
// Double-brace URN refs: {{urn:...,term}} or {{urn:...,term,display}}
|
|
158
|
+
// Note: glossarist ≥ 0.3.7 parseMention handles these as 'urn-ref', but we
|
|
159
|
+
// keep this handler for when parseMention returns 'unresolved' (glossarist < 0.3.7)
|
|
160
|
+
let result = text.replace(/\{\{(urn:[^,}]+),([^,}]+)(?:,([^}]+))?\}\}/g, (_, uri, term, display) => {
|
|
161
|
+
const t = (display || term).trim();
|
|
162
|
+
if (opts.xrefResolver) {
|
|
163
|
+
return opts.xrefResolver(uri, t);
|
|
164
|
+
}
|
|
165
|
+
return t;
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Single-brace URN refs: {urn:...,term} or {urn:...,term,display}
|
|
169
|
+
result = result.replace(/\{(urn:[^,}]+),([^,}]+)(?:,([^}]+))?\}/g, (_, uri, term, display) => {
|
|
170
|
+
const t = (display || term).trim();
|
|
171
|
+
if (opts.xrefResolver) {
|
|
172
|
+
return opts.xrefResolver(uri, t);
|
|
173
|
+
}
|
|
174
|
+
return t;
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
return result;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function resolveMentions(text: string, opts: RenderOptions): string {
|
|
181
|
+
// Single-pass {{...}} mention dispatcher via parseMention (SSOT)
|
|
182
|
+
return text.replace(/\{\{([^{}]+?)\}\}/g, (_orig, body) => {
|
|
183
|
+
const parsed = parseMention(body);
|
|
184
|
+
|
|
185
|
+
// cite:key[,render term] — bibliography citation
|
|
186
|
+
if (parsed.kind === 'cite-ref') {
|
|
187
|
+
const key = parsed.key!;
|
|
188
|
+
const label = parsed.label ?? null;
|
|
189
|
+
if (opts.citeResolver) return opts.citeResolver(key, label);
|
|
190
|
+
return `<span class="bib-ref">${escapeHtml(label ?? key)}</span>`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// urn:...[,render term] — URN cross-reference (glossarist ≥ 0.3.7)
|
|
194
|
+
const anyParsed = parsed as Record<string, unknown>;
|
|
195
|
+
if ((anyParsed.kind as string) === 'urn-ref') {
|
|
196
|
+
const uri = anyParsed.uri as string;
|
|
197
|
+
const label = (anyParsed.label as string) ?? uri;
|
|
198
|
+
if (opts.urnRefResolver) return opts.urnRefResolver(uri, label);
|
|
199
|
+
if (opts.xrefResolver) return opts.xrefResolver(uri, label);
|
|
200
|
+
return escapeHtml(label);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// numeric_id[,render term] — local concept ID
|
|
204
|
+
if (parsed.kind === 'numeric') {
|
|
205
|
+
const id = parsed.id!;
|
|
206
|
+
const label = parsed.label;
|
|
207
|
+
if (label && opts.conceptRefResolver) {
|
|
208
|
+
return opts.conceptRefResolver(id, label);
|
|
209
|
+
}
|
|
210
|
+
return `<span class="gl-mention">${escapeHtml(id)}</span>`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// designation[,render term] — designation matching (glossarist ≥ 0.3.7)
|
|
214
|
+
if ((anyParsed.kind as string) === 'designation') {
|
|
215
|
+
const designation = anyParsed.id as string;
|
|
216
|
+
const label = (anyParsed.label as string) ?? designation;
|
|
217
|
+
if (opts.conceptRefResolver) {
|
|
218
|
+
return opts.conceptRefResolver(designation, label);
|
|
219
|
+
}
|
|
220
|
+
return escapeHtml(label);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Fallback for unresolved: handle two-arg form or render as plain text
|
|
224
|
+
// This handles cases where parseMention doesn't recognize the kind
|
|
225
|
+
// (e.g. glossarist < 0.3.7 before urn-ref/designation kinds were added)
|
|
226
|
+
const commaIdx = body.indexOf(',');
|
|
227
|
+
if (commaIdx > 0) {
|
|
228
|
+
const id = body.slice(0, commaIdx).trim();
|
|
229
|
+
const display = body.slice(commaIdx + 1).trim();
|
|
230
|
+
if (opts.conceptRefResolver) return opts.conceptRefResolver(id, display);
|
|
231
|
+
return escapeHtml(display);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return `<span class="gl-mention">${escapeHtml(body.trim())}</span>`;
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── Public API ───────────────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Render Glossarist inline content notation to HTML.
|
|
242
|
+
*
|
|
243
|
+
* Pipeline stages (in order):
|
|
244
|
+
* 1. Math placeholders (stem:, latexmath:)
|
|
245
|
+
* 2. AsciiDoc tables
|
|
246
|
+
* 3. Bullet and numbered lists
|
|
247
|
+
* 4. Text formatting (bold, italic, subscript)
|
|
248
|
+
* 5. Bibliography cross-references (<<ref,title>>)
|
|
249
|
+
* 6. Figure references (<<fig_...>>)
|
|
250
|
+
* 7. Single-brace URN inline references ({urn:...})
|
|
251
|
+
* 8. Mention dispatcher via parseMention (cite-ref, urn-ref, numeric, designation)
|
|
252
|
+
*/
|
|
253
|
+
export function renderContent(text: string, xrefResolverOrOpts?: XrefResolver | RenderOptions): string {
|
|
254
|
+
if (!text) return '';
|
|
255
|
+
let result = text;
|
|
256
|
+
|
|
257
|
+
const opts: RenderOptions = typeof xrefResolverOrOpts === 'function'
|
|
258
|
+
? { xrefResolver: xrefResolverOrOpts }
|
|
259
|
+
: (xrefResolverOrOpts ?? {});
|
|
260
|
+
|
|
261
|
+
// Stage 1: Math expressions → placeholders for v-math directive
|
|
262
|
+
result = replaceBracketed(result, 'stem:', (expr, bold) => mathPlaceholder(expr, 'asciimath', bold));
|
|
263
|
+
result = replaceBracketed(result, 'latexmath:', (expr, bold) => mathPlaceholder(expr, 'latex', bold));
|
|
264
|
+
|
|
265
|
+
// Stage 2: Block structures
|
|
266
|
+
result = convertAsciiDocTables(result);
|
|
267
|
+
result = convertLists(result);
|
|
268
|
+
|
|
269
|
+
// Stage 3: Inline formatting
|
|
270
|
+
result = result.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
271
|
+
result = result.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>');
|
|
272
|
+
result = result.replace(/~([^~]+)~/g, '<sub>$1</sub>');
|
|
273
|
+
|
|
274
|
+
// Stage 4: Reference resolution
|
|
275
|
+
result = resolveBibRefs(result, opts);
|
|
276
|
+
result = resolveFigRefs(result, opts);
|
|
277
|
+
result = resolveUrnRefs(result, opts);
|
|
278
|
+
|
|
279
|
+
// Stage 5: Mention dispatcher (parseMention SSOT)
|
|
280
|
+
result = resolveMentions(result, opts);
|
|
281
|
+
|
|
282
|
+
return result;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Strip all inline notation to produce plain text.
|
|
287
|
+
* Used for search indexing, previews, and accessibility.
|
|
288
|
+
*/
|
|
289
|
+
export function cleanContent(text: string): string {
|
|
290
|
+
if (!text) return '';
|
|
291
|
+
let result = text
|
|
292
|
+
.replace(/<[^>]+>/g, '')
|
|
293
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
|
294
|
+
.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '$1')
|
|
295
|
+
.replace(/~([^~]+)~/g, '_$1')
|
|
296
|
+
.replace(/\n[ \t]*\* /g, '; ')
|
|
297
|
+
.replace(/<<([^,>]+),([^>]+)>>/g, '$2')
|
|
298
|
+
.replace(/<<(fig_[^>]+)>>/g, '$1')
|
|
299
|
+
// URN refs — show render term (second part for two-arg, third part for three-arg)
|
|
300
|
+
.replace(/\{\{urn:[^,}]+,([^,}]+),([^}]+)\}\}/g, '$1') // three-arg: {{urn:...,term,display}} → term
|
|
301
|
+
.replace(/\{\{urn:[^,}]+(?:,([^}]+))?\}\}/g, (_, label) => label ? label.trim() : '') // two-arg or bare
|
|
302
|
+
.replace(/\{urn:[^,}]+,([^,}]+)(?:,[^}]+)?\}/g, '$1')
|
|
303
|
+
// Cite refs — show render term (or empty if bare)
|
|
304
|
+
.replace(/\{\{cite:[^,}]+(?:,([^}]+))?\}\}/g, (_, label) => label ? label.trim() : '')
|
|
305
|
+
// Two-arg mentions: show render term (second part)
|
|
306
|
+
.replace(/\{\{([^,}]+),\s*([^}]+)\}\}/g, '$2')
|
|
307
|
+
// One-arg mentions: show the identifier
|
|
308
|
+
.replace(/\{\{([^,}]+)\}\}/g, '$1')
|
|
309
|
+
.replace(/(?:\*?)stem:\[([^\]]*)\]/g, '$1')
|
|
310
|
+
.replace(/(?:\*?)latexmath:\[([^\]]*)\]/g, '$1');
|
|
311
|
+
return result;
|
|
312
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { escapeHtml } from './escape';
|
|
2
|
+
|
|
1
3
|
const INLINE_PATTERNS: [RegExp, (m: RegExpMatchArray) => string][] = [
|
|
2
4
|
[/\*\*(.+?)\*\*/g, m => `<strong>${m[1]}</strong>`],
|
|
3
5
|
[/(?<!\*)\*([^*]+?)\*(?!\*)/g, m => `<em>${m[1]}</em>`],
|
|
@@ -118,5 +120,3 @@ export function renderMarkdown(input: string): string {
|
|
|
118
120
|
|
|
119
121
|
return blocks.join('\n');
|
|
120
122
|
}
|
|
121
|
-
|
|
122
|
-
import { escapeHtml } from './escape';
|