@glossarist/concept-browser 0.2.2 → 0.2.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glossarist/concept-browser",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
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.1.1",
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 yaml from 'js-yaml';
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 extracted dir ---
62
- function readGcrMetadata(targetDir) {
63
- const metaPath = path.join(targetDir, 'metadata.yaml');
64
- if (!fs.existsSync(metaPath)) return null;
65
- return yaml.load(fs.readFileSync(metaPath, 'utf8'));
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(targetDir);
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.statistics?.total_concepts || '?'} concepts, ${meta.uri || 'no uri'}`);
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('');
@@ -6,7 +6,7 @@ const { config } = useSiteConfig();
6
6
 
7
7
  const poweredBy = computed(() => {
8
8
  const pb = config.value?.features?.poweredBy as { title?: string; url?: string } | undefined;
9
- return { title: pb?.title || 'Glossarist', url: pb?.url || 'https://glossarist.org' };
9
+ return { title: pb?.title || 'Glossarist Concept Browser', url: pb?.url || 'https://github.com/glossarist/concept-browser' };
10
10
  });
11
11
 
12
12
  const socialLinks = computed(() => {
@@ -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="ownerName">
32
+ <span v-if="copyrightOwner">
33
+ &copy; {{ new Date().getFullYear() }} {{ copyrightOwner }}
34
+ </span>
35
+ <span v-else-if="ownerName">
32
36
  &copy; {{ 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>
@@ -51,7 +55,7 @@ const ownerUrl = computed(() => config.value?.branding?.ownerUrl || '/');
51
55
  >{{ link.label }}</a>
52
56
  <span class="text-ink-200">|</span>
53
57
  <span class="text-xs">
54
- Powered by <a :href="poweredBy.url" target="_blank" rel="noopener" class="concept-link">{{ poweredBy.title }}</a>
58
+ Built with the <a :href="poweredBy.url" target="_blank" rel="noopener" class="concept-link">{{ poweredBy.title }}</a>
55
59
  </span>
56
60
  </div>
57
61
  </div>
@@ -31,6 +31,7 @@ const datasetEntries = computed(() => {
31
31
  });
32
32
 
33
33
  const currentManifest = computed(() => store.manifests.get(currentDataset.value));
34
+ const showDatasetNav = computed(() => !!currentManifest.value || !!siteConfig.value?.defaultDataset);
34
35
 
35
36
  function closeMobile() { ui.sidebarOpen = false; }
36
37
 
@@ -42,7 +43,8 @@ function goToDataset(id: string) {
42
43
  function pageRoute(page: { route: string; datasetScoped?: boolean }): string {
43
44
  if (!page.route) return '/';
44
45
  if (page.datasetScoped) {
45
- return `/dataset/${currentDataset.value}/${page.route}`;
46
+ const dsId = currentDataset.value || siteConfig.value?.defaultDataset || '';
47
+ return `/dataset/${dsId}/${page.route}`;
46
48
  }
47
49
  return `/${page.route}`;
48
50
  }
@@ -84,8 +86,8 @@ function isActive(page: { route: string; datasetScoped?: boolean }): boolean {
84
86
  </nav>
85
87
 
86
88
  <!-- Dataset-level navigation (shown when viewing a dataset) -->
87
- <div v-if="currentManifest" class="mb-6">
88
- <div class="section-label">{{ currentManifest.title }}</div>
89
+ <div v-if="showDatasetNav" class="mb-6">
90
+ <div class="section-label">{{ currentManifest?.title || siteConfig?.title || 'Dataset' }}</div>
89
91
  <nav class="space-y-0.5">
90
92
  <router-link
91
93
  v-for="page in datasetPages"
@@ -126,13 +128,13 @@ function isActive(page: { route: string; datasetScoped?: boolean }): boolean {
126
128
  <!-- Powered by -->
127
129
  <div class="mt-6 pt-4 border-t border-ink-100/60">
128
130
  <div class="text-[11px] text-ink-300">
129
- Powered by
131
+ Built with the
130
132
  <a
131
133
  :href="(siteConfig?.features?.poweredBy as any)?.url || 'https://glossarist.org'"
132
134
  target="_blank"
133
135
  rel="noopener"
134
136
  class="concept-link"
135
- >{{ (siteConfig?.features?.poweredBy as any)?.title || 'Glossarist' }}</a>
137
+ >{{ (siteConfig?.features?.poweredBy as any)?.title || 'Glossarist Concept Browser' }}</a>
136
138
  </div>
137
139
  </div>
138
140
  </div>
@@ -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 { XrefResolver } from '../utils/math';
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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
88
89
  }
89
90
 
90
- const xrefResolver: XrefResolver = (uri, term) => {
91
- const resolution = factory.resolve(uri, props.registerId);
92
- if (resolution.type === 'internal') {
93
- return `<a href="#" class="xref-link" data-register="${escapeAttr(resolution.registerId)}" data-concept="${escapeAttr(resolution.conceptId)}">${escapeAttr(term)}</a>`;
94
- }
95
- if (resolution.type === 'site') {
96
- return `<a href="${escapeAttr(resolution.baseUrl)}/resolve/${escapeAttr(encodeURIComponent(uri))}" target="_blank" rel="noopener" class="xref-link xref-external">${escapeAttr(term)}</a>`;
97
- }
98
- if (resolution.type === 'url') {
99
- return `<a href="${escapeAttr(resolution.url)}" target="_blank" rel="noopener" class="xref-link xref-external">${escapeAttr(term)}</a>`;
100
- }
101
- return escapeAttr(term);
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, xrefResolver)"></div>
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, xrefResolver)"></div>
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, xrefResolver)"></div>
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 { XrefResolver } from '../utils/math';
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 xrefResolver: XrefResolver = (uri, term) => {
86
- return `<a href="#" class="xref-link" data-uri="${escapeAttr(uri)}">${escapeAttr(term)}</a>`;
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, xrefResolver)"></div>
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, xrefResolver)"></div>
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, xrefResolver)"></div>
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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
58
+ }
@@ -155,4 +155,5 @@ export interface SiteConfig {
155
155
  email?: string;
156
156
  pages?: PageConfig[];
157
157
  contributors?: Contributor[];
158
+ copyright?: string;
158
159
  }
@@ -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;
@@ -196,6 +208,10 @@
196
208
  .prose-news li {
197
209
  @apply mb-1;
198
210
  }
211
+ .prose-news li.list-level-2 {
212
+ @apply pl-3;
213
+ list-style-type: circle;
214
+ }
199
215
  .prose-page a,
200
216
  .prose-news a {
201
217
  @apply concept-link;
@@ -53,11 +53,14 @@ export function renderAsciiDocLite(text: string): string {
53
53
  }
54
54
 
55
55
  // Unordered list item
56
- if (trimmed.match(/^\*\s+/)) {
56
+ if (trimmed.match(/^\*+\s+/)) {
57
57
  flushParagraph(output);
58
58
  const items: string[] = [];
59
- while (i < lines.length && lines[i].trim().match(/^\*\s+/)) {
60
- items.push(`<li>${inlineFormat(lines[i].trim().replace(/^\*\s+/, ''))}</li>`);
59
+ while (i < lines.length && lines[i].trim().match(/^\*+\s+/)) {
60
+ const itemLine = lines[i].trim();
61
+ const stars = itemLine.match(/^(\*+)\s+/)?.[1].length ?? 1;
62
+ const text = itemLine.replace(/^\*+\s+/, '');
63
+ items.push(`<li class="list-level-${stars}">${inlineFormat(text)}</li>`);
61
64
  i++;
62
65
  }
63
66
  output.push(`<ul>${items.join('')}</ul>`);
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, xrefResolver?: XrefResolver): 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 URN inline refs: {{urn:...,term}} (double-braced)
62
- result = result.replace(/\{\{(urn:[^,}]+),([^}]+)\}\}/g, (_, uri, term) => {
63
- if (xrefResolver) {
64
- return xrefResolver(uri, term.trim());
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 term.trim();
95
+ return text;
67
96
  });
68
97
 
69
- // Handle URN inline refs: {urn:...,term} (single-braced)
70
- result = result.replace(/\{(urn:[^,}]+),([^}]+)\}/g, (_, uri, term) => {
71
- if (xrefResolver) {
72
- return xrefResolver(uri, term.trim());
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 term.trim();
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(/\{\{urn:[^,}]+,([^}]+)\}\}/g, '$1')
115
- .replace(/\{urn:[^,}]+,([^}]+)\}/g, '$1')
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
  }
@@ -64,7 +64,11 @@ function stripFrontmatter(text: string): string {
64
64
  function formatDate(dateStr: string) {
65
65
  if (!dateStr) return '';
66
66
  try {
67
- return new Date(dateStr).toLocaleDateString('en-US', {
67
+ // Handle Jekyll-style dates: "2024-06-19 00:00:00 +0800"
68
+ const normalized = dateStr
69
+ .replace(/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})\s+([+-]\d{2})(\d{2})$/, '$1T$2$3:$4')
70
+ .replace(/^(\d{4}-\d{2}-\d{2})\s+([+-]\d{2})(\d{2})$/, '$1T00:00:00$2:$3');
71
+ return new Date(normalized).toLocaleDateString('en-US', {
68
72
  year: 'numeric', month: 'long', day: 'numeric',
69
73
  });
70
74
  } catch {
@@ -136,7 +140,7 @@ function formatDate(dateStr: string) {
136
140
  <p v-if="activeSlug !== post.slug && post.excerpt" class="text-ink-500 text-sm mt-2 leading-relaxed">{{ post.excerpt }}</p>
137
141
  </button>
138
142
 
139
- <div v-if="activeSlug === post.slug" class="card border-t-0 rounded-t-none p-6 pt-2 -mt-1">
143
+ <div v-if="activeSlug === post.slug" class="card rounded-t-none -mt-1 p-6 pt-3 border-t border-ink-100/40">
140
144
  <div v-if="activeLoading" class="animate-pulse space-y-2">
141
145
  <div class="h-4 bg-ink-100 rounded w-full"></div>
142
146
  <div class="h-4 bg-ink-100 rounded w-5/6"></div>