@glossarist/concept-browser 0.1.7 → 0.1.9
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 +1 -1
- package/scripts/generate-data.mjs +131 -1
- package/src/App.vue +3 -1
- package/src/adapters/DatasetAdapter.ts +10 -2
- package/src/adapters/types.ts +2 -1
- package/src/components/AppFooter.vue +60 -0
- package/src/components/AppSidebar.vue +10 -5
- package/src/components/ConceptCard.vue +13 -9
- package/src/components/ConceptDetail.vue +35 -8
- package/src/components/SearchBar.vue +2 -0
- package/src/config/types.ts +17 -0
- package/src/style.css +3 -0
- package/src/utils/concept-formats.ts +1 -0
- package/src/utils/math.ts +19 -2
- package/src/views/ContributorsView.vue +5 -0
- package/src/views/DatasetView.vue +47 -4
- package/src/views/GraphView.vue +3 -3
- package/src/views/HomeView.vue +7 -3
- package/src/views/NewsView.vue +5 -0
- package/src/views/ResolveView.vue +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glossarist/concept-browser",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
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": {
|
|
@@ -257,6 +257,105 @@ function conceptJsonToSkosJsonLd(concept) {
|
|
|
257
257
|
return JSON.stringify(doc, null, 2);
|
|
258
258
|
}
|
|
259
259
|
|
|
260
|
+
function escapeXml(s) {
|
|
261
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function conceptJsonToTbx(concept) {
|
|
265
|
+
const id = concept['gl:identifier'] || '';
|
|
266
|
+
const uri = concept['@id'] || '';
|
|
267
|
+
const localized = concept['gl:localizedConcept'] || {};
|
|
268
|
+
|
|
269
|
+
const langSections = [];
|
|
270
|
+
for (const [lang, lc] of Object.entries(localized)) {
|
|
271
|
+
const descs = lc['gl:designation'] || [];
|
|
272
|
+
const definitions = (lc['gl:definition'] || []).filter(d => d['gl:content']);
|
|
273
|
+
const notes = (lc['gl:notes'] || []).filter(d => d['gl:content']);
|
|
274
|
+
const examples = (lc['gl:examples'] || []).filter(d => d['gl:content']);
|
|
275
|
+
const sources = lc['gl:source'] || [];
|
|
276
|
+
const entryStatus = lc['gl:entryStatus'] || '';
|
|
277
|
+
|
|
278
|
+
if (!descs.length && !definitions.length) continue;
|
|
279
|
+
|
|
280
|
+
const termEntries = [];
|
|
281
|
+
for (const d of descs) {
|
|
282
|
+
const term = d['gl:term'];
|
|
283
|
+
if (!term) continue;
|
|
284
|
+
const status = d['gl:normativeStatus'] || '';
|
|
285
|
+
const type = d['@type'] || '';
|
|
286
|
+
let gramGrp = '';
|
|
287
|
+
if (d['gl:gender']) gramGrp = `\n <grammaticalGender>${escapeXml(d['gl:gender'])}</grammaticalGender>`;
|
|
288
|
+
let partOfSpeech = '';
|
|
289
|
+
if (type.includes('Abbreviation')) partOfSpeech = '\n <partOfSpeech>abbreviation</partOfSpeech>';
|
|
290
|
+
if (type.includes('Symbol')) partOfSpeech = '\n <partOfSpeech>symbol</partOfSpeech>';
|
|
291
|
+
|
|
292
|
+
termEntries.push(` <termEntry>
|
|
293
|
+
<langSet xml:lang="${lang}">
|
|
294
|
+
<tig>
|
|
295
|
+
<term>${escapeXml(term)}</term>${gramGrp}${partOfSpeech}
|
|
296
|
+
</tig>
|
|
297
|
+
</langSet>
|
|
298
|
+
</termEntry>`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
let defBlock = '';
|
|
302
|
+
if (definitions.length) {
|
|
303
|
+
const defParts = definitions.map(d => ` <p>${escapeXml(d['gl:content'])}</p>`).join('\n');
|
|
304
|
+
defBlock = `\n <descrip type="definition">\n${defParts}\n </descrip>`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
let noteBlock = '';
|
|
308
|
+
for (let i = 0; i < notes.length; i++) {
|
|
309
|
+
noteBlock += `\n <note type="note">${escapeXml(notes[i]['gl:content'])}</note>`;
|
|
310
|
+
}
|
|
311
|
+
for (let i = 0; i < examples.length; i++) {
|
|
312
|
+
noteBlock += `\n <note type="example">${escapeXml(examples[i]['gl:content'])}</note>`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
let sourceBlock = '';
|
|
316
|
+
for (const src of sources) {
|
|
317
|
+
const origin = src['gl:origin'] || {};
|
|
318
|
+
const parts = [];
|
|
319
|
+
if (origin['gl:ref']) parts.push(origin['gl:ref']);
|
|
320
|
+
if (origin['gl:clause']) parts.push(origin['gl:clause']);
|
|
321
|
+
if (parts.length) {
|
|
322
|
+
sourceBlock += `\n <ref>${escapeXml(parts.join(', '))}</ref>`;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
let statusBlock = '';
|
|
327
|
+
if (entryStatus) {
|
|
328
|
+
statusBlock += `\n <descrip type="entryStatus">${escapeXml(entryStatus)}</descrip>`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const termEntriesBlock = termEntries.length ? '\n' + termEntries.join('\n') : '';
|
|
332
|
+
langSections.push({ lang, termEntries, blocks: [defBlock, noteBlock, sourceBlock, statusBlock].filter(b => b).join('') });
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (!langSections.length) return '';
|
|
336
|
+
|
|
337
|
+
const bodyEntries = langSections.map(ls => {
|
|
338
|
+
return ` <languageSection xml:lang="${ls.lang}">${ls.blocks}\n </languageSection>`;
|
|
339
|
+
}).join('\n');
|
|
340
|
+
|
|
341
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
342
|
+
<tbx style="dca" type="TBX-Basic" xml:lang="en" xmlns="urn:iso:std:iso:30042:ed-2">
|
|
343
|
+
<tbxHeader>
|
|
344
|
+
<fileDesc>
|
|
345
|
+
<source>${escapeXml(uri)}</source>
|
|
346
|
+
</fileDesc>
|
|
347
|
+
</tbxHeader>
|
|
348
|
+
<text>
|
|
349
|
+
<body>
|
|
350
|
+
<conceptEntry id="${escapeXml(id)}">
|
|
351
|
+
${bodyEntries}
|
|
352
|
+
</conceptEntry>
|
|
353
|
+
</body>
|
|
354
|
+
</text>
|
|
355
|
+
</tbx>
|
|
356
|
+
`;
|
|
357
|
+
}
|
|
358
|
+
|
|
260
359
|
function processDataset(dir, register, opts) {
|
|
261
360
|
const files = fs.readdirSync(dir).filter(f => f.endsWith('.yaml')).sort((a, b) => naturalSort(a.replace('.yaml', ''), b.replace('.yaml', '')));
|
|
262
361
|
|
|
@@ -266,7 +365,7 @@ function processDataset(dir, register, opts) {
|
|
|
266
365
|
const concepts = [];
|
|
267
366
|
const langTermCounts = {};
|
|
268
367
|
const langDefCounts = {};
|
|
269
|
-
const availableFormats = ['ttl', 'jsonld', 'yaml'];
|
|
368
|
+
const availableFormats = ['ttl', 'jsonld', 'yaml', 'tbx'];
|
|
270
369
|
|
|
271
370
|
for (let i = 0; i < files.length; i++) {
|
|
272
371
|
const file = files[i];
|
|
@@ -286,6 +385,12 @@ function processDataset(dir, register, opts) {
|
|
|
286
385
|
const skosJsonLd = conceptJsonToSkosJsonLd(jsonld);
|
|
287
386
|
fs.writeFileSync(path.join(conceptsDir, `${termid}.jsonld`), skosJsonLd);
|
|
288
387
|
|
|
388
|
+
// Generate TBX-XML format
|
|
389
|
+
const tbxContent = conceptJsonToTbx(jsonld);
|
|
390
|
+
if (tbxContent) {
|
|
391
|
+
fs.writeFileSync(path.join(conceptsDir, `${termid}.tbx`), tbxContent);
|
|
392
|
+
}
|
|
393
|
+
|
|
289
394
|
// Copy source YAML
|
|
290
395
|
fs.copyFileSync(path.join(dir, file), path.join(conceptsDir, `${termid}.yaml`));
|
|
291
396
|
|
|
@@ -373,6 +478,30 @@ function processDataset(dir, register, opts) {
|
|
|
373
478
|
};
|
|
374
479
|
}
|
|
375
480
|
|
|
481
|
+
// Copy bulk format files from compiled/ directory (full GCR)
|
|
482
|
+
const compiledDir = path.join(ROOT, '.datasets', register, 'compiled');
|
|
483
|
+
const bulkFormats = [];
|
|
484
|
+
if (fs.existsSync(compiledDir)) {
|
|
485
|
+
for (const file of fs.readdirSync(compiledDir)) {
|
|
486
|
+
const src = path.join(compiledDir, file);
|
|
487
|
+
const dest = path.join(DATA, register, file);
|
|
488
|
+
fs.copyFileSync(src, dest);
|
|
489
|
+
const ext = path.extname(file);
|
|
490
|
+
const formatMap = {
|
|
491
|
+
'.ttl': 'turtle',
|
|
492
|
+
'.jsonld': 'jsonld',
|
|
493
|
+
'.xml': 'tbx',
|
|
494
|
+
'.jsonl': 'jsonl',
|
|
495
|
+
'.yaml': 'yaml',
|
|
496
|
+
};
|
|
497
|
+
const formatName = formatMap[ext] || ext.slice(1);
|
|
498
|
+
bulkFormats.push({ file, format: formatName, size: fs.statSync(src).size });
|
|
499
|
+
}
|
|
500
|
+
if (bulkFormats.length) {
|
|
501
|
+
console.log(` Copied ${bulkFormats.length} bulk format files`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
376
505
|
const manifest = {
|
|
377
506
|
id: register,
|
|
378
507
|
datasetUri: opts.datasetUri,
|
|
@@ -396,6 +525,7 @@ function processDataset(dir, register, opts) {
|
|
|
396
525
|
color: opts.color,
|
|
397
526
|
languageStats: langStats,
|
|
398
527
|
availableFormats,
|
|
528
|
+
bulkFormats,
|
|
399
529
|
};
|
|
400
530
|
if (opts.languageOrder) manifest.languageOrder = opts.languageOrder;
|
|
401
531
|
writeJson(path.join(DATA, register, 'manifest.json'), manifest);
|
package/src/App.vue
CHANGED
|
@@ -6,6 +6,7 @@ import { buildPageRoutes } from './router/page-routes';
|
|
|
6
6
|
import router from './router';
|
|
7
7
|
import AppHeader from './components/AppHeader.vue';
|
|
8
8
|
import AppSidebar from './components/AppSidebar.vue';
|
|
9
|
+
import AppFooter from './components/AppFooter.vue';
|
|
9
10
|
|
|
10
11
|
const store = useVocabularyStore();
|
|
11
12
|
const { loadConfig, config, globalPages, datasetPages } = useSiteConfig();
|
|
@@ -48,7 +49,7 @@ onUnmounted(() => {
|
|
|
48
49
|
<AppHeader />
|
|
49
50
|
<div class="flex flex-1 overflow-hidden">
|
|
50
51
|
<AppSidebar />
|
|
51
|
-
<main class="flex-1 overflow-y-auto bg-surface">
|
|
52
|
+
<main class="flex-1 overflow-y-auto bg-surface flex flex-col">
|
|
52
53
|
<div v-if="!appReady" class="flex items-center justify-center h-[70vh]">
|
|
53
54
|
<div class="w-full max-w-md px-6 space-y-6">
|
|
54
55
|
<!-- Title skeleton -->
|
|
@@ -85,6 +86,7 @@ onUnmounted(() => {
|
|
|
85
86
|
</transition>
|
|
86
87
|
</router-view>
|
|
87
88
|
</template>
|
|
89
|
+
<AppFooter />
|
|
88
90
|
</main>
|
|
89
91
|
</div>
|
|
90
92
|
<!-- Scroll-to-top -->
|
|
@@ -214,13 +214,21 @@ export class DatasetAdapter {
|
|
|
214
214
|
for (const entry of arr) {
|
|
215
215
|
if (!entry) continue;
|
|
216
216
|
const term = entry.eng || '';
|
|
217
|
-
|
|
217
|
+
const termMatch = term.toLowerCase().includes(q);
|
|
218
|
+
const idMatch = entry.id.toLowerCase().includes(q);
|
|
219
|
+
if (termMatch || idMatch) {
|
|
220
|
+
const matchField = termMatch ? 'designation' as const : 'id' as const;
|
|
221
|
+
let snippet: string | undefined;
|
|
222
|
+
if (!termMatch && idMatch) {
|
|
223
|
+
snippet = `ID: ${entry.id}`;
|
|
224
|
+
}
|
|
218
225
|
hits.push({
|
|
219
226
|
conceptId: entry.id,
|
|
220
227
|
registerId: this.registerId,
|
|
221
228
|
designation: term,
|
|
222
229
|
language: lang,
|
|
223
|
-
matchField
|
|
230
|
+
matchField,
|
|
231
|
+
snippet,
|
|
224
232
|
});
|
|
225
233
|
}
|
|
226
234
|
}
|
package/src/adapters/types.ts
CHANGED
|
@@ -25,6 +25,7 @@ export interface Manifest {
|
|
|
25
25
|
languageOrder?: string[];
|
|
26
26
|
languageStats?: Record<string, { terms: number; definitions: number }>;
|
|
27
27
|
availableFormats?: string[];
|
|
28
|
+
bulkFormats?: { file: string; format: string; size: number }[];
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
export interface ConceptIndex {
|
|
@@ -142,7 +143,7 @@ export interface SearchHit {
|
|
|
142
143
|
registerId: string;
|
|
143
144
|
designation: string;
|
|
144
145
|
language: string;
|
|
145
|
-
matchField: 'designation' | '
|
|
146
|
+
matchField: 'designation' | 'id';
|
|
146
147
|
snippet?: string;
|
|
147
148
|
}
|
|
148
149
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue';
|
|
3
|
+
import { useSiteConfig } from '../config/use-site-config';
|
|
4
|
+
|
|
5
|
+
const { config } = useSiteConfig();
|
|
6
|
+
|
|
7
|
+
const poweredBy = computed(() => {
|
|
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' };
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const socialLinks = computed(() => {
|
|
13
|
+
const s = config.value?.social;
|
|
14
|
+
if (!s) return [];
|
|
15
|
+
const links: { key: string; label: string; url: string }[] = [];
|
|
16
|
+
if (s.github) links.push({ key: 'github', label: 'GitHub', url: s.github });
|
|
17
|
+
if (s.twitter) links.push({ key: 'twitter', label: 'Twitter', url: s.twitter });
|
|
18
|
+
return links;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const footerNav = computed(() => config.value?.footerNav ?? []);
|
|
22
|
+
const ownerName = computed(() => config.value?.branding?.ownerName || config.value?.title || '');
|
|
23
|
+
const ownerUrl = computed(() => config.value?.branding?.ownerUrl || '/');
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
<template>
|
|
27
|
+
<footer class="border-t border-ink-100/60 bg-surface-raised mt-auto">
|
|
28
|
+
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
|
29
|
+
<div class="flex flex-col sm:flex-row items-center justify-between gap-4 text-sm text-ink-400">
|
|
30
|
+
<div class="flex items-center gap-3">
|
|
31
|
+
<span v-if="ownerName">
|
|
32
|
+
© {{ new Date().getFullYear() }}
|
|
33
|
+
<a v-if="ownerUrl" :href="ownerUrl" target="_blank" rel="noopener" class="concept-link">{{ ownerName }}</a>
|
|
34
|
+
<span v-else>{{ ownerName }}</span>
|
|
35
|
+
</span>
|
|
36
|
+
</div>
|
|
37
|
+
<div class="flex items-center gap-4">
|
|
38
|
+
<router-link
|
|
39
|
+
v-for="item in footerNav"
|
|
40
|
+
:key="item.route"
|
|
41
|
+
:to="item.route ? `/${item.route}` : '/'"
|
|
42
|
+
class="hover:text-ink-700 transition-colors"
|
|
43
|
+
>{{ item.label }}</router-link>
|
|
44
|
+
<a
|
|
45
|
+
v-for="link in socialLinks"
|
|
46
|
+
:key="link.key"
|
|
47
|
+
:href="link.url"
|
|
48
|
+
target="_blank"
|
|
49
|
+
rel="noopener"
|
|
50
|
+
class="hover:text-ink-700 transition-colors"
|
|
51
|
+
>{{ link.label }}</a>
|
|
52
|
+
<span class="text-ink-200">|</span>
|
|
53
|
+
<span class="text-xs">
|
|
54
|
+
Powered by <a :href="poweredBy.url" target="_blank" rel="noopener" class="concept-link">{{ poweredBy.title }}</a>
|
|
55
|
+
</span>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
</footer>
|
|
60
|
+
</template>
|
|
@@ -12,7 +12,7 @@ const ui = useUiStore();
|
|
|
12
12
|
const router = useRouter();
|
|
13
13
|
const route = useRoute();
|
|
14
14
|
const { getColor } = useDsStyle();
|
|
15
|
-
const { globalPages, datasetPages } = useSiteConfig();
|
|
15
|
+
const { globalPages, datasetPages, config: siteConfig } = useSiteConfig();
|
|
16
16
|
|
|
17
17
|
const currentDataset = computed(() => (route.params as any).registerId ?? '');
|
|
18
18
|
|
|
@@ -121,11 +121,16 @@ function isActive(page: { route: string; datasetScoped?: boolean }): boolean {
|
|
|
121
121
|
</button>
|
|
122
122
|
</nav>
|
|
123
123
|
|
|
124
|
-
<!--
|
|
124
|
+
<!-- Powered by -->
|
|
125
125
|
<div class="mt-6 pt-4 border-t border-ink-100/60">
|
|
126
|
-
<div class="text-[11px] text-ink-300
|
|
127
|
-
|
|
128
|
-
<
|
|
126
|
+
<div class="text-[11px] text-ink-300">
|
|
127
|
+
Powered by
|
|
128
|
+
<a
|
|
129
|
+
:href="(siteConfig?.features?.poweredBy as any)?.url || 'https://glossarist.org'"
|
|
130
|
+
target="_blank"
|
|
131
|
+
rel="noopener"
|
|
132
|
+
class="concept-link"
|
|
133
|
+
>{{ (siteConfig?.features?.poweredBy as any)?.title || 'Glossarist' }}</a>
|
|
129
134
|
</div>
|
|
130
135
|
</div>
|
|
131
136
|
</div>
|
|
@@ -35,6 +35,7 @@ const manifestLanguages = computed(() => store.manifests.get(props.registerId)?.
|
|
|
35
35
|
<button
|
|
36
36
|
@click="viewConcept"
|
|
37
37
|
class="card-hover p-4 text-left w-full border-l-2 group"
|
|
38
|
+
:class="(entry.status === 'superseded' || entry.status === 'withdrawn') ? 'opacity-70' : ''"
|
|
38
39
|
:style="{ borderLeftColor: getColor(registerId) }"
|
|
39
40
|
>
|
|
40
41
|
<div class="flex items-start justify-between gap-2">
|
|
@@ -51,15 +52,18 @@ const manifestLanguages = computed(() => store.manifests.get(props.registerId)?.
|
|
|
51
52
|
{{ entry.status === 'Standard' ? 'valid' : entry.status }}
|
|
52
53
|
</span>
|
|
53
54
|
</div>
|
|
54
|
-
<!-- Language coverage
|
|
55
|
-
<div class="flex gap-
|
|
56
|
-
<span
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
55
|
+
<!-- Language coverage -->
|
|
56
|
+
<div class="flex items-center gap-1.5 mt-2.5" :aria-label="`${manifestLanguages.length} languages`">
|
|
57
|
+
<span class="text-[11px] text-ink-300">{{ manifestLanguages.length }} lang</span>
|
|
58
|
+
<div class="flex gap-0.5">
|
|
59
|
+
<span
|
|
60
|
+
v-for="lang in manifestLanguages"
|
|
61
|
+
:key="lang"
|
|
62
|
+
class="w-1.5 h-1.5 rounded-full"
|
|
63
|
+
:style="{ backgroundColor: getColor(registerId) + '40' }"
|
|
64
|
+
:aria-label="lang"
|
|
65
|
+
></span>
|
|
66
|
+
</div>
|
|
63
67
|
</div>
|
|
64
68
|
</button>
|
|
65
69
|
</template>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { ConceptDocument, LocalizedConcept, GraphEdge } from '../adapters/types';
|
|
3
3
|
import type { Manifest } from '../adapters/types';
|
|
4
|
-
import { computed, ref, nextTick } from 'vue';
|
|
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
7
|
import type { XrefResolver } from '../utils/math';
|
|
@@ -65,6 +65,9 @@ const languages = computed(() => {
|
|
|
65
65
|
});
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
+
// Initialize collapsed state when languages change
|
|
69
|
+
watch(languages, (langs) => { initCollapsed(langs); }, { immediate: true });
|
|
70
|
+
|
|
68
71
|
const engConcept = computed((): LocalizedConcept | null => {
|
|
69
72
|
return props.concept['gl:localizedConcept']?.['eng'] ?? null;
|
|
70
73
|
});
|
|
@@ -146,9 +149,15 @@ const allLangContent = computed(() => {
|
|
|
146
149
|
return result;
|
|
147
150
|
});
|
|
148
151
|
|
|
149
|
-
// Collapsible language sections —
|
|
152
|
+
// Collapsible language sections — auto-collapse non-eng when 6+ languages
|
|
150
153
|
const collapsedLangs = ref(new Set<string>());
|
|
151
154
|
|
|
155
|
+
function initCollapsed(langs: string[]) {
|
|
156
|
+
if (langs.length >= 6) {
|
|
157
|
+
collapsedLangs.value = new Set(langs.filter(l => l !== 'eng'));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
152
161
|
function hasContent(lc: LangContent): boolean {
|
|
153
162
|
return !!(lc.definition || lc.notes.length || lc.examples.length || lc.sources.length);
|
|
154
163
|
}
|
|
@@ -186,6 +195,17 @@ function scrollToLang(lang: string) {
|
|
|
186
195
|
const outgoingEdges = computed(() => props.edges.filter(e => e.source === props.concept['@id']));
|
|
187
196
|
const incomingEdges = computed(() => props.edges.filter(e => e.target === props.concept['@id']));
|
|
188
197
|
|
|
198
|
+
function edgeDatasetBadge(uri: string): { id: string; title: string } | null {
|
|
199
|
+
const resolution = factory.resolve(uri, props.registerId);
|
|
200
|
+
if (resolution.type === 'internal' && resolution.registerId !== props.registerId) {
|
|
201
|
+
const m = store.manifests.get(resolution.registerId);
|
|
202
|
+
return { id: resolution.registerId, title: m?.shortname || m?.title || resolution.registerId };
|
|
203
|
+
}
|
|
204
|
+
if (resolution.type === 'site') return { id: '', title: resolution.label };
|
|
205
|
+
if (resolution.type === 'url') return { id: '', title: resolution.label };
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
189
209
|
async function navigateEdge(edge: GraphEdge) {
|
|
190
210
|
const uri = edge.source === props.concept['@id'] ? edge.target : edge.source;
|
|
191
211
|
const resolution = factory.resolve(uri);
|
|
@@ -299,7 +319,7 @@ function plainTruncate(html: string, max: number = 120): string {
|
|
|
299
319
|
</button>
|
|
300
320
|
</div>
|
|
301
321
|
</div>
|
|
302
|
-
<h1 class="font-serif text-2xl sm:text-3xl text-ink-800 leading-snug mb-3" v-html="renderMath(
|
|
322
|
+
<h1 class="font-serif text-2xl sm:text-3xl text-ink-800 leading-snug mb-3" v-html="renderMath(primaryTerm)"></h1>
|
|
303
323
|
<div class="flex flex-wrap gap-2">
|
|
304
324
|
<span class="badge badge-blue font-mono">{{ conceptId }}</span>
|
|
305
325
|
<span class="badge" :class="entryStatusColor(engConcept?.['gl:entryStatus'] ?? '')" v-if="engConcept?.['gl:entryStatus']">
|
|
@@ -369,12 +389,17 @@ function plainTruncate(html: string, max: number = 120): string {
|
|
|
369
389
|
<div v-else class="w-full flex items-center gap-2.5 px-3 sm:px-4 py-3">
|
|
370
390
|
<span class="text-xs font-semibold text-ink-500 bg-ink-50 px-1.5 py-0.5 rounded">{{ langName(lc.lang) }}</span>
|
|
371
391
|
<span class="font-medium text-ink-800 text-sm" v-html="renderMath(getTermForLang(lc.lang))"></span>
|
|
372
|
-
<span class="text-xs text-ink-200 ml-2">
|
|
392
|
+
<span class="text-xs text-ink-200 ml-2 italic">designation only</span>
|
|
373
393
|
<span v-if="lc.entryStatus" class="badge text-[10px] ml-auto" :class="entryStatusColor(lc.entryStatus)">{{ lc.entryStatus }}</span>
|
|
374
394
|
</div>
|
|
375
395
|
<!-- Collapsed preview -->
|
|
376
|
-
<div v-if="hasContent(lc) && collapsedLangs.has(lc.lang)
|
|
377
|
-
<p class="text-xs text-ink-300 leading-relaxed pl-[22px]">{{ plainTruncate(lc.definition) }}</p>
|
|
396
|
+
<div v-if="hasContent(lc) && collapsedLangs.has(lc.lang)" class="px-3 sm:px-4 pb-3 -mt-0.5">
|
|
397
|
+
<p v-if="lc.definition" class="text-xs text-ink-300 leading-relaxed pl-[22px]">{{ plainTruncate(lc.definition) }}</p>
|
|
398
|
+
<p v-else class="text-xs text-ink-200 leading-relaxed pl-[22px]">
|
|
399
|
+
<template v-if="lc.notes.length">{{ lc.notes.length }} note{{ lc.notes.length > 1 ? 's' : '' }}</template>
|
|
400
|
+
<template v-if="lc.notes.length && lc.examples.length"> · </template>
|
|
401
|
+
<template v-if="lc.examples.length">{{ lc.examples.length }} example{{ lc.examples.length > 1 ? 's' : '' }}</template>
|
|
402
|
+
</p>
|
|
378
403
|
</div>
|
|
379
404
|
|
|
380
405
|
<!-- Expandable content -->
|
|
@@ -440,9 +465,10 @@ function plainTruncate(html: string, max: number = 120): string {
|
|
|
440
465
|
v-for="edge in outgoingEdges"
|
|
441
466
|
:key="edge.target"
|
|
442
467
|
@click="navigateEdge(edge)"
|
|
443
|
-
class="text-sm concept-link block truncate w-full text-left"
|
|
468
|
+
class="text-sm concept-link block truncate w-full text-left flex items-center gap-1.5"
|
|
444
469
|
>
|
|
445
470
|
{{ edge.label || edge.target.split('/').pop() }}
|
|
471
|
+
<span v-if="edgeDatasetBadge(edge.target)" class="badge badge-gray text-[9px] flex-shrink-0 truncate max-w-[100px]">{{ edgeDatasetBadge(edge.target)!.title }}</span>
|
|
446
472
|
</button>
|
|
447
473
|
</div>
|
|
448
474
|
</div>
|
|
@@ -453,9 +479,10 @@ function plainTruncate(html: string, max: number = 120): string {
|
|
|
453
479
|
v-for="edge in incomingEdges"
|
|
454
480
|
:key="edge.source"
|
|
455
481
|
@click="navigateEdge(edge)"
|
|
456
|
-
class="text-sm concept-link block truncate w-full text-left"
|
|
482
|
+
class="text-sm concept-link block truncate w-full text-left flex items-center gap-1.5"
|
|
457
483
|
>
|
|
458
484
|
{{ edge.label || edge.source.split('/').pop() }}
|
|
485
|
+
<span v-if="edgeDatasetBadge(edge.source)" class="badge badge-gray text-[9px] flex-shrink-0 truncate max-w-[100px]">{{ edgeDatasetBadge(edge.source)!.title }}</span>
|
|
459
486
|
</button>
|
|
460
487
|
</div>
|
|
461
488
|
</div>
|
|
@@ -224,8 +224,10 @@ onMounted(() => {
|
|
|
224
224
|
<div class="min-w-0">
|
|
225
225
|
<span class="font-medium text-ink-800 group-hover:text-ink-900 transition-colors">{{ hit.designation }}</span>
|
|
226
226
|
<span class="text-xs text-ink-300 ml-2 font-mono">{{ hit.conceptId }}</span>
|
|
227
|
+
<span v-if="hit.snippet" class="block text-xs text-ink-300 mt-0.5 truncate">{{ hit.snippet }}</span>
|
|
227
228
|
</div>
|
|
228
229
|
<div class="flex items-center gap-2 flex-shrink-0">
|
|
230
|
+
<span v-if="hit.matchField === 'id'" class="badge badge-gray text-[10px]">ID match</span>
|
|
229
231
|
<span class="text-xs font-semibold text-ink-500 bg-ink-50 px-1.5 py-0.5 rounded">{{ langName(hit.language) }}</span>
|
|
230
232
|
</div>
|
|
231
233
|
</button>
|
package/src/config/types.ts
CHANGED
|
@@ -90,8 +90,25 @@ export interface DatasetConfig {
|
|
|
90
90
|
color?: string;
|
|
91
91
|
tags?: string[];
|
|
92
92
|
languageOrder?: string[];
|
|
93
|
+
downloads?: string[];
|
|
93
94
|
}
|
|
94
95
|
|
|
96
|
+
// === Downloads ===
|
|
97
|
+
|
|
98
|
+
export interface BulkFormatInfo {
|
|
99
|
+
file: string;
|
|
100
|
+
format: string;
|
|
101
|
+
size: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const FORMAT_LABELS: Record<string, string> = {
|
|
105
|
+
turtle: 'Turtle (RDF)',
|
|
106
|
+
jsonld: 'JSON-LD (SKOS)',
|
|
107
|
+
tbx: 'TBX-XML',
|
|
108
|
+
jsonl: 'JSONL',
|
|
109
|
+
yaml: 'YAML',
|
|
110
|
+
};
|
|
111
|
+
|
|
95
112
|
// === Pages ===
|
|
96
113
|
|
|
97
114
|
export type PageType = 'news' | 'contributors' | 'about' | 'stats' | 'custom';
|
package/src/style.css
CHANGED
|
@@ -9,6 +9,7 @@ export interface FormatDescriptor {
|
|
|
9
9
|
export const FORMAT_REGISTRY: Record<string, FormatDescriptor> = {
|
|
10
10
|
ttl: { extension: 'ttl', label: 'Turtle RDF', mediaType: 'text/turtle' },
|
|
11
11
|
jsonld: { extension: 'jsonld', label: 'JSON-LD', mediaType: 'application/ld+json' },
|
|
12
|
+
tbx: { extension: 'tbx', label: 'TBX-XML', mediaType: 'application/xml' },
|
|
12
13
|
yaml: { extension: 'yaml', label: 'YAML', mediaType: 'text/yaml' },
|
|
13
14
|
};
|
|
14
15
|
|
package/src/utils/math.ts
CHANGED
|
@@ -4,9 +4,11 @@ export type XrefResolver = (uri: string, term: string) => string;
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Convert `* item` lines into <ul><li> blocks.
|
|
7
|
+
* Also handles `1)` and `1.` numbered items into ordered lists.
|
|
7
8
|
*/
|
|
8
9
|
function convertLists(text: string): string {
|
|
9
|
-
|
|
10
|
+
// Unordered: * item (separated by \n or \n\n)
|
|
11
|
+
let result = text.replace(/(?:^|\n)((?:[ \t]*\* [^\n]+)(?:\n[ \t]*\* [^\n]+)*)/g, (_, block) => {
|
|
10
12
|
if (/^\*stem:\[/.test(block.trimStart())) return _;
|
|
11
13
|
const items: string[] = [];
|
|
12
14
|
const re = /[ \t]*\* ([^\n]+)/g;
|
|
@@ -16,8 +18,23 @@ function convertLists(text: string): string {
|
|
|
16
18
|
}
|
|
17
19
|
if (!items.length) return _;
|
|
18
20
|
const lis = items.map(item => `<li>${item}</li>`).join('');
|
|
19
|
-
return
|
|
21
|
+
return `\n<ul class="concept-list">${lis}</ul>`;
|
|
20
22
|
});
|
|
23
|
+
|
|
24
|
+
// Ordered: 1) item or 1. item (numbered items)
|
|
25
|
+
result = result.replace(/(?:^|\n)((?:[ \t]*\d+[).][ \t]+[^\n]+)(?:\n[ \t]*\d+[).][ \t]+[^\n]+)*)/g, (_, block) => {
|
|
26
|
+
const items: string[] = [];
|
|
27
|
+
const re = /[ \t]*\d+[).][ \t]+([^\n]+)/g;
|
|
28
|
+
let m;
|
|
29
|
+
while ((m = re.exec(block)) !== null) {
|
|
30
|
+
items.push(m[1].trim());
|
|
31
|
+
}
|
|
32
|
+
if (!items.length) return _;
|
|
33
|
+
const lis = items.map(item => `<li>${item}</li>`).join('');
|
|
34
|
+
return `\n<ol class="concept-list concept-list-ordered">${lis}</ol>`;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return result;
|
|
21
38
|
}
|
|
22
39
|
|
|
23
40
|
/**
|
|
@@ -37,6 +37,11 @@ onMounted(async () => {
|
|
|
37
37
|
|
|
38
38
|
<template>
|
|
39
39
|
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
40
|
+
<nav aria-label="Breadcrumb" class="flex items-center gap-1.5 text-sm text-ink-400 mb-6">
|
|
41
|
+
<router-link :to="{ name: 'home' }" class="hover:text-ink-700 transition-colors">Home</router-link>
|
|
42
|
+
<span class="text-ink-200">/</span>
|
|
43
|
+
<span class="text-ink-700">Contributors</span>
|
|
44
|
+
</nav>
|
|
40
45
|
<h1 class="font-serif text-3xl text-ink-800 mb-2">Contributors</h1>
|
|
41
46
|
<p class="text-ink-400 mb-8">
|
|
42
47
|
Organizations and individuals contributing to {{ config?.branding?.ownerName || config?.title || 'this glossary' }}.
|
|
@@ -3,6 +3,7 @@ import { computed, ref, watch, onMounted, onUnmounted } from 'vue';
|
|
|
3
3
|
import { useVocabularyStore } from '../stores/vocabulary';
|
|
4
4
|
import { useDsStyle } from '../utils/dataset-style';
|
|
5
5
|
import { useDatasetLoader } from '../composables/use-dataset-loader';
|
|
6
|
+
import { FORMAT_LABELS } from '../config/types';
|
|
6
7
|
import ConceptCard from '../components/ConceptCard.vue';
|
|
7
8
|
|
|
8
9
|
const props = defineProps<{ registerId: string }>();
|
|
@@ -15,6 +16,23 @@ const manifest = computed(() => store.manifests.get(props.registerId));
|
|
|
15
16
|
const adapter = computed(() => store.datasets.get(props.registerId));
|
|
16
17
|
const chunkLoading = ref(false);
|
|
17
18
|
|
|
19
|
+
function formatSize(bytes: number): string {
|
|
20
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
21
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
22
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const bulkDownloads = computed(() => {
|
|
26
|
+
const m = manifest.value;
|
|
27
|
+
if (!m?.bulkFormats?.length) return [];
|
|
28
|
+
return m.bulkFormats.map(f => ({
|
|
29
|
+
...f,
|
|
30
|
+
url: `${m.baseUrl}/${f.file}`,
|
|
31
|
+
label: FORMAT_LABELS[f.format] || f.format.toUpperCase(),
|
|
32
|
+
sizeLabel: formatSize(f.size),
|
|
33
|
+
}));
|
|
34
|
+
});
|
|
35
|
+
|
|
18
36
|
const totalConceptCount = computed(() => adapter.value?.getConceptCount() ?? 0);
|
|
19
37
|
|
|
20
38
|
const filter = ref('');
|
|
@@ -145,6 +163,26 @@ function goToPage(p: number) {
|
|
|
145
163
|
</div>
|
|
146
164
|
</div>
|
|
147
165
|
|
|
166
|
+
<!-- Downloads -->
|
|
167
|
+
<div v-if="bulkDownloads.length" class="card p-4 mb-6">
|
|
168
|
+
<h3 class="text-xs font-semibold text-ink-400 uppercase tracking-wide mb-3">Download</h3>
|
|
169
|
+
<div class="flex flex-wrap gap-2">
|
|
170
|
+
<a
|
|
171
|
+
v-for="dl in bulkDownloads"
|
|
172
|
+
:key="dl.file"
|
|
173
|
+
:href="dl.url"
|
|
174
|
+
download
|
|
175
|
+
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-ink-100 bg-surface-raised text-sm font-medium text-ink-700 hover:bg-ink-50 hover:border-ink-200 transition-colors"
|
|
176
|
+
>
|
|
177
|
+
<svg class="w-3.5 h-3.5 text-ink-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
178
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
|
179
|
+
</svg>
|
|
180
|
+
{{ dl.label }}
|
|
181
|
+
<span class="text-ink-300 text-xs">{{ dl.sizeLabel }}</span>
|
|
182
|
+
</a>
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
|
|
148
186
|
<!-- Loading state (initial dataset load) -->
|
|
149
187
|
<div v-if="loading || (!adapter?.index && !localError)" class="space-y-4 py-4">
|
|
150
188
|
<div class="space-y-2">
|
|
@@ -186,10 +224,15 @@ function goToPage(p: number) {
|
|
|
186
224
|
</svg>
|
|
187
225
|
</div>
|
|
188
226
|
<span class="text-sm text-ink-400">
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
227
|
+
<template v-if="filter.trim()">
|
|
228
|
+
{{ filtered.length.toLocaleString() }} of {{ totalConceptCount.toLocaleString() }} concepts
|
|
229
|
+
</template>
|
|
230
|
+
<template v-else-if="totalPages > 1">
|
|
231
|
+
{{ ((page - 1) * perPage + 1).toLocaleString() }}–{{ Math.min(page * perPage, totalConceptCount).toLocaleString() }} of {{ totalConceptCount.toLocaleString() }} concepts
|
|
232
|
+
</template>
|
|
233
|
+
<template v-else>
|
|
234
|
+
{{ totalConceptCount.toLocaleString() }} concepts
|
|
235
|
+
</template>
|
|
193
236
|
</span>
|
|
194
237
|
</div>
|
|
195
238
|
|
package/src/views/GraphView.vue
CHANGED
|
@@ -43,10 +43,10 @@ onMounted(async () => {
|
|
|
43
43
|
<template>
|
|
44
44
|
<div class="flex flex-col" style="height: calc(100vh - 56px)">
|
|
45
45
|
<div class="px-4 sm:px-6 py-3 border-b border-ink-100/60 bg-surface-raised flex items-center gap-3 flex-shrink-0">
|
|
46
|
-
<nav aria-label="Breadcrumb" class="flex items-center gap-1.5 text-sm">
|
|
47
|
-
<router-link :to="{ name: 'home' }" class="
|
|
46
|
+
<nav aria-label="Breadcrumb" class="flex items-center gap-1.5 text-sm text-ink-400">
|
|
47
|
+
<router-link :to="{ name: 'home' }" class="hover:text-ink-700 transition-colors">Home</router-link>
|
|
48
48
|
<span class="text-ink-200">/</span>
|
|
49
|
-
<span class="text-ink-700
|
|
49
|
+
<span class="text-ink-700">Graph View</span>
|
|
50
50
|
</nav>
|
|
51
51
|
<span class="text-xs text-ink-300 ml-1">
|
|
52
52
|
{{ graphEdges.length.toLocaleString() }} edges
|
package/src/views/HomeView.vue
CHANGED
|
@@ -80,8 +80,7 @@ function goToGraph() { router.push({ name: 'graph' }); }
|
|
|
80
80
|
<template v-if="!siteConfig?.subtitle">Terminology<br class="hidden sm:block" /> Register</template>
|
|
81
81
|
</h1>
|
|
82
82
|
<p class="text-base text-ink-400 max-w-lg leading-relaxed">
|
|
83
|
-
Explore standardized terminology datasets from ISO and IEC technical committees.
|
|
84
|
-
Browse concepts, definitions, and cross-references across multilingual vocabularies.
|
|
83
|
+
{{ siteConfig?.description || 'Explore standardized terminology datasets from ISO and IEC technical committees. Browse concepts, definitions, and cross-references across multilingual vocabularies.' }}
|
|
85
84
|
</p>
|
|
86
85
|
<div class="flex flex-wrap gap-3 mt-7">
|
|
87
86
|
<button @click="goToSearch" class="btn-primary flex items-center gap-2">
|
|
@@ -121,7 +120,12 @@ function goToGraph() { router.push({ name: 'graph' }); }
|
|
|
121
120
|
<div class="section-label mb-0">Available Datasets</div>
|
|
122
121
|
<span class="text-xs text-ink-300">Click to browse</span>
|
|
123
122
|
</div>
|
|
124
|
-
<div class="
|
|
123
|
+
<div :class="[
|
|
124
|
+
filteredDatasets.length === 1 ? 'max-w-md' : '',
|
|
125
|
+
filteredDatasets.length === 2 ? 'max-w-3xl' : '',
|
|
126
|
+
'grid gap-4',
|
|
127
|
+
filteredDatasets.length === 1 ? 'grid-cols-1' : 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3'
|
|
128
|
+
]">
|
|
125
129
|
<button
|
|
126
130
|
v-for="(ds, idx) in filteredDatasets"
|
|
127
131
|
:key="ds.id"
|
package/src/views/NewsView.vue
CHANGED
|
@@ -75,6 +75,11 @@ function formatDate(dateStr: string) {
|
|
|
75
75
|
|
|
76
76
|
<template>
|
|
77
77
|
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
78
|
+
<nav aria-label="Breadcrumb" class="flex items-center gap-1.5 text-sm text-ink-400 mb-6">
|
|
79
|
+
<router-link :to="{ name: 'home' }" class="hover:text-ink-700 transition-colors">Home</router-link>
|
|
80
|
+
<span class="text-ink-200">/</span>
|
|
81
|
+
<span class="text-ink-700">News</span>
|
|
82
|
+
</nav>
|
|
78
83
|
<h1 class="font-serif text-3xl text-ink-800 mb-2">News</h1>
|
|
79
84
|
<p class="text-ink-400 mb-8">
|
|
80
85
|
Updates from {{ config?.branding?.ownerName || config?.title || 'Glossarist' }}.
|
|
@@ -53,7 +53,7 @@ onMounted(async () => {
|
|
|
53
53
|
<p class="text-ink-500 mb-2">The following concept URI could not be resolved:</p>
|
|
54
54
|
<code class="text-sm text-ink-600 break-all bg-ink-50 px-3 py-2 rounded">{{ uri }}</code>
|
|
55
55
|
<div class="mt-8">
|
|
56
|
-
<router-link :to="{ name: 'home' }" class="
|
|
56
|
+
<router-link :to="{ name: 'home' }" class="concept-link">Return to home</router-link>
|
|
57
57
|
</div>
|
|
58
58
|
</template>
|
|
59
59
|
<template v-else>
|