@glossarist/concept-browser 0.7.35 → 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 -1
- 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 +12 -0
- package/src/adapters/GraphDataSource.ts +3 -3
- package/src/adapters/ReferenceResolver.ts +85 -55
- package/src/adapters/UriRouter.ts +82 -10
- package/src/adapters/factory.ts +34 -10
- 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 +6 -4
- package/src/components/LanguageDetail.vue +6 -6
- package/src/composables/use-concept-content.ts +8 -8
- package/src/composables/use-render-options.ts +1 -1
- package/src/graph/GraphEngine.ts +3 -3
- package/src/stores/vocabulary.ts +2 -2
- package/src/utils/content-renderer.ts +312 -0
- package/src/utils/markdown-lite.ts +2 -2
- package/src/utils/math.ts +0 -189
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { Citation } from 'glossarist';
|
|
3
|
+
import type { CitationClassification, CiteResolution } from '../adapters/types';
|
|
3
4
|
import { computed, ref } from 'vue';
|
|
4
5
|
import { getFactory } from '../adapters/factory';
|
|
5
6
|
import { useRouter } from 'vue-router';
|
|
@@ -14,22 +15,26 @@ const router = useRouter();
|
|
|
14
15
|
const store = useVocabularyStore();
|
|
15
16
|
const factory = getFactory();
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
if (!ref?.source || !locality?.referenceFrom) return null;
|
|
18
|
+
// ── Single source of truth for citation resolution ────────────────────────
|
|
19
|
+
// Both classification and navigation target come from the same resolveCite()
|
|
20
|
+
// call, so they can never disagree.
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
const citeResolution = computed<CiteResolution>(() =>
|
|
23
|
+
factory.resolver.resolveCite(props.citation, props.registerId),
|
|
24
|
+
);
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
const classification = computed<CitationClassification>(() =>
|
|
27
|
+
citeResolution.value.classification,
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const resolvedTarget = computed(() => citeResolution.value.resolved);
|
|
27
31
|
|
|
28
|
-
const resolvedTarget = computed(() => resolveCitation());
|
|
29
32
|
const isCrossDataset = computed(() =>
|
|
30
33
|
resolvedTarget.value != null && resolvedTarget.value.registerId !== props.registerId,
|
|
31
34
|
);
|
|
32
35
|
|
|
36
|
+
// ── Navigation ────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
33
38
|
async function navigateToCitation() {
|
|
34
39
|
if (!resolvedTarget.value) return;
|
|
35
40
|
const { registerId, conceptId } = resolvedTarget.value;
|
|
@@ -37,7 +42,37 @@ async function navigateToCitation() {
|
|
|
37
42
|
router.push({ name: 'concept', params: { registerId, conceptId } });
|
|
38
43
|
}
|
|
39
44
|
|
|
40
|
-
//
|
|
45
|
+
// ── Wrapper element determined by classification ──────────────────────────
|
|
46
|
+
// Internal → button (navigates to concept)
|
|
47
|
+
// Self-contained with link → anchor (external link)
|
|
48
|
+
// Everything else → span (plain text)
|
|
49
|
+
|
|
50
|
+
const sourceTag = computed(() => {
|
|
51
|
+
if (classification.value === 'internal-citation') return 'button';
|
|
52
|
+
if (classification.value === 'self-contained-citation' && props.citation.link) return 'a';
|
|
53
|
+
return 'span';
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const sourceAttrs = computed(() => {
|
|
57
|
+
if (sourceTag.value === 'button') {
|
|
58
|
+
return { class: 'concept-link font-medium inline-flex items-center gap-0.5' };
|
|
59
|
+
}
|
|
60
|
+
if (sourceTag.value === 'a') {
|
|
61
|
+
return { href: props.citation.link!, target: '_blank', rel: 'noopener', class: 'concept-link font-medium' };
|
|
62
|
+
}
|
|
63
|
+
return { class: 'font-medium' };
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const sourceEvents = computed(() => {
|
|
67
|
+
if (sourceTag.value !== 'button') return {};
|
|
68
|
+
return {
|
|
69
|
+
onClick: navigateToCitation,
|
|
70
|
+
onMouseenter: schedulePreview,
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// ── Hover preview ─────────────────────────────────────────────────────────
|
|
75
|
+
|
|
41
76
|
const triggerEl = ref<HTMLElement | null>(null);
|
|
42
77
|
const preview = ref<{ designation: string; definition: string } | null>(null);
|
|
43
78
|
const previewVisible = ref(false);
|
|
@@ -90,39 +125,60 @@ function hidePreview() {
|
|
|
90
125
|
if (previewTimer) { clearTimeout(previewTimer); previewTimer = null; }
|
|
91
126
|
previewVisible.value = false;
|
|
92
127
|
}
|
|
128
|
+
|
|
129
|
+
// ── Locality formatting ───────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
function formatLocality(loc: NonNullable<Citation['locality']>): string {
|
|
132
|
+
const parts: string[] = [];
|
|
133
|
+
const lType = (loc as unknown as Record<string, unknown>).type as string | null;
|
|
134
|
+
const from = (loc as unknown as Record<string, unknown>).referenceFrom ?? (loc as unknown as Record<string, unknown>).reference_from;
|
|
135
|
+
const to = (loc as unknown as Record<string, unknown>).referenceTo ?? (loc as unknown as Record<string, unknown>).reference_to;
|
|
136
|
+
if (lType) parts.push(`, ${lType}`);
|
|
137
|
+
if (from) parts.push(to ? ` ${from}–${to}` : ` ${from}`);
|
|
138
|
+
return parts.join('');
|
|
139
|
+
}
|
|
93
140
|
</script>
|
|
94
141
|
|
|
95
142
|
<template>
|
|
96
143
|
<span class="inline" @mouseleave="hidePreview">
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
144
|
+
<!-- Source reference: dynamic wrapper (button/a/span) -->
|
|
145
|
+
<template v-if="citation.ref?.source">
|
|
146
|
+
<component
|
|
147
|
+
:is="sourceTag"
|
|
148
|
+
v-bind="sourceAttrs"
|
|
149
|
+
v-on="sourceEvents"
|
|
103
150
|
>
|
|
104
151
|
{{ citation.ref.source }}
|
|
105
152
|
<span v-if="isCrossDataset" class="text-[10px] opacity-60 leading-none">↗</span>
|
|
106
|
-
</
|
|
107
|
-
<span v-else-if="citation.ref.source" class="font-medium">{{ citation.ref.source }}</span>
|
|
153
|
+
</component>
|
|
108
154
|
<span v-if="citation.ref.id"> {{ citation.ref.id }}</span>
|
|
109
155
|
<span v-if="citation.ref.version" class="text-ink-400"> ({{ citation.ref.version }})</span>
|
|
110
156
|
</template>
|
|
157
|
+
|
|
158
|
+
<!-- Locality: same formatting across all classifications -->
|
|
111
159
|
<template v-if="citation.locality">
|
|
112
|
-
<button
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
160
|
+
<button
|
|
161
|
+
v-if="classification === 'internal-citation'"
|
|
162
|
+
@click="navigateToCitation"
|
|
163
|
+
@mouseenter="schedulePreview"
|
|
164
|
+
class="concept-link"
|
|
165
|
+
>
|
|
166
|
+
{{ formatLocality(citation.locality) }}
|
|
117
167
|
</button>
|
|
118
|
-
<
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
{{ citation.locality.referenceTo ? ` ${citation.locality.referenceFrom}–${citation.locality.referenceTo}` : ` ${citation.locality.referenceFrom}` }}
|
|
122
|
-
</span>
|
|
123
|
-
</template>
|
|
168
|
+
<span v-else class="text-ink-400">
|
|
169
|
+
{{ formatLocality(citation.locality) }}
|
|
170
|
+
</span>
|
|
124
171
|
</template>
|
|
125
|
-
|
|
172
|
+
|
|
173
|
+
<!-- External link badge (only for non-self-contained citations that have a link) -->
|
|
174
|
+
<a
|
|
175
|
+
v-if="citation.link && classification !== 'self-contained-citation'"
|
|
176
|
+
:href="citation.link"
|
|
177
|
+
target="_blank"
|
|
178
|
+
rel="noopener"
|
|
179
|
+
class="concept-link ml-1"
|
|
180
|
+
>[link]</a>
|
|
181
|
+
|
|
126
182
|
<span v-if="citation.original" class="text-xs text-ink-300 ml-1">(orig: {{ citation.original }})</span>
|
|
127
183
|
<span v-if="resolvedTarget" class="text-[9px] text-ink-300 ml-1">→ {{ resolvedTarget.registerId }}/{{ resolvedTarget.conceptId }}</span>
|
|
128
184
|
|
|
@@ -3,8 +3,8 @@ import type { Concept, LocalizedConcept, Designation } from 'glossarist';
|
|
|
3
3
|
import type { Manifest, GraphEdge } from '../adapters/types';
|
|
4
4
|
import { computed, ref, nextTick, watch } from 'vue';
|
|
5
5
|
import { langName } from '../utils/lang';
|
|
6
|
-
import {
|
|
7
|
-
import type { RenderOptions } from '../utils/
|
|
6
|
+
import { renderContent } from '../utils/content-renderer';
|
|
7
|
+
import type { RenderOptions } from '../utils/content-renderer';
|
|
8
8
|
import { escapeAttr } from '../utils/escape';
|
|
9
9
|
import { entryStatusColor, conceptStatusColor, conceptStatusLabel, conceptStatusDefinition, entryStatusLabel, entryStatusDefinition, getPreferredTerm } from '../utils/concept-helpers';
|
|
10
10
|
import { sourceTypeInfo, sourceStatusInfo } from '../utils/designation-registry';
|
|
@@ -88,7 +88,7 @@ const engConcept = computed((): LocalizedConcept | null => {
|
|
|
88
88
|
});
|
|
89
89
|
|
|
90
90
|
const primaryTerm = computed(() => getPreferredTerm(engConcept.value, conceptId.value));
|
|
91
|
-
const renderedPrimaryTerm = computed(() =>
|
|
91
|
+
const renderedPrimaryTerm = computed(() => renderContent(primaryTerm.value));
|
|
92
92
|
|
|
93
93
|
const managedStatus = computed(() => props.concept.status);
|
|
94
94
|
|
|
@@ -118,7 +118,9 @@ const renderOpts = computed<RenderOptions>(() => ({
|
|
|
118
118
|
return escapeAttr(term);
|
|
119
119
|
},
|
|
120
120
|
conceptRefResolver: (conceptId, term) => {
|
|
121
|
-
|
|
121
|
+
const adapter = factory.getAdapter(props.registerId);
|
|
122
|
+
const resolvedId = adapter?.lookupByDesignation(conceptId) ?? conceptId;
|
|
123
|
+
return `<a href="#" class="xref-link" data-register="${escapeAttr(props.registerId)}" data-concept="${escapeAttr(resolvedId)}">${escapeAttr(term)}</a>`;
|
|
122
124
|
},
|
|
123
125
|
bibResolver,
|
|
124
126
|
figResolver,
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
import type { Concept, LocalizedConcept, Designation, Expression, Abbreviation as AbbreviationType } from 'glossarist';
|
|
3
3
|
import { computed } from 'vue';
|
|
4
4
|
import { langName, langLabel } from '../utils/lang';
|
|
5
|
-
import {
|
|
6
|
-
import type { RenderOptions } from '../utils/
|
|
5
|
+
import { renderContent } from '../utils/content-renderer';
|
|
6
|
+
import type { RenderOptions } from '../utils/content-renderer';
|
|
7
7
|
import { escapeAttr } from '../utils/escape';
|
|
8
8
|
import { entryStatusColor } from '../utils/concept-helpers';
|
|
9
9
|
import { designationTypeInfo, normativeStatusInfo, grammarBadges, pronunciationLabel, pronunciationTooltip } from '../utils/designation-registry';
|
|
@@ -115,7 +115,7 @@ function handleContentClick(e: MouseEvent) {
|
|
|
115
115
|
<div class="section-label">{{ t('concept.designations') }}</div>
|
|
116
116
|
<div class="space-y-2 mt-3">
|
|
117
117
|
<div v-for="(d, i) in designations" :key="i" class="flex items-center gap-2 flex-wrap">
|
|
118
|
-
<span class="font-medium text-ink-800 text-lg" v-html="
|
|
118
|
+
<span class="font-medium text-ink-800 text-lg" v-html="renderContent(d.designation)"></span>
|
|
119
119
|
<span class="badge text-[10px]" :class="designationTypeInfo(d).color">{{ designationTypeInfo(d).label }}</span>
|
|
120
120
|
<span class="badge text-[10px]" :class="normativeStatusInfo(d.normativeStatus).color">{{ normativeStatusInfo(d.normativeStatus).label }}</span>
|
|
121
121
|
<template v-if="d.type === 'expression' && (d as Expression).grammarInfo?.length">
|
|
@@ -142,7 +142,7 @@ function handleContentClick(e: MouseEvent) {
|
|
|
142
142
|
<!-- Definition -->
|
|
143
143
|
<div v-if="definition" class="card p-5">
|
|
144
144
|
<div class="section-label">{{ t('concept.definition') }}</div>
|
|
145
|
-
<div class="text-ink-800 leading-relaxed mt-3" v-html="
|
|
145
|
+
<div class="text-ink-800 leading-relaxed mt-3" v-html="renderContent(definition, renderOpts)"></div>
|
|
146
146
|
</div>
|
|
147
147
|
|
|
148
148
|
<!-- Notes -->
|
|
@@ -151,7 +151,7 @@ function handleContentClick(e: MouseEvent) {
|
|
|
151
151
|
<div class="space-y-3 mt-3">
|
|
152
152
|
<div v-for="(note, i) in notes" :key="i" class="text-ink-600 text-sm leading-relaxed">
|
|
153
153
|
<span class="font-medium text-ink-400 text-xs uppercase tracking-wide">{{ t('concept.note') }} {{ i + 1 }}</span>
|
|
154
|
-
<div class="mt-1" v-html="
|
|
154
|
+
<div class="mt-1" v-html="renderContent(note, renderOpts)"></div>
|
|
155
155
|
</div>
|
|
156
156
|
</div>
|
|
157
157
|
</div>
|
|
@@ -162,7 +162,7 @@ function handleContentClick(e: MouseEvent) {
|
|
|
162
162
|
<div class="space-y-3 mt-3">
|
|
163
163
|
<div v-for="(ex, i) in examples" :key="i" class="text-ink-600 text-sm leading-relaxed">
|
|
164
164
|
<span class="font-medium text-ink-400 text-xs uppercase tracking-wide">{{ t('concept.example') }} {{ i + 1 }}</span>
|
|
165
|
-
<div class="mt-1" v-html="
|
|
165
|
+
<div class="mt-1" v-html="renderContent(ex, renderOpts)"></div>
|
|
166
166
|
</div>
|
|
167
167
|
</div>
|
|
168
168
|
</div>
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { computed, ref, watch, type ComputedRef } from 'vue';
|
|
2
2
|
import type { Concept, LocalizedConcept, ConceptSource, Designation } from 'glossarist';
|
|
3
3
|
import type { Manifest } from '../adapters/types';
|
|
4
|
-
import type { RenderOptions } from '../utils/
|
|
5
|
-
import {
|
|
4
|
+
import type { RenderOptions } from '../utils/content-renderer';
|
|
5
|
+
import { renderContent, cleanContent } from '../utils/content-renderer';
|
|
6
6
|
import { getAnnotations } from '../adapters/model-bridge';
|
|
7
7
|
import { getPreferredTerm, entryStatusColor, entryStatusLabel, entryStatusDefinition } from '../utils/concept-helpers';
|
|
8
8
|
import { sortLanguages } from '../utils/lang';
|
|
@@ -68,18 +68,18 @@ export function useConceptContent(
|
|
|
68
68
|
result.push({
|
|
69
69
|
lang,
|
|
70
70
|
lc,
|
|
71
|
-
renderedTerm:
|
|
71
|
+
renderedTerm: renderContent(getPreferredTerm(lc, '')),
|
|
72
72
|
definition,
|
|
73
|
-
renderedDefinition:
|
|
73
|
+
renderedDefinition: renderContent(definition, opts),
|
|
74
74
|
annotations,
|
|
75
|
-
renderedAnnotations: annotations.map((a: string) =>
|
|
75
|
+
renderedAnnotations: annotations.map((a: string) => renderContent(a, opts)),
|
|
76
76
|
notes,
|
|
77
|
-
renderedNotes: notes.map(n =>
|
|
77
|
+
renderedNotes: notes.map(n => renderContent(n, opts)),
|
|
78
78
|
examples,
|
|
79
|
-
renderedExamples: examples.map(e =>
|
|
79
|
+
renderedExamples: examples.map(e => renderContent(e, opts)),
|
|
80
80
|
sources: lc.sources,
|
|
81
81
|
designations: lc.terms,
|
|
82
|
-
renderedDesignations: new Map(lc.terms.map(d => [d.designation,
|
|
82
|
+
renderedDesignations: new Map(lc.terms.map(d => [d.designation, renderContent(d.designation)])),
|
|
83
83
|
entryStatus: lc.entryStatus ?? '',
|
|
84
84
|
classification: lc.classification,
|
|
85
85
|
reviewType: lc.reviewType,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ref, watch } from 'vue';
|
|
2
|
-
import type { RenderOptions, BibResolver, FigResolver } from '../utils/
|
|
2
|
+
import type { RenderOptions, BibResolver, FigResolver } from '../utils/content-renderer';
|
|
3
3
|
import { getFactory } from '../adapters/factory';
|
|
4
4
|
import { escapeAttr } from '../utils/escape';
|
|
5
5
|
|
package/src/graph/GraphEngine.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { GraphNode, GraphEdge } from '../adapters/types';
|
|
2
|
-
import {
|
|
2
|
+
import { UriRouter } from '../adapters/UriRouter';
|
|
3
3
|
|
|
4
4
|
function hasDesignations(node: GraphNode): boolean {
|
|
5
5
|
const d = node.designations;
|
|
@@ -33,9 +33,9 @@ export class GraphEngine {
|
|
|
33
33
|
if (this.edgeKeys.has(key)) return;
|
|
34
34
|
this.edgeKeys.add(key);
|
|
35
35
|
|
|
36
|
-
const parsed =
|
|
36
|
+
const parsed = UriRouter.parseUri(edge.target);
|
|
37
37
|
if (!this.nodes.has(edge.source)) {
|
|
38
|
-
const sourceParsed =
|
|
38
|
+
const sourceParsed = UriRouter.parseUri(edge.source);
|
|
39
39
|
this.nodes.set(edge.source, {
|
|
40
40
|
uri: edge.source,
|
|
41
41
|
register: sourceParsed?.registerId ?? edge.register,
|
package/src/stores/vocabulary.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type { Manifest, SearchHit, GraphEdge } from '../adapters/types';
|
|
|
6
6
|
import type { Concept } from 'glossarist';
|
|
7
7
|
import { conceptUri } from '../adapters/model-bridge';
|
|
8
8
|
import { GraphEngine } from '../graph';
|
|
9
|
-
import {
|
|
9
|
+
import { UriRouter } from '../adapters/UriRouter';
|
|
10
10
|
import { deduplicateSearchHits } from '../utils/search';
|
|
11
11
|
|
|
12
12
|
export const useVocabularyStore = defineStore('vocabulary', () => {
|
|
@@ -159,7 +159,7 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
|
|
|
159
159
|
if (adapter && loadedEdges.length > 0) {
|
|
160
160
|
const targetRegisters = new Set<string>();
|
|
161
161
|
for (const edge of loadedEdges) {
|
|
162
|
-
const parsed =
|
|
162
|
+
const parsed = UriRouter.parseUri(edge.target);
|
|
163
163
|
if (parsed?.registerId && parsed.registerId !== registerId) {
|
|
164
164
|
targetRegisters.add(parsed.registerId);
|
|
165
165
|
}
|
|
@@ -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';
|