@glossarist/concept-browser 0.2.3 → 0.2.5
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/fetch-datasets.mjs +15 -9
- package/scripts/generate-data.mjs +100 -2
- package/src/__tests__/math.test.ts +27 -0
- package/src/components/AppFooter.vue +5 -1
- package/src/components/ConceptDetail.vue +25 -16
- package/src/components/LanguageDetail.vue +14 -6
- package/src/composables/use-render-options.ts +58 -0
- package/src/config/types.ts +1 -0
- package/src/config/use-site-config.ts +1 -0
- package/src/style.css +12 -0
- package/src/utils/math.ts +46 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glossarist/concept-browser",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
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": {
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"@vitejs/plugin-vue": "^5.2.3",
|
|
23
23
|
"autoprefixer": "^10.4.21",
|
|
24
24
|
"d3": "^7.9.0",
|
|
25
|
-
"glossarist": "^0.
|
|
25
|
+
"glossarist": "^0.2.0",
|
|
26
26
|
"js-yaml": "^4.1.0",
|
|
27
27
|
"katex": "^0.16.45",
|
|
28
28
|
"pinia": "^2.3.1",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import fs from 'fs';
|
|
17
17
|
import path from 'path';
|
|
18
|
-
import
|
|
18
|
+
import { loadGcr } from 'glossarist';
|
|
19
19
|
import { execSync } from 'child_process';
|
|
20
20
|
import { loadSiteConfig } from './load-site-config.mjs';
|
|
21
21
|
|
|
@@ -58,11 +58,17 @@ function extractGcr(gcrPath, targetDir) {
|
|
|
58
58
|
console.log(` Extracted to ${targetDir}`);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
// --- Read GCR metadata from
|
|
62
|
-
function readGcrMetadata(
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
61
|
+
// --- Read GCR metadata from ZIP without extraction ---
|
|
62
|
+
async function readGcrMetadata(gcrPath) {
|
|
63
|
+
if (!fs.existsSync(gcrPath)) return null;
|
|
64
|
+
try {
|
|
65
|
+
const buf = fs.readFileSync(gcrPath);
|
|
66
|
+
const pkg = await loadGcr(buf);
|
|
67
|
+
const meta = await pkg.metadata();
|
|
68
|
+
return meta ? meta.toJSON() : null;
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
// --- Dependency validation ---
|
|
@@ -167,11 +173,11 @@ for (const ds of config.datasets) {
|
|
|
167
173
|
}
|
|
168
174
|
}
|
|
169
175
|
|
|
170
|
-
// Read metadata for dependency validation
|
|
171
|
-
const meta = readGcrMetadata(
|
|
176
|
+
// Read metadata for dependency validation (from GCR ZIP, not extracted dir)
|
|
177
|
+
const meta = await readGcrMetadata(gcrPath);
|
|
172
178
|
if (meta) {
|
|
173
179
|
gcrMetadata[ds.id] = meta;
|
|
174
|
-
console.log(` ${meta.
|
|
180
|
+
console.log(` ${meta.concept_count || '?'} concepts, ${meta.uri_prefix || 'no uri'}`);
|
|
175
181
|
}
|
|
176
182
|
} catch (e) {
|
|
177
183
|
console.warn(` Failed: ${e.message}`);
|
|
@@ -98,9 +98,72 @@ function refsToJsonLd(refs) {
|
|
|
98
98
|
})).filter(r => r['@id']);
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
function buildRefMaps(config) {
|
|
102
|
+
const refPrefixMap = {};
|
|
103
|
+
const urnStandardMap = {};
|
|
104
|
+
|
|
105
|
+
for (const ds of config.datasets) {
|
|
106
|
+
const uri = ds.uri || '';
|
|
107
|
+
const urnMatch = uri.match(/^urn:iso:std:iso:(\d+):\*$/);
|
|
108
|
+
if (urnMatch) urnStandardMap[urnMatch[1]] = ds.id;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
for (const route of config.routing || []) {
|
|
112
|
+
if (route.uri && route.uri.includes('iec') && route.uri.includes('60050')) {
|
|
113
|
+
const mapped = route.targetDataset;
|
|
114
|
+
if (mapped) refPrefixMap['IEV'] = mapped;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const xref = config.crossReferences || {};
|
|
119
|
+
if (xref.refPrefixMap) Object.assign(refPrefixMap, xref.refPrefixMap);
|
|
120
|
+
if (xref.urnStandardMap) Object.assign(urnStandardMap, xref.urnStandardMap);
|
|
121
|
+
|
|
122
|
+
return { refPrefixMap, urnStandardMap };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function extractInlineRefs(localizedData, refPrefixMap, urnStandardMap) {
|
|
126
|
+
const refs = [];
|
|
127
|
+
const texts = [];
|
|
128
|
+
|
|
129
|
+
if (localizedData.definition) {
|
|
130
|
+
const defs = Array.isArray(localizedData.definition) ? localizedData.definition : [localizedData.definition];
|
|
131
|
+
for (const d of defs) texts.push(typeof d === 'string' ? d : (d.content || ''));
|
|
132
|
+
}
|
|
133
|
+
if (localizedData.notes) {
|
|
134
|
+
for (const n of localizedData.notes) texts.push(typeof n === 'string' ? n : (n.content || ''));
|
|
135
|
+
}
|
|
136
|
+
if (localizedData.examples) {
|
|
137
|
+
for (const e of localizedData.examples) texts.push(typeof e === 'string' ? e : (e.content || ''));
|
|
138
|
+
}
|
|
139
|
+
const fullText = texts.join(' ');
|
|
140
|
+
|
|
141
|
+
for (const m of fullText.matchAll(/\{\{([^,}]+),\s*IEV:([^}]+)\}\}/g)) {
|
|
142
|
+
const datasetId = refPrefixMap['IEV'];
|
|
143
|
+
if (datasetId) refs.push({ id: `https://glossarist.org/${datasetId}/concept/${m[2]}`, term: m[1].trim() });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
for (const m of fullText.matchAll(/\{urn:iso:std:iso:(\d+):([^,}]+),([^,}]+)(?:,([^}]+))?\}/g)) {
|
|
147
|
+
const datasetId = urnStandardMap[m[1]];
|
|
148
|
+
if (datasetId) refs.push({ id: `https://glossarist.org/${datasetId}/concept/${m[2]}`, term: (m[4] || m[3]).trim() });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
for (const m of fullText.matchAll(/\{\{urn:iso:std:iso:(\d+):([^,}]+),([^,}]+)(?:,([^}]+))?\}\}/g)) {
|
|
152
|
+
const datasetId = urnStandardMap[m[1]];
|
|
153
|
+
if (datasetId) refs.push({ id: `https://glossarist.org/${datasetId}/concept/${m[2]}`, term: (m[4] || m[3]).trim() });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const seen = new Set();
|
|
157
|
+
return refs.filter(r => {
|
|
158
|
+
if (seen.has(r.id)) return false;
|
|
159
|
+
seen.add(r.id);
|
|
160
|
+
return true;
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
101
164
|
const LANG_CODES = ['eng', 'ara', 'deu', 'fra', 'spa', 'ita', 'jpn', 'kor', 'pol', 'por', 'srp', 'swe', 'zho', 'rus', 'fin', 'dan', 'nld', 'msa', 'nob', 'nno', 'zho'];
|
|
102
165
|
|
|
103
|
-
function yamlToJsonLd(conceptYaml, register) {
|
|
166
|
+
function yamlToJsonLd(conceptYaml, register, refMaps) {
|
|
104
167
|
const termid = String(conceptYaml.termid);
|
|
105
168
|
const doc = {
|
|
106
169
|
'@context': 'https://glossarist.org/ns/context.jsonld',
|
|
@@ -142,6 +205,11 @@ function yamlToJsonLd(conceptYaml, register) {
|
|
|
142
205
|
}
|
|
143
206
|
if (lc.references && lc.references.length > 0) {
|
|
144
207
|
lDoc['gl:references'] = refsToJsonLd(lc.references);
|
|
208
|
+
} else if (refMaps) {
|
|
209
|
+
const inlineRefs = extractInlineRefs(lc, refMaps.refPrefixMap, refMaps.urnStandardMap);
|
|
210
|
+
if (inlineRefs.length > 0) {
|
|
211
|
+
lDoc['gl:references'] = refsToJsonLd(inlineRefs);
|
|
212
|
+
}
|
|
145
213
|
}
|
|
146
214
|
|
|
147
215
|
localizations[lang] = lDoc;
|
|
@@ -374,7 +442,7 @@ function processDataset(dir, register, opts) {
|
|
|
374
442
|
if (!conceptYaml || !conceptYaml.termid) continue;
|
|
375
443
|
|
|
376
444
|
const termid = String(conceptYaml.termid);
|
|
377
|
-
const jsonld = yamlToJsonLd(conceptYaml, register);
|
|
445
|
+
const jsonld = yamlToJsonLd(conceptYaml, register, refMaps);
|
|
378
446
|
writeJson(path.join(conceptsDir, `${termid}.json`), jsonld);
|
|
379
447
|
|
|
380
448
|
// Generate Turtle format
|
|
@@ -526,10 +594,36 @@ function processDataset(dir, register, opts) {
|
|
|
526
594
|
languageStats: langStats,
|
|
527
595
|
availableFormats,
|
|
528
596
|
bulkFormats,
|
|
597
|
+
hasBibliography: opts.hasBibliography,
|
|
598
|
+
hasImages: opts.hasImages,
|
|
529
599
|
};
|
|
530
600
|
if (opts.languageOrder) manifest.languageOrder = opts.languageOrder;
|
|
531
601
|
writeJson(path.join(DATA, register, 'manifest.json'), manifest);
|
|
532
602
|
|
|
603
|
+
// Copy bibliography.yaml → bibliography.json
|
|
604
|
+
const bibPath = path.join(ROOT, '.datasets', register, 'bibliography.yaml');
|
|
605
|
+
if (fs.existsSync(bibPath)) {
|
|
606
|
+
const bibData = readYaml(bibPath);
|
|
607
|
+
writeJson(path.join(DATA, register, 'bibliography.json'), bibData);
|
|
608
|
+
console.log(` Copied bibliography (${Object.keys(bibData).length} entries)`);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Copy images/
|
|
612
|
+
const imagesSrcDir = path.join(ROOT, '.datasets', register, 'images');
|
|
613
|
+
if (fs.existsSync(imagesSrcDir) && fs.statSync(imagesSrcDir).isDirectory()) {
|
|
614
|
+
const imagesDestDir = path.join(DATA, register, 'images');
|
|
615
|
+
fs.mkdirSync(imagesDestDir, { recursive: true });
|
|
616
|
+
let imgCount = 0;
|
|
617
|
+
for (const file of fs.readdirSync(imagesSrcDir)) {
|
|
618
|
+
const src = path.join(imagesSrcDir, file);
|
|
619
|
+
if (fs.statSync(src).isFile()) {
|
|
620
|
+
fs.copyFileSync(src, path.join(imagesDestDir, file));
|
|
621
|
+
imgCount++;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
console.log(` Copied ${imgCount} images`);
|
|
625
|
+
}
|
|
626
|
+
|
|
533
627
|
console.log(` Generated ${concepts.length} concepts, manifest, ${chunks.length} index chunks`);
|
|
534
628
|
return concepts.length;
|
|
535
629
|
}
|
|
@@ -538,6 +632,7 @@ function processDataset(dir, register, opts) {
|
|
|
538
632
|
console.log('Generating Glossarist vocabulary browser data...\n');
|
|
539
633
|
|
|
540
634
|
const { config } = loadSiteConfig();
|
|
635
|
+
const refMaps = buildRefMaps(config);
|
|
541
636
|
const counts = {};
|
|
542
637
|
const registry = [];
|
|
543
638
|
|
|
@@ -571,6 +666,8 @@ for (let i = 0; i < config.datasets.length; i++) {
|
|
|
571
666
|
color: ds.color || DS_PALETTE[i % DS_PALETTE.length],
|
|
572
667
|
datasetUri: ds.uri,
|
|
573
668
|
uriAliases: ds.uriAliases,
|
|
669
|
+
hasBibliography: fs.existsSync(path.join(ROOT, '.datasets', ds.id, 'bibliography.yaml')),
|
|
670
|
+
hasImages: fs.existsSync(path.join(ROOT, '.datasets', ds.id, 'images')),
|
|
574
671
|
});
|
|
575
672
|
registry.push({ id: ds.id, manifestUrl: `/data/${ds.id}/manifest.json` });
|
|
576
673
|
}
|
|
@@ -824,6 +921,7 @@ writeJson(path.join(PUBLIC, 'site-config.json'), {
|
|
|
824
921
|
email: config.email,
|
|
825
922
|
pages: processedPages.length > 0 ? processedPages : undefined,
|
|
826
923
|
contributors: config.contributors || undefined,
|
|
924
|
+
copyright: config.copyright || undefined,
|
|
827
925
|
});
|
|
828
926
|
console.log('Generated site-config.json');
|
|
829
927
|
|
|
@@ -63,6 +63,29 @@ describe('renderMath', () => {
|
|
|
63
63
|
expect(result).toBe('a entity ref');
|
|
64
64
|
});
|
|
65
65
|
|
|
66
|
+
it('uses display text from three-part URN refs', () => {
|
|
67
|
+
const result = renderMath('a {{urn:iso:std:iso:14812:3.1.1.6,person,Person}} ref');
|
|
68
|
+
expect(result).toBe('a Person ref');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('resolves three-part URN refs with display text via xrefResolver', () => {
|
|
72
|
+
const resolver = (uri: string, term: string) => `[${term}→${uri}]`;
|
|
73
|
+
const result = renderMath(
|
|
74
|
+
'{{urn:iso:std:iso:14812:3.1.1.6,person,Person}}, object, event',
|
|
75
|
+
resolver,
|
|
76
|
+
);
|
|
77
|
+
expect(result).toBe('[Person→urn:iso:std:iso:14812:3.1.1.6], object, event');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('resolves single-braced three-part URN refs', () => {
|
|
81
|
+
const resolver = (uri: string, term: string) => `[${term}→${uri}]`;
|
|
82
|
+
const result = renderMath(
|
|
83
|
+
'{urn:iso:std:iso:14812:3.5.3.4,user,users} are people',
|
|
84
|
+
resolver,
|
|
85
|
+
);
|
|
86
|
+
expect(result).toBe('[users→urn:iso:std:iso:14812:3.5.3.4] are people');
|
|
87
|
+
});
|
|
88
|
+
|
|
66
89
|
it('strips remaining {{...}} to just the term', () => {
|
|
67
90
|
const result = renderMath('see {{some term, unknown ref}}');
|
|
68
91
|
expect(result).toBe('see some term');
|
|
@@ -106,6 +129,10 @@ describe('cleanContent', () => {
|
|
|
106
129
|
expect(cleanContent('a {{urn:iso:std:iso:14812:3.1.1.1,entity}} ref')).toBe('a entity ref');
|
|
107
130
|
});
|
|
108
131
|
|
|
132
|
+
it('strips three-part URN refs to the linked term (not display text)', () => {
|
|
133
|
+
expect(cleanContent('{{urn:iso:std:iso:14812:3.1.1.6,person,Person}}, object')).toBe('person, object');
|
|
134
|
+
});
|
|
135
|
+
|
|
109
136
|
it('handles empty input', () => {
|
|
110
137
|
expect(cleanContent('')).toBe('');
|
|
111
138
|
expect(cleanContent(null as any)).toBe('');
|
|
@@ -19,6 +19,7 @@ const socialLinks = computed(() => {
|
|
|
19
19
|
});
|
|
20
20
|
|
|
21
21
|
const footerNav = computed(() => config.value?.footerNav ?? []);
|
|
22
|
+
const copyrightOwner = computed(() => config.value?.copyright || '');
|
|
22
23
|
const ownerName = computed(() => config.value?.branding?.ownerName || config.value?.title || '');
|
|
23
24
|
const ownerUrl = computed(() => config.value?.branding?.ownerUrl || '/');
|
|
24
25
|
</script>
|
|
@@ -28,7 +29,10 @@ const ownerUrl = computed(() => config.value?.branding?.ownerUrl || '/');
|
|
|
28
29
|
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
|
29
30
|
<div class="flex flex-col sm:flex-row items-center justify-between gap-4 text-sm text-ink-400">
|
|
30
31
|
<div class="flex items-center gap-3">
|
|
31
|
-
<span v-if="
|
|
32
|
+
<span v-if="copyrightOwner">
|
|
33
|
+
© {{ new Date().getFullYear() }} {{ copyrightOwner }}
|
|
34
|
+
</span>
|
|
35
|
+
<span v-else-if="ownerName">
|
|
32
36
|
© {{ new Date().getFullYear() }}
|
|
33
37
|
<a v-if="ownerUrl" :href="ownerUrl" target="_blank" rel="noopener" class="concept-link">{{ ownerName }}</a>
|
|
34
38
|
<span v-else>{{ ownerName }}</span>
|
|
@@ -4,11 +4,12 @@ import type { Manifest } from '../adapters/types';
|
|
|
4
4
|
import { computed, ref, nextTick, watch } from 'vue';
|
|
5
5
|
import { langName, langLabel } from '../utils/lang';
|
|
6
6
|
import { renderMath, cleanContent } from '../utils/math';
|
|
7
|
-
import type {
|
|
7
|
+
import type { RenderOptions } from '../utils/math';
|
|
8
8
|
import { useRouter } from 'vue-router';
|
|
9
9
|
import { useVocabularyStore } from '../stores/vocabulary';
|
|
10
10
|
import { useDsStyle } from '../utils/dataset-style';
|
|
11
11
|
import { getFactory } from '../adapters/factory';
|
|
12
|
+
import { useRenderOptions } from '../composables/use-render-options';
|
|
12
13
|
import ConceptTimeline from './ConceptTimeline.vue';
|
|
13
14
|
import FormatDownloads from './FormatDownloads.vue';
|
|
14
15
|
|
|
@@ -87,20 +88,28 @@ function escapeAttr(s: string) {
|
|
|
87
88
|
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
88
89
|
}
|
|
89
90
|
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
91
|
+
const { ensureBibLoaded, bibResolver, figResolver } = useRenderOptions(() => props.registerId);
|
|
92
|
+
|
|
93
|
+
const renderOpts: RenderOptions = {
|
|
94
|
+
xrefResolver: (uri, term) => {
|
|
95
|
+
const resolution = factory.resolve(uri, props.registerId);
|
|
96
|
+
if (resolution.type === 'internal') {
|
|
97
|
+
return `<a href="#" class="xref-link" data-register="${escapeAttr(resolution.registerId)}" data-concept="${escapeAttr(resolution.conceptId)}">${escapeAttr(term)}</a>`;
|
|
98
|
+
}
|
|
99
|
+
if (resolution.type === 'site') {
|
|
100
|
+
return `<a href="${escapeAttr(resolution.baseUrl)}/resolve/${escapeAttr(encodeURIComponent(uri))}" target="_blank" rel="noopener" class="xref-link xref-external">${escapeAttr(term)}</a>`;
|
|
101
|
+
}
|
|
102
|
+
if (resolution.type === 'url') {
|
|
103
|
+
return `<a href="${escapeAttr(resolution.url)}" target="_blank" rel="noopener" class="xref-link xref-external">${escapeAttr(term)}</a>`;
|
|
104
|
+
}
|
|
105
|
+
return escapeAttr(term);
|
|
106
|
+
},
|
|
107
|
+
bibResolver,
|
|
108
|
+
figResolver,
|
|
102
109
|
};
|
|
103
110
|
|
|
111
|
+
watch(() => props.registerId, () => { ensureBibLoaded(); }, { immediate: true });
|
|
112
|
+
|
|
104
113
|
// Handle clicks on cross-reference links via event delegation
|
|
105
114
|
function handleContentClick(e: MouseEvent) {
|
|
106
115
|
const target = (e.target as HTMLElement).closest('.xref-link') as HTMLElement | null;
|
|
@@ -415,14 +424,14 @@ function plainTruncate(html: string, max: number = 120): string {
|
|
|
415
424
|
|
|
416
425
|
<!-- Definition -->
|
|
417
426
|
<div v-if="lc.definition" class="p-4 rounded-lg bg-surface border-l-2" :style="{ borderLeftColor: getColor(manifest.id) }">
|
|
418
|
-
<div class="text-ink-800 leading-relaxed" v-html="renderMath(lc.definition,
|
|
427
|
+
<div class="text-ink-800 leading-relaxed" v-html="renderMath(lc.definition, renderOpts)"></div>
|
|
419
428
|
</div>
|
|
420
429
|
|
|
421
430
|
<!-- Notes -->
|
|
422
431
|
<div v-if="lc.notes.length" class="space-y-2">
|
|
423
432
|
<div v-for="(note, i) in lc.notes" :key="i" class="text-ink-600 text-sm leading-relaxed">
|
|
424
433
|
<span class="font-medium text-ink-400 text-xs uppercase tracking-wide">Note {{ i + 1 }}</span>
|
|
425
|
-
<div class="mt-1" v-html="renderMath(note,
|
|
434
|
+
<div class="mt-1" v-html="renderMath(note, renderOpts)"></div>
|
|
426
435
|
</div>
|
|
427
436
|
</div>
|
|
428
437
|
|
|
@@ -430,7 +439,7 @@ function plainTruncate(html: string, max: number = 120): string {
|
|
|
430
439
|
<div v-if="lc.examples.length" class="space-y-2">
|
|
431
440
|
<div v-for="(ex, i) in lc.examples" :key="i" class="text-ink-600 text-sm leading-relaxed">
|
|
432
441
|
<span class="font-medium text-ink-400 text-xs uppercase tracking-wide">Example {{ i + 1 }}</span>
|
|
433
|
-
<div class="mt-1" v-html="renderMath(ex,
|
|
442
|
+
<div class="mt-1" v-html="renderMath(ex, renderOpts)"></div>
|
|
434
443
|
</div>
|
|
435
444
|
</div>
|
|
436
445
|
|
|
@@ -3,7 +3,7 @@ import type { LocalizedConcept, Designation, ConceptSource } from '../adapters/t
|
|
|
3
3
|
import { computed } from 'vue';
|
|
4
4
|
import { langName, langLabel } from '../utils/lang';
|
|
5
5
|
import { renderMath } from '../utils/math';
|
|
6
|
-
import type {
|
|
6
|
+
import type { RenderOptions } from '../utils/math';
|
|
7
7
|
import { useRouter } from 'vue-router';
|
|
8
8
|
import { useVocabularyStore } from '../stores/vocabulary';
|
|
9
9
|
import { getFactory } from '../adapters/factory';
|
|
@@ -82,8 +82,16 @@ function escapeAttr(s: string) {
|
|
|
82
82
|
|
|
83
83
|
const factory = getFactory();
|
|
84
84
|
|
|
85
|
-
const
|
|
86
|
-
|
|
85
|
+
const renderOpts: RenderOptions = {
|
|
86
|
+
xrefResolver: (uri, term) => {
|
|
87
|
+
return `<a href="#" class="xref-link" data-uri="${escapeAttr(uri)}">${escapeAttr(term)}</a>`;
|
|
88
|
+
},
|
|
89
|
+
bibResolver: (refId, title) => {
|
|
90
|
+
return `<span class="bib-ref">${escapeAttr(title)}</span>`;
|
|
91
|
+
},
|
|
92
|
+
figResolver: (figId) => {
|
|
93
|
+
return `<span class="fig-ref">${escapeAttr(figId)}</span>`;
|
|
94
|
+
},
|
|
87
95
|
};
|
|
88
96
|
|
|
89
97
|
function handleContentClick(e: MouseEvent) {
|
|
@@ -145,7 +153,7 @@ function handleContentClick(e: MouseEvent) {
|
|
|
145
153
|
<!-- Definition -->
|
|
146
154
|
<div v-if="definition" class="card p-5">
|
|
147
155
|
<div class="section-label">Definition</div>
|
|
148
|
-
<div class="text-ink-800 leading-relaxed mt-3" v-html="renderMath(definition,
|
|
156
|
+
<div class="text-ink-800 leading-relaxed mt-3" v-html="renderMath(definition, renderOpts)"></div>
|
|
149
157
|
</div>
|
|
150
158
|
|
|
151
159
|
<!-- Notes -->
|
|
@@ -154,7 +162,7 @@ function handleContentClick(e: MouseEvent) {
|
|
|
154
162
|
<div class="space-y-3 mt-3">
|
|
155
163
|
<div v-for="(note, i) in notes" :key="i" class="text-ink-600 text-sm leading-relaxed">
|
|
156
164
|
<span class="font-medium text-ink-400 text-xs uppercase tracking-wide">Note {{ i + 1 }}</span>
|
|
157
|
-
<div class="mt-1" v-html="renderMath(note,
|
|
165
|
+
<div class="mt-1" v-html="renderMath(note, renderOpts)"></div>
|
|
158
166
|
</div>
|
|
159
167
|
</div>
|
|
160
168
|
</div>
|
|
@@ -165,7 +173,7 @@ function handleContentClick(e: MouseEvent) {
|
|
|
165
173
|
<div class="space-y-3 mt-3">
|
|
166
174
|
<div v-for="(ex, i) in examples" :key="i" class="text-ink-600 text-sm leading-relaxed">
|
|
167
175
|
<span class="font-medium text-ink-400 text-xs uppercase tracking-wide">Example {{ i + 1 }}</span>
|
|
168
|
-
<div class="mt-1" v-html="renderMath(ex,
|
|
176
|
+
<div class="mt-1" v-html="renderMath(ex, renderOpts)"></div>
|
|
169
177
|
</div>
|
|
170
178
|
</div>
|
|
171
179
|
</div>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { ref, watch } from 'vue';
|
|
2
|
+
import type { RenderOptions, BibResolver, FigResolver } from '../utils/math';
|
|
3
|
+
import { getFactory } from '../adapters/factory';
|
|
4
|
+
|
|
5
|
+
interface BibEntry {
|
|
6
|
+
reference: string;
|
|
7
|
+
title?: string;
|
|
8
|
+
link?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const bibCache = new Map<string, Record<string, BibEntry>>();
|
|
12
|
+
|
|
13
|
+
async function loadBibliography(registerId: string): Promise<Record<string, BibEntry> | null> {
|
|
14
|
+
if (bibCache.has(registerId)) return bibCache.get(registerId)!;
|
|
15
|
+
try {
|
|
16
|
+
const resp = await fetch(`/data/${registerId}/bibliography.json`);
|
|
17
|
+
if (!resp.ok) return null;
|
|
18
|
+
const data = await resp.json();
|
|
19
|
+
bibCache.set(registerId, data);
|
|
20
|
+
return data;
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function useRenderOptions(registerId: () => string) {
|
|
27
|
+
const bibData = ref<Record<string, BibEntry> | null>(null);
|
|
28
|
+
|
|
29
|
+
async function ensureBibLoaded() {
|
|
30
|
+
const id = registerId();
|
|
31
|
+
if (!id) return;
|
|
32
|
+
bibData.value = await loadBibliography(id);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const bibResolver: BibResolver = (refId, title) => {
|
|
36
|
+
const entry = bibData.value?.[refId];
|
|
37
|
+
if (!entry) {
|
|
38
|
+
return `<span class="bib-ref">${escapeAttr(title)}</span>`;
|
|
39
|
+
}
|
|
40
|
+
const display = title || entry.reference;
|
|
41
|
+
if (entry.link) {
|
|
42
|
+
return `<a href="${escapeAttr(entry.link)}" target="_blank" rel="noopener" class="bib-link" title="${escapeAttr(entry.title || '')}">${escapeAttr(display)}</a>`;
|
|
43
|
+
}
|
|
44
|
+
return `<span class="bib-ref" title="${escapeAttr(entry.title || '')}">${escapeAttr(display)}</span>`;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const figResolver: FigResolver = (figId) => {
|
|
48
|
+
const id = registerId();
|
|
49
|
+
const imgSrc = `/data/${id}/images/${figId}.png`;
|
|
50
|
+
return `<span class="fig-ref"><a href="${escapeAttr(imgSrc)}" target="_blank" rel="noopener">${escapeAttr(figId)}</a></span>`;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
return { bibData, ensureBibLoaded, bibResolver, figResolver };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function escapeAttr(s: string) {
|
|
57
|
+
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
58
|
+
}
|
package/src/config/types.ts
CHANGED
|
@@ -30,6 +30,7 @@ export interface RuntimeSiteConfig {
|
|
|
30
30
|
email?: string;
|
|
31
31
|
pages?: PageConfig[];
|
|
32
32
|
contributors?: { name: string; role?: string; organization?: string; url?: string; email?: string }[];
|
|
33
|
+
copyright?: string;
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
const siteConfig = ref<RuntimeSiteConfig | null>(null);
|
package/src/style.css
CHANGED
|
@@ -121,6 +121,18 @@
|
|
|
121
121
|
font-weight: 500;
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
.bib-link {
|
|
125
|
+
@apply text-ink-600 hover:text-ink-800 underline-offset-2 hover:underline transition-colors;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.bib-ref {
|
|
129
|
+
@apply text-ink-500 italic;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.fig-ref a {
|
|
133
|
+
@apply text-ink-600 hover:text-ink-800 underline-offset-2 hover:underline transition-colors;
|
|
134
|
+
}
|
|
135
|
+
|
|
124
136
|
/* Concept definition lists */
|
|
125
137
|
.concept-list {
|
|
126
138
|
list-style: disc;
|
package/src/utils/math.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import katex from 'katex';
|
|
2
2
|
|
|
3
3
|
export type XrefResolver = (uri: string, term: string) => string;
|
|
4
|
+
export type BibResolver = (refId: string, title: string) => string;
|
|
5
|
+
export type FigResolver = (figId: string) => string;
|
|
6
|
+
|
|
7
|
+
export interface RenderOptions {
|
|
8
|
+
xrefResolver?: XrefResolver;
|
|
9
|
+
bibResolver?: BibResolver;
|
|
10
|
+
figResolver?: FigResolver;
|
|
11
|
+
}
|
|
4
12
|
|
|
5
13
|
/**
|
|
6
14
|
* Convert `* item` lines into <ul><li> blocks.
|
|
@@ -39,12 +47,16 @@ function convertLists(text: string): string {
|
|
|
39
47
|
|
|
40
48
|
/**
|
|
41
49
|
* Render stem:[...] math notation to KaTeX HTML.
|
|
42
|
-
* Also handles cross-reference inline patterns (URN refs).
|
|
50
|
+
* Also handles cross-reference inline patterns (URN refs, bibliography, figures).
|
|
43
51
|
*/
|
|
44
|
-
export function renderMath(text: string,
|
|
52
|
+
export function renderMath(text: string, xrefResolverOrOpts?: XrefResolver | RenderOptions): string {
|
|
45
53
|
if (!text) return '';
|
|
46
54
|
let result = text;
|
|
47
55
|
|
|
56
|
+
const opts: RenderOptions = typeof xrefResolverOrOpts === 'function'
|
|
57
|
+
? { xrefResolver: xrefResolverOrOpts }
|
|
58
|
+
: (xrefResolverOrOpts ?? {});
|
|
59
|
+
|
|
48
60
|
result = result.replace(/\*stem:\[([^\]]*)\]\*/g, (_, math) => {
|
|
49
61
|
return renderKatexSpan(math, true);
|
|
50
62
|
});
|
|
@@ -58,20 +70,38 @@ export function renderMath(text: string, xrefResolver?: XrefResolver): string {
|
|
|
58
70
|
result = result.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
|
59
71
|
result = result.replace(/~([^~]+)~/g, '<sub>$1</sub>');
|
|
60
72
|
|
|
61
|
-
// Handle
|
|
62
|
-
result = result.replace(
|
|
63
|
-
if (
|
|
64
|
-
return
|
|
73
|
+
// Handle AsciiDoc bibliography xrefs: <<ref_XX,title>>
|
|
74
|
+
result = result.replace(/<<([^,>]+),([^>]+)>>/g, (_, refId, title) => {
|
|
75
|
+
if (opts.bibResolver) {
|
|
76
|
+
return opts.bibResolver(refId.trim(), title.trim());
|
|
77
|
+
}
|
|
78
|
+
return `<span class="bib-ref">${escapeHtml(title.trim())}</span>`;
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Handle AsciiDoc figure xrefs: <<fig_XX>>
|
|
82
|
+
result = result.replace(/<<(fig_[^>]+)>>/g, (_, figId) => {
|
|
83
|
+
if (opts.figResolver) {
|
|
84
|
+
return opts.figResolver(figId.trim());
|
|
85
|
+
}
|
|
86
|
+
return `<span class="fig-ref">${escapeHtml(figId.trim())}</span>`;
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Handle URN inline refs: {{urn:...,term[,displayText]}} (double-braced)
|
|
90
|
+
result = result.replace(/\{\{(urn:[^,}]+),([^,}]+)(?:,([^}]+))?\}\}/g, (_, uri, term, display) => {
|
|
91
|
+
const text = (display || term).trim();
|
|
92
|
+
if (opts.xrefResolver) {
|
|
93
|
+
return opts.xrefResolver(uri, text);
|
|
65
94
|
}
|
|
66
|
-
return
|
|
95
|
+
return text;
|
|
67
96
|
});
|
|
68
97
|
|
|
69
|
-
// Handle URN inline refs: {urn:...,term} (single-braced)
|
|
70
|
-
result = result.replace(/\{(urn:[^,}]+),([^}]+)
|
|
71
|
-
|
|
72
|
-
|
|
98
|
+
// Handle URN inline refs: {urn:...,term[,displayText]} (single-braced)
|
|
99
|
+
result = result.replace(/\{(urn:[^,}]+),([^,}]+)(?:,([^}]+))?\}/g, (_, uri, term, display) => {
|
|
100
|
+
const text = (display || term).trim();
|
|
101
|
+
if (opts.xrefResolver) {
|
|
102
|
+
return opts.xrefResolver(uri, text);
|
|
73
103
|
}
|
|
74
|
-
return
|
|
104
|
+
return text;
|
|
75
105
|
});
|
|
76
106
|
|
|
77
107
|
// Handle any remaining {{...}} refs (fallback: show term before comma)
|
|
@@ -111,7 +141,9 @@ export function cleanContent(text: string): string {
|
|
|
111
141
|
.replace(/\*([^*]+)\*/g, '$1')
|
|
112
142
|
.replace(/~([^~]+)~/g, '_$1')
|
|
113
143
|
.replace(/\n[ \t]*\* /g, '; ')
|
|
114
|
-
.replace(
|
|
115
|
-
.replace(
|
|
144
|
+
.replace(/<<([^,>]+),([^>]+)>>/g, '$2')
|
|
145
|
+
.replace(/<<(fig_[^>]+)>>/g, '$1')
|
|
146
|
+
.replace(/\{\{urn:[^,}]+,([^,}]+)(?:,[^}]+)?\}\}/g, '$1')
|
|
147
|
+
.replace(/\{urn:[^,}]+,([^,}]+)(?:,[^}]+)?\}/g, '$1')
|
|
116
148
|
.replace(/\{\{([^,}]+)(?:,\s*[^}]+)?\}\}/g, '$1');
|
|
117
149
|
}
|