@glossarist/concept-browser 0.3.4 → 0.3.7

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.
Files changed (43) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/about-view.test.ts +98 -0
  3. package/src/__tests__/app-footer.test.ts +38 -0
  4. package/src/__tests__/app-header.test.ts +130 -0
  5. package/src/__tests__/app-sidebar.test.ts +159 -0
  6. package/src/__tests__/asciidoc-lite.test.ts +1 -1
  7. package/src/__tests__/concept-card.test.ts +115 -0
  8. package/src/__tests__/concept-detail-interaction.test.ts +251 -0
  9. package/src/__tests__/concept-timeline.test.ts +175 -0
  10. package/src/__tests__/concept-view.test.ts +75 -0
  11. package/src/__tests__/contributors-view.test.ts +103 -0
  12. package/src/__tests__/dataset-view.test.ts +231 -0
  13. package/src/__tests__/format-downloads.test.ts +98 -0
  14. package/src/__tests__/graph-view.test.ts +69 -0
  15. package/src/__tests__/home-interaction.test.ts +157 -0
  16. package/src/__tests__/language-detail.test.ts +146 -0
  17. package/src/__tests__/nav-icon.test.ts +48 -0
  18. package/src/__tests__/news-view.test.ts +87 -0
  19. package/src/__tests__/page-view.test.ts +83 -0
  20. package/src/__tests__/resolve-view.test.ts +77 -0
  21. package/src/__tests__/router.test.ts +65 -0
  22. package/src/__tests__/search-bar.test.ts +219 -0
  23. package/src/__tests__/search-view.test.ts +41 -0
  24. package/src/__tests__/stats-view.test.ts +77 -0
  25. package/src/__tests__/test-helpers.ts +168 -0
  26. package/src/__tests__/ui-store.test.ts +100 -0
  27. package/src/__tests__/v-math.test.ts +8 -7
  28. package/src/adapters/DatasetAdapter.ts +17 -15
  29. package/src/adapters/types.ts +1 -1
  30. package/src/components/ConceptDetail.vue +16 -54
  31. package/src/components/ConceptTimeline.vue +1 -8
  32. package/src/components/LanguageDetail.vue +2 -25
  33. package/src/composables/use-render-options.ts +1 -4
  34. package/src/router/index.ts +1 -1
  35. package/src/stores/vocabulary.ts +7 -7
  36. package/src/utils/asciidoc-lite.ts +17 -19
  37. package/src/utils/concept-helpers.ts +34 -0
  38. package/src/utils/escape.ts +7 -0
  39. package/src/utils/markdown-lite.ts +1 -3
  40. package/src/utils/math.ts +2 -11
  41. package/src/utils/plurimath.ts +2 -7
  42. package/src/views/ConceptView.vue +22 -1
  43. package/src/views/DatasetView.vue +7 -2
@@ -44,13 +44,20 @@ export class DatasetAdapter {
44
44
  const resp = await fetch(`${this.baseUrl}/index.json`);
45
45
  if (!resp.ok) throw new Error(`Failed to load index for ${this.registerId}: ${resp.status}`);
46
46
  this.index = (await resp.json()) as ConceptIndex;
47
+ this.buildSummaryIndex();
48
+ return this.index;
49
+ }
50
+
51
+ private buildSummaryIndex() {
47
52
  this.summaryMap.clear();
48
53
  this.positionIndex.clear();
49
- for (let i = 0; i < this.index.concepts.length; i++) {
50
- this.summaryMap.set(this.index.concepts[i].id, this.index.concepts[i]);
51
- this.positionIndex.set(this.index.concepts[i].id, i);
54
+ for (let i = 0; i < this.index!.concepts.length; i++) {
55
+ const entry = this.index!.concepts[i];
56
+ if (entry) {
57
+ this.summaryMap.set(entry.id, entry);
58
+ this.positionIndex.set(entry.id, i);
59
+ }
52
60
  }
53
- return this.index;
54
61
  }
55
62
 
56
63
  private async loadIndexChunked(): Promise<ConceptIndex> {
@@ -62,12 +69,7 @@ export class DatasetAdapter {
62
69
  const resp = await fetch(`${this.baseUrl}/index.json`);
63
70
  if (!resp.ok) throw new Error(`Failed to load index for ${this.registerId}`);
64
71
  this.index = (await resp.json()) as ConceptIndex;
65
- this.summaryMap.clear();
66
- this.positionIndex.clear();
67
- for (let i = 0; i < this.index.concepts.length; i++) {
68
- this.summaryMap.set(this.index.concepts[i].id, this.index.concepts[i]);
69
- this.positionIndex.set(this.index.concepts[i].id, i);
70
- }
72
+ this.buildSummaryIndex();
71
73
  return this.index;
72
74
  }
73
75
 
@@ -121,7 +123,7 @@ export class DatasetAdapter {
121
123
  eng: entry.designations?.eng || Object.values(entry.designations || {})[0] || '',
122
124
  status: entry.status,
123
125
  };
124
- (this.index!.concepts as (ConceptSummary | undefined)[])[startPos + i] = summary;
126
+ this.index!.concepts[startPos + i] = summary;
125
127
  this.summaryMap.set(entry.id, summary);
126
128
  this.positionIndex.set(entry.id, startPos + i);
127
129
  }
@@ -153,7 +155,7 @@ export class DatasetAdapter {
153
155
 
154
156
  isRangeLoaded(offset: number, limit: number): boolean {
155
157
  if (!this.index?.concepts) return false;
156
- const arr = this.index.concepts as (ConceptSummary | undefined)[];
158
+ const arr = this.index.concepts;
157
159
  for (let i = offset; i < Math.min(offset + limit, arr.length); i++) {
158
160
  if (arr[i] === undefined) return false;
159
161
  }
@@ -175,7 +177,7 @@ export class DatasetAdapter {
175
177
  return this.summaryMap.get(conceptId);
176
178
  }
177
179
 
178
- getConcepts(): ConceptSummary[] {
180
+ getConcepts(): (ConceptSummary | undefined)[] {
179
181
  return this.index?.concepts ?? [];
180
182
  }
181
183
 
@@ -188,7 +190,7 @@ export class DatasetAdapter {
188
190
  }
189
191
 
190
192
  getAdjacentConcepts(conceptId: string): { prev: string | null; next: string | null } {
191
- const concepts = this.index?.concepts as (ConceptSummary | undefined)[] | undefined;
193
+ const concepts = this.index?.concepts;
192
194
  if (!concepts) return { prev: null, next: null };
193
195
  const idx = this.getConceptPosition(conceptId);
194
196
  if (idx === -1) return { prev: null, next: null };
@@ -208,7 +210,7 @@ export class DatasetAdapter {
208
210
  search(query: string, lang: string = 'eng'): SearchHit[] {
209
211
  const q = query.toLowerCase();
210
212
  const hits: SearchHit[] = [];
211
- const arr = this.index?.concepts as (ConceptSummary | undefined)[] | undefined;
213
+ const arr = this.index?.concepts;
212
214
  if (!arr) return hits;
213
215
 
214
216
  for (const entry of arr) {
@@ -34,7 +34,7 @@ export interface ConceptIndex {
34
34
  conceptCount: number;
35
35
  chunkSize: number;
36
36
  chunks: { file: string; count: number }[];
37
- concepts: ConceptSummary[];
37
+ concepts: (ConceptSummary | undefined)[];
38
38
  }
39
39
 
40
40
  export interface ConceptSummary {
@@ -5,6 +5,8 @@ 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 { RenderOptions } from '../utils/math';
8
+ import { escapeAttr } from '../utils/escape';
9
+ import { entryStatusColor, designationTypeLabel, designationTypeColor, getPreferredTerm } from '../utils/concept-helpers';
8
10
  import { useRouter } from 'vue-router';
9
11
  import { useVocabularyStore } from '../stores/vocabulary';
10
12
  import { useDsStyle } from '../utils/dataset-style';
@@ -66,27 +68,24 @@ const languages = computed(() => {
66
68
  });
67
69
  });
68
70
 
69
- // Initialize collapsed state when languages change
71
+ // Collapsible language sections — auto-collapse non-eng when 6+ languages
72
+ const collapsedLangs = ref(new Set<string>());
73
+
74
+ function initCollapsed(langs: string[]) {
75
+ if (langs.length >= 6) {
76
+ collapsedLangs.value = new Set(langs.filter(l => l !== 'eng'));
77
+ }
78
+ }
79
+
70
80
  watch(languages, (langs) => { initCollapsed(langs); }, { immediate: true });
71
81
 
72
82
  const engConcept = computed((): LocalizedConcept | null => {
73
83
  return props.concept['gl:localizedConcept']?.['eng'] ?? null;
74
84
  });
75
85
 
76
- const primaryTerm = computed(() => {
77
- const eng = engConcept.value;
78
- if (!eng?.['gl:designation']?.length) return conceptId.value;
79
- const desigs = eng['gl:designation'];
80
- const preferredExpr = desigs.find(d => d['gl:normativeStatus'] === 'preferred' && d['@type'] === 'gl:Expression');
81
- if (preferredExpr) return preferredExpr['gl:term'];
82
- const preferred = desigs.find(d => d['gl:normativeStatus'] === 'preferred');
83
- return preferred?.['gl:term'] ?? desigs[0]?.['gl:term'] ?? conceptId.value;
84
- });
86
+ const primaryTerm = computed(() => getPreferredTerm(engConcept.value, conceptId.value));
85
87
 
86
88
  // Cross-reference resolver: generates clickable links for inline refs
87
- function escapeAttr(s: string) {
88
- return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
89
- }
90
89
 
91
90
  const { ensureBibLoaded, bibResolver, figResolver } = useRenderOptions(() => props.registerId);
92
91
 
@@ -158,15 +157,6 @@ const allLangContent = computed(() => {
158
157
  return result;
159
158
  });
160
159
 
161
- // Collapsible language sections — auto-collapse non-eng when 6+ languages
162
- const collapsedLangs = ref(new Set<string>());
163
-
164
- function initCollapsed(langs: string[]) {
165
- if (langs.length >= 6) {
166
- collapsedLangs.value = new Set(langs.filter(l => l !== 'eng'));
167
- }
168
- }
169
-
170
160
  function hasContent(lc: LangContent): boolean {
171
161
  return !!(lc.definition || lc.notes.length || lc.examples.length || lc.sources.length);
172
162
  }
@@ -256,12 +246,7 @@ async function navigateEdge(edge: GraphEdge) {
256
246
 
257
247
  function getTermForLang(lang: string): string {
258
248
  const lc = props.concept['gl:localizedConcept']?.[lang];
259
- if (!lc?.['gl:designation']?.length) return '\u2014';
260
- const desigs = lc['gl:designation'];
261
- const preferredExpr = desigs.find(d => d['gl:normativeStatus'] === 'preferred' && d['@type'] === 'gl:Expression');
262
- if (preferredExpr) return preferredExpr['gl:term'];
263
- const preferred = desigs.find(d => d['gl:normativeStatus'] === 'preferred');
264
- return preferred?.['gl:term'] ?? desigs[0]?.['gl:term'] ?? '\u2014';
249
+ return getPreferredTerm(lc);
265
250
  }
266
251
 
267
252
  function getDesignationsForLang(lang: string) {
@@ -283,32 +268,9 @@ function hasDefinition(lang: string): boolean {
283
268
  return lc['gl:definition']?.some((d: any) => d['gl:content']) ?? false;
284
269
  }
285
270
 
286
- function designationTypeLabel(type: string): string {
287
- const labels: Record<string, string> = {
288
- 'gl:Expression': 'Expression',
289
- 'gl:Symbol': 'Symbol',
290
- 'gl:Abbreviation': 'Abbreviation',
291
- 'gl:GraphicalSymbol': 'Graphical',
292
- };
293
- return labels[type] ?? type;
294
- }
295
-
296
- function designationTypeColor(type: string): string {
297
- if (type === 'gl:Symbol') return 'badge-purple';
298
- if (type === 'gl:Abbreviation') return 'badge-yellow';
299
- return 'badge-blue';
300
- }
301
-
302
- function entryStatusColor(status: string): string {
303
- if (status === 'valid' || status === 'Standard') return 'badge-green';
304
- if (status === 'superseded') return 'bg-red-50 text-red-700';
305
- if (status === 'withdrawn') return 'bg-red-100 text-red-800';
306
- if (status === 'draft') return 'badge-yellow';
307
- return 'badge-gray';
308
- }
309
-
310
271
  function goAdjacent(id: string) {
311
272
  router.push({ name: 'concept', params: { registerId: props.registerId, conceptId: id } });
273
+ window.scrollTo({ top: 0, behavior: 'smooth' });
312
274
  }
313
275
 
314
276
  function plainTruncate(html: string, max: number = 120): string {
@@ -339,7 +301,7 @@ function plainTruncate(html: string, max: number = 120): string {
339
301
  v-if="adjacent.prev"
340
302
  @click="goAdjacent(adjacent.prev)"
341
303
  class="p-1.5 rounded-md text-ink-300 hover:text-ink-600 hover:bg-ink-50 transition-colors"
342
- title="Previous concept"
304
+ title="Previous concept (←)"
343
305
  >
344
306
  <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
345
307
  </button>
@@ -347,7 +309,7 @@ function plainTruncate(html: string, max: number = 120): string {
347
309
  v-if="adjacent.next"
348
310
  @click="goAdjacent(adjacent.next)"
349
311
  class="p-1.5 rounded-md text-ink-300 hover:text-ink-600 hover:bg-ink-50 transition-colors"
350
- title="Next concept"
312
+ title="Next concept (→)"
351
313
  >
352
314
  <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
353
315
  </button>
@@ -2,6 +2,7 @@
2
2
  import type { LocalizedConcept } from '../adapters/types';
3
3
  import { computed } from 'vue';
4
4
  import { langName, langLabel } from '../utils/lang';
5
+ import { entryStatusColor } from '../utils/concept-helpers';
5
6
 
6
7
  const props = defineProps<{
7
8
  localizedConcepts: Record<string, LocalizedConcept>;
@@ -216,14 +217,6 @@ function eventRingColor(type: string): string {
216
217
  return colors[type] || 'ring-ink-100';
217
218
  }
218
219
 
219
- function entryStatusColor(status: string): string {
220
- if (status === 'valid' || status === 'Standard') return 'badge-green';
221
- if (status === 'superseded') return 'bg-red-50 text-red-700';
222
- if (status === 'withdrawn') return 'bg-red-100 text-red-800';
223
- if (status === 'draft') return 'badge-yellow';
224
- return 'badge-gray';
225
- }
226
-
227
220
  function eventIconPath(type: string): string {
228
221
  // Returns an SVG path for the event type icon
229
222
  switch (type) {
@@ -4,6 +4,8 @@ import { computed } from 'vue';
4
4
  import { langName, langLabel } from '../utils/lang';
5
5
  import { renderMath } from '../utils/math';
6
6
  import type { RenderOptions } from '../utils/math';
7
+ import { escapeAttr } from '../utils/escape';
8
+ import { entryStatusColor, designationTypeLabel, designationTypeColor } from '../utils/concept-helpers';
7
9
  import { useRouter } from 'vue-router';
8
10
  import { useVocabularyStore } from '../stores/vocabulary';
9
11
  import { getFactory } from '../adapters/factory';
@@ -51,35 +53,10 @@ function normativeColor(status: string): string {
51
53
  if (status === 'deprecated') return 'bg-red-50 text-red-700';
52
54
  return 'bg-amber-50 text-amber-700';
53
55
  }
54
- function entryStatusColor(status: string): string {
55
- if (status === 'valid' || status === 'Standard') return 'badge-green';
56
- if (status === 'superseded') return 'bg-red-50 text-red-700';
57
- if (status === 'withdrawn') return 'bg-red-100 text-red-800';
58
- if (status === 'draft') return 'badge-yellow';
59
- return 'badge-gray';
60
- }
61
- function designationTypeLabel(type: string): string {
62
- const labels: Record<string, string> = {
63
- 'gl:Expression': 'Expression',
64
- 'gl:Symbol': 'Symbol',
65
- 'gl:Abbreviation': 'Abbreviation',
66
- 'gl:GraphicalSymbol': 'Graphical',
67
- };
68
- return labels[type] ?? type;
69
- }
70
- function designationTypeColor(type: string): string {
71
- if (type === 'gl:Symbol') return 'badge-purple';
72
- if (type === 'gl:Abbreviation') return 'badge-yellow';
73
- return 'badge-blue';
74
- }
75
56
 
76
57
  const router = useRouter();
77
58
  const store = useVocabularyStore();
78
59
 
79
- function escapeAttr(s: string) {
80
- return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
81
- }
82
-
83
60
  const factory = getFactory();
84
61
 
85
62
  const renderOpts: RenderOptions = {
@@ -1,6 +1,7 @@
1
1
  import { ref, watch } from 'vue';
2
2
  import type { RenderOptions, BibResolver, FigResolver } from '../utils/math';
3
3
  import { getFactory } from '../adapters/factory';
4
+ import { escapeAttr } from '../utils/escape';
4
5
 
5
6
  interface BibEntry {
6
7
  reference: string;
@@ -52,7 +53,3 @@ export function useRenderOptions(registerId: () => string) {
52
53
 
53
54
  return { bibData, ensureBibLoaded, bibResolver, figResolver };
54
55
  }
55
-
56
- function escapeAttr(s: string) {
57
- return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
58
- }
@@ -1,6 +1,6 @@
1
1
  import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
2
2
 
3
- const routes: RouteRecordRaw[] = [
3
+ export const routes: RouteRecordRaw[] = [
4
4
  {
5
5
  path: '/',
6
6
  name: 'home',
@@ -58,8 +58,8 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
58
58
  }
59
59
  }
60
60
  initialized.value = true;
61
- } catch (e: any) {
62
- error.value = `Failed to discover datasets: ${e.message}`;
61
+ } catch (e: unknown) {
62
+ error.value = `Failed to discover datasets: ${e instanceof Error ? e.message : String(e)}`;
63
63
  } finally {
64
64
  loading.value = false;
65
65
  }
@@ -81,8 +81,8 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
81
81
 
82
82
  // Seed graph nodes lazily — don't block UI for large datasets
83
83
  seedGraphNodes(registerId, adapter);
84
- } catch (e: any) {
85
- error.value = `Failed to load dataset ${registerId}: ${e.message}`;
84
+ } catch (e: unknown) {
85
+ error.value = `Failed to load dataset ${registerId}: ${e instanceof Error ? e.message : String(e)}`;
86
86
  throw e;
87
87
  }
88
88
  }
@@ -234,8 +234,8 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
234
234
 
235
235
  touchGraph();
236
236
  conceptEdges.value = graph.value.getEdges(uri);
237
- } catch (e: any) {
238
- error.value = `Failed to load concept ${conceptId}: ${e.message}`;
237
+ } catch (e: unknown) {
238
+ error.value = `Failed to load concept ${conceptId}: ${e instanceof Error ? e.message : String(e)}`;
239
239
  currentConcept.value = null;
240
240
  throw e;
241
241
  }
@@ -275,7 +275,7 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
275
275
  const loaded = [...datasets.value.values()].filter(a => a.index);
276
276
  if (!loaded.length) return null;
277
277
  const adapter = loaded[Math.floor(Math.random() * loaded.length)];
278
- const concepts = adapter.getConcepts() as (import('../adapters/types').ConceptSummary | undefined)[];
278
+ const concepts = adapter.getConcepts();
279
279
  const dense = concepts.filter((c): c is import('../adapters/types').ConceptSummary => c != null);
280
280
  if (!dense.length) return null;
281
281
  const pick = dense[Math.floor(Math.random() * dense.length)];
@@ -2,10 +2,21 @@
2
2
  * Lightweight AsciiDoc-to-HTML converter for news posts.
3
3
  * Handles: paragraphs, headings, bold, italic, monospace, links, lists, source blocks.
4
4
  */
5
+
6
+ import { escapeHtml, escapeAttr } from './escape';
7
+
5
8
  export function renderAsciiDocLite(text: string): string {
6
9
  if (!text) return '';
7
10
 
8
11
  const output: string[] = [];
12
+ let paragraphBuf: string[] = [];
13
+
14
+ function flushParagraph() {
15
+ if (paragraphBuf.length > 0) {
16
+ output.push(`<p>${paragraphBuf.join(' ')}</p>`);
17
+ paragraphBuf = [];
18
+ }
19
+ }
9
20
  const lines = text.split('\n');
10
21
  let i = 0;
11
22
  let inSourceBlock = false;
@@ -22,7 +33,7 @@ export function renderAsciiDocLite(text: string): string {
22
33
  sourceLines = [];
23
34
  inSourceBlock = false;
24
35
  } else {
25
- flushParagraph(output);
36
+ flushParagraph();
26
37
  inSourceBlock = true;
27
38
  }
28
39
  i++;
@@ -37,7 +48,7 @@ export function renderAsciiDocLite(text: string): string {
37
48
 
38
49
  // Empty line — paragraph break
39
50
  if (!trimmed) {
40
- flushParagraph(output);
51
+ flushParagraph();
41
52
  i++;
42
53
  continue;
43
54
  }
@@ -45,7 +56,7 @@ export function renderAsciiDocLite(text: string): string {
45
56
  // Headings
46
57
  const headingMatch = trimmed.match(/^(={1,5})\s+(.+)$/);
47
58
  if (headingMatch) {
48
- flushParagraph(output);
59
+ flushParagraph();
49
60
  const level = headingMatch[1].length + 1;
50
61
  output.push(`<h${level}>${inlineFormat(headingMatch[2])}</h${level}>`);
51
62
  i++;
@@ -54,7 +65,7 @@ export function renderAsciiDocLite(text: string): string {
54
65
 
55
66
  // Unordered list item
56
67
  if (trimmed.match(/^\*+\s+/)) {
57
- flushParagraph(output);
68
+ flushParagraph();
58
69
  const items: string[] = [];
59
70
  while (i < lines.length && lines[i].trim().match(/^\*+\s+/)) {
60
71
  const itemLine = lines[i].trim();
@@ -69,7 +80,7 @@ export function renderAsciiDocLite(text: string): string {
69
80
 
70
81
  // Ordered list item
71
82
  if (trimmed.match(/^\.\s+/)) {
72
- flushParagraph(output);
83
+ flushParagraph();
73
84
  const items: string[] = [];
74
85
  while (i < lines.length && lines[i].trim().match(/^\.\s+/)) {
75
86
  items.push(`<li>${inlineFormat(lines[i].trim().replace(/^\.\s+/, ''))}</li>`);
@@ -84,20 +95,11 @@ export function renderAsciiDocLite(text: string): string {
84
95
  i++;
85
96
  }
86
97
 
87
- flushParagraph(output);
98
+ flushParagraph();
88
99
 
89
100
  return output.join('\n');
90
101
  }
91
102
 
92
- let paragraphBuf: string[] = [];
93
-
94
- function flushParagraph(output: string[]) {
95
- if (paragraphBuf.length > 0) {
96
- output.push(`<p>${paragraphBuf.join(' ')}</p>`);
97
- paragraphBuf = [];
98
- }
99
- }
100
-
101
103
  function inlineFormat(text: string): string {
102
104
  // AsciiDoc link: https://example.com[text]
103
105
  text = text.replace(/(https?:\/\/[^\s\[]+)\[([^\]]*)\]/g, (_, url, label) =>
@@ -120,7 +122,3 @@ function inlineFormat(text: string): string {
120
122
 
121
123
  return text;
122
124
  }
123
-
124
- function escapeHtml(s: string): string {
125
- return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
126
- }
@@ -0,0 +1,34 @@
1
+ import type { Designation, LocalizedConcept } from '../adapters/types';
2
+
3
+ export function entryStatusColor(status: string): string {
4
+ if (status === 'valid' || status === 'Standard') return 'badge-green';
5
+ if (status === 'superseded') return 'bg-red-50 text-red-700';
6
+ if (status === 'withdrawn') return 'bg-red-100 text-red-800';
7
+ if (status === 'draft') return 'badge-yellow';
8
+ return 'badge-gray';
9
+ }
10
+
11
+ export function designationTypeLabel(type: string): string {
12
+ const labels: Record<string, string> = {
13
+ 'gl:Expression': 'Expression',
14
+ 'gl:Symbol': 'Symbol',
15
+ 'gl:Abbreviation': 'Abbreviation',
16
+ 'gl:GraphicalSymbol': 'Graphical',
17
+ };
18
+ return labels[type] ?? type;
19
+ }
20
+
21
+ export function designationTypeColor(type: string): string {
22
+ if (type === 'gl:Symbol') return 'badge-purple';
23
+ if (type === 'gl:Abbreviation') return 'badge-yellow';
24
+ return 'badge-blue';
25
+ }
26
+
27
+ export function getPreferredTerm(lc: LocalizedConcept | null | undefined, fallback = '—'): string {
28
+ if (!lc?.['gl:designation']?.length) return fallback;
29
+ const desigs = lc['gl:designation'];
30
+ const preferredExpr = desigs.find(d => d['gl:normativeStatus'] === 'preferred' && d['@type'] === 'gl:Expression');
31
+ if (preferredExpr) return preferredExpr['gl:term'];
32
+ const preferred = desigs.find(d => d['gl:normativeStatus'] === 'preferred');
33
+ return preferred?.['gl:term'] ?? desigs[0]?.['gl:term'] ?? fallback;
34
+ }
@@ -0,0 +1,7 @@
1
+ export function escapeHtml(s: string): string {
2
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
3
+ }
4
+
5
+ export function escapeAttr(s: string): string {
6
+ return escapeHtml(s).replace(/"/g, '&quot;');
7
+ }
@@ -104,6 +104,4 @@ export function renderMarkdown(input: string): string {
104
104
  return blocks.join('\n');
105
105
  }
106
106
 
107
- function escapeHtml(s: string): string {
108
- return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
109
- }
107
+ import { escapeHtml } from './escape';
package/src/utils/math.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { escapeHtml, escapeAttr } from './escape';
2
+
1
3
  export type XrefResolver = (uri: string, term: string) => string;
2
4
  export type BibResolver = (refId: string, title: string) => string;
3
5
  export type FigResolver = (figId: string) => string;
@@ -8,10 +10,6 @@ export interface RenderOptions {
8
10
  figResolver?: FigResolver;
9
11
  }
10
12
 
11
- function escapeAttr(s: string): string {
12
- return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
13
- }
14
-
15
13
  function replaceBracketed(text: string, prefix: string, handler: (content: string, bold: boolean) => string): string {
16
14
  let result = '';
17
15
  let i = 0;
@@ -84,13 +82,6 @@ function convertLists(text: string): string {
84
82
  return result;
85
83
  }
86
84
 
87
- function escapeHtml(text: string): string {
88
- return text
89
- .replace(/&/g, '&amp;')
90
- .replace(/</g, '&lt;')
91
- .replace(/>/g, '&gt;');
92
- }
93
-
94
85
  export function renderMath(text: string, xrefResolverOrOpts?: XrefResolver | RenderOptions): string {
95
86
  if (!text) return '';
96
87
  let result = text;
@@ -1,3 +1,5 @@
1
+ import { escapeHtml } from './escape';
2
+
1
3
  type PlurimathCtor = new (data: string, format: string) => {
2
4
  toAsciimath(): string;
3
5
  toLatex(): string;
@@ -30,13 +32,6 @@ export function renderToMathML(expr: string, format: string): string | null {
30
32
  }
31
33
  }
32
34
 
33
- function escapeHtml(text: string): string {
34
- return text
35
- .replace(/&/g, '&amp;')
36
- .replace(/</g, '&lt;')
37
- .replace(/>/g, '&gt;');
38
- }
39
-
40
35
  export function mathToHtml(expr: string, format: string, bold: boolean): string {
41
36
  const mathml = renderToMathML(expr, format);
42
37
  if (mathml) {
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
- import { computed, watch, ref } from 'vue';
2
+ import { computed, watch, ref, onMounted, onUnmounted } from 'vue';
3
+ import { useRouter } from 'vue-router';
3
4
  import { useVocabularyStore } from '../stores/vocabulary';
4
5
  import ConceptDetail from '../components/ConceptDetail.vue';
5
6
 
@@ -9,6 +10,7 @@ const props = defineProps<{
9
10
  }>();
10
11
 
11
12
  const store = useVocabularyStore();
13
+ const router = useRouter();
12
14
  const conceptLoading = ref(false);
13
15
  const localError = ref<string | null>(null);
14
16
 
@@ -55,6 +57,25 @@ async function loadAdjacent() {
55
57
  }
56
58
 
57
59
  watch(() => props.conceptId, () => { loadAdjacent(); });
60
+
61
+ function goAdjacent(id: string) {
62
+ router.push({ name: 'concept', params: { registerId: props.registerId, conceptId: id } });
63
+ window.scrollTo({ top: 0, behavior: 'smooth' });
64
+ }
65
+
66
+ function onKeydown(e: KeyboardEvent) {
67
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
68
+ if (e.key === 'ArrowLeft' && adjacent.value.prev) {
69
+ e.preventDefault();
70
+ goAdjacent(adjacent.value.prev);
71
+ } else if (e.key === 'ArrowRight' && adjacent.value.next) {
72
+ e.preventDefault();
73
+ goAdjacent(adjacent.value.next);
74
+ }
75
+ }
76
+
77
+ onMounted(() => window.addEventListener('keydown', onKeydown));
78
+ onUnmounted(() => window.removeEventListener('keydown', onKeydown));
58
79
  </script>
59
80
 
60
81
  <template>
@@ -44,6 +44,11 @@ function onGlobalKeydown(e: KeyboardEvent) {
44
44
  e.preventDefault();
45
45
  filterInput.value?.focus();
46
46
  }
47
+ if (e.key === 'ArrowRight' && document.activeElement?.tagName !== 'INPUT' && page.value < totalPages.value) {
48
+ goToPage(page.value + 1);
49
+ } else if (e.key === 'ArrowLeft' && document.activeElement?.tagName !== 'INPUT' && page.value > 1) {
50
+ goToPage(page.value - 1);
51
+ }
47
52
  }
48
53
 
49
54
  onMounted(() => window.addEventListener('keydown', onGlobalKeydown));
@@ -62,7 +67,7 @@ watch(filter, async (q) => {
62
67
 
63
68
  // Dense array: only loaded (non-undefined) entries
64
69
  const loadedConcepts = computed(() => {
65
- const arr = adapter.value?.getConcepts() as (import('../adapters/types').ConceptSummary | undefined)[] | undefined;
70
+ const arr = adapter.value?.getConcepts();
66
71
  if (!arr) return [];
67
72
  return arr.filter((c): c is import('../adapters/types').ConceptSummary => c != null);
68
73
  });
@@ -93,7 +98,7 @@ const paged = computed(() => {
93
98
  }
94
99
  // When not filtering, slice directly from the pre-allocated index (may contain undefined)
95
100
  const start = (page.value - 1) * perPage;
96
- const arr = adapter.value?.getConcepts() as (import('../adapters/types').ConceptSummary | undefined)[] | undefined;
101
+ const arr = adapter.value?.getConcepts();
97
102
  if (!arr) return [];
98
103
  return arr.slice(start, start + perPage).filter((c): c is import('../adapters/types').ConceptSummary => c != null);
99
104
  });