@glossarist/concept-browser 0.7.44 → 0.7.45

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 (56) hide show
  1. package/package.json +2 -2
  2. package/scripts/generate-data.mjs +20 -11
  3. package/scripts/lib/build/image-assets.mjs +190 -0
  4. package/scripts/lib/build/non-verbal-consumer.mjs +221 -0
  5. package/src/__tests__/bibliography-adapter.test.ts +79 -0
  6. package/src/__tests__/content-renderer-nvr-mentions.test.ts +57 -0
  7. package/src/__tests__/locale.test.ts +46 -0
  8. package/src/__tests__/model-bridge-entity-refs.test.ts +114 -0
  9. package/src/__tests__/non-verbal-anchor.test.ts +33 -0
  10. package/src/__tests__/non-verbal-cross-ref.test.ts +146 -0
  11. package/src/__tests__/non-verbal-highlight.test.ts +56 -0
  12. package/src/__tests__/non-verbal-kind.test.ts +77 -0
  13. package/src/__tests__/non-verbal-list.test.ts +67 -0
  14. package/src/__tests__/non-verbal-rep-display.test.ts +85 -0
  15. package/src/__tests__/non-verbal-scroll-guard.test.ts +116 -0
  16. package/src/__tests__/use-concept-entities.test.ts +76 -0
  17. package/src/adapters/bibliography-adapter.ts +49 -0
  18. package/src/adapters/factory.ts +14 -0
  19. package/src/adapters/model-bridge.ts +51 -0
  20. package/src/adapters/non-verbal/figure-bridge.ts +101 -0
  21. package/src/adapters/non-verbal/formula-bridge.ts +48 -0
  22. package/src/adapters/non-verbal/index.ts +55 -0
  23. package/src/adapters/non-verbal/kind.ts +46 -0
  24. package/src/adapters/non-verbal/prefix.ts +67 -0
  25. package/src/adapters/non-verbal/source-bridge.ts +81 -0
  26. package/src/adapters/non-verbal/table-bridge.ts +98 -0
  27. package/src/adapters/non-verbal/types.ts +133 -0
  28. package/src/adapters/non-verbal-resolver.ts +101 -0
  29. package/src/components/ConceptDetail.vue +17 -4
  30. package/src/components/LanguageDetail.vue +0 -3
  31. package/src/components/NonVerbalRepDisplay.vue +82 -24
  32. package/src/components/figure/FigureDisplay.vue +132 -0
  33. package/src/components/figure/FigureImages.vue +111 -0
  34. package/src/components/figure/figure-image-pick.ts +56 -0
  35. package/src/components/figure/figure-layout.ts +26 -0
  36. package/src/components/formula/FormulaDisplay.vue +90 -0
  37. package/src/components/formula/FormulaExpression.vue +70 -0
  38. package/src/components/non-verbal/NonVerbalCaption.vue +104 -0
  39. package/src/components/non-verbal/NonVerbalFallback.vue +69 -0
  40. package/src/components/non-verbal/NonVerbalList.vue +118 -0
  41. package/src/components/non-verbal/NonVerbalSources.vue +61 -0
  42. package/src/components/table/TableDisplay.vue +99 -0
  43. package/src/components/table/TableMarkup.vue +63 -0
  44. package/src/components/table/TableStructured.vue +66 -0
  45. package/src/composables/use-concept-entities.ts +70 -0
  46. package/src/composables/use-non-verbal-cross-ref.ts +79 -0
  47. package/src/composables/use-non-verbal-entity.ts +58 -0
  48. package/src/composables/use-reduced-motion.ts +26 -0
  49. package/src/composables/use-render-options.ts +30 -33
  50. package/src/router/index.ts +3 -0
  51. package/src/router/non-verbal-scroll-guard.ts +56 -0
  52. package/src/style.css +17 -0
  53. package/src/utils/content-renderer.ts +76 -64
  54. package/src/utils/locale.ts +92 -0
  55. package/src/utils/non-verbal-anchor.ts +51 -0
  56. package/src/utils/non-verbal-highlight.ts +27 -0
@@ -1,55 +1,52 @@
1
- import { ref, watch } from 'vue';
2
- import type { RenderOptions, BibResolver, FigResolver } from '../utils/content-renderer';
1
+ import { ref } from 'vue';
2
+ import type { RenderOptions, BibResolver, NonVerbalRefResolver } from '../utils/content-renderer';
3
+ import type { NonVerbalKind } from '../adapters/non-verbal/types';
3
4
  import { getFactory } from '../adapters/factory';
5
+ import { anchorId } from '../utils/non-verbal-anchor';
4
6
  import { escapeAttr } from '../utils/escape';
5
7
 
6
- interface BibEntry {
7
- reference: string;
8
- title?: string;
9
- link?: string;
10
- }
11
-
12
- const bibCache = new Map<string, Record<string, BibEntry>>();
13
-
14
- async function loadBibliography(registerId: string): Promise<Record<string, BibEntry> | null> {
15
- if (bibCache.has(registerId)) return bibCache.get(registerId)!;
16
- try {
17
- const resp = await fetch(`${import.meta.env.BASE_URL}data/${registerId}/bibliography.json`);
18
- if (!resp.ok) return null;
19
- const data = await resp.json();
20
- bibCache.set(registerId, data);
21
- return data;
22
- } catch {
23
- return null;
24
- }
25
- }
26
-
27
8
  export function useRenderOptions(registerId: () => string) {
28
- const bibData = ref<Record<string, BibEntry> | null>(null);
9
+ const ready = ref(false);
29
10
 
30
11
  async function ensureBibLoaded() {
31
12
  const id = registerId();
32
13
  if (!id) return;
33
- bibData.value = await loadBibliography(id);
14
+ await getFactory().bibliography(id).load();
15
+ ready.value = true;
34
16
  }
35
17
 
36
18
  const bibResolver: BibResolver = (refId, title) => {
37
- const entry = bibData.value?.[refId];
19
+ const id = registerId();
20
+ const entry = id ? getFactory().bibliography(id).findById(refId) : null;
38
21
  if (!entry) {
39
22
  return `<span class="bib-ref">${escapeAttr(title)}</span>`;
40
23
  }
41
- const display = title || entry.reference;
24
+ const display = title || entry.reference || refId;
42
25
  if (entry.link) {
43
- return `<a href="${escapeAttr(entry.link)}" target="_blank" rel="noopener" class="bib-link" title="${escapeAttr(entry.title || '')}">${escapeAttr(display)}</a>`;
26
+ return `<a href="${escapeAttr(entry.link)}" target="_blank" rel="noopener" class="bib-link" title="${escapeAttr(entry.title ?? '')}">${escapeAttr(display)}</a>`;
44
27
  }
45
- return `<span class="bib-ref" title="${escapeAttr(entry.title || '')}">${escapeAttr(display)}</span>`;
28
+ return `<span class="bib-ref" title="${escapeAttr(entry.title ?? '')}">${escapeAttr(display)}</span>`;
46
29
  };
47
30
 
48
- const figResolver: FigResolver = (figId) => {
31
+ const nonVerbalRefResolver: NonVerbalRefResolver = (kind: NonVerbalKind, entityId, display) => {
49
32
  const id = registerId();
50
- const imgSrc = `${import.meta.env.BASE_URL}data/${id}/images/${figId}.png`;
51
- return `<span class="fig-ref"><a href="${escapeAttr(imgSrc)}" target="_blank" rel="noopener">${escapeAttr(figId)}</a></span>`;
33
+ if (!id) {
34
+ const label = display ?? entityId;
35
+ return `<span class="nv-ref nv-ref--${kind}">${escapeAttr(label)}</span>`;
36
+ }
37
+ const href = `#${anchorId(kind, id, entityId)}`;
38
+ const label = display ?? defaultLabelForKind(kind, entityId);
39
+ const cls = `nv-ref nv-ref--${kind}`;
40
+ return `<a href="${href}" class="${cls}" data-nv-kind="${escapeAttr(kind)}" data-nv-dataset="${escapeAttr(id)}" data-nv-entity="${escapeAttr(entityId)}">${escapeAttr(label)}</a>`;
52
41
  };
53
42
 
54
- return { bibData, ensureBibLoaded, bibResolver, figResolver };
43
+ return { ready, ensureBibLoaded, bibResolver, nonVerbalRefResolver };
44
+ }
45
+
46
+ function defaultLabelForKind(kind: NonVerbalKind, entityId: string): string {
47
+ switch (kind) {
48
+ case 'figure': return `Figure ${entityId}`;
49
+ case 'table': return `Table ${entityId}`;
50
+ case 'formula': return `Formula ${entityId}`;
51
+ }
55
52
  }
@@ -1,4 +1,5 @@
1
1
  import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
2
+ import { installNonVerbalScroll } from './non-verbal-scroll-guard';
2
3
 
3
4
  export const routes: RouteRecordRaw[] = [
4
5
  {
@@ -109,4 +110,6 @@ const router = createRouter({
109
110
  routes,
110
111
  });
111
112
 
113
+ installNonVerbalScroll(router);
114
+
112
115
  export default router;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * installNonVerbalScroll — router hook that turns entity-hash deep-links
3
+ * (`/concept/X#figure-{ds}-{id}`) into scroll + highlight on arrival.
4
+ *
5
+ * Why polling instead of an event? Entity components fetch their
6
+ * JSON-LD asynchronously via the resolver; the target `<figure>` does
7
+ * not exist in the DOM until that fetch resolves. Polling for up to
8
+ * `timeoutMs` covers the slow-network worst case without coupling the
9
+ * router to the resolver.
10
+ *
11
+ * The anchor id format is owned by `utils/non-verbal-anchor.ts`; this
12
+ * guard only matches the hash prefix and hands off to the same
13
+ * highlight utility used by click-driven navigation, so the user sees
14
+ * identical behavior whether they arrived by click or by URL.
15
+ */
16
+ import type { Router } from 'vue-router';
17
+ import { highlightEntity, scrollToEntity } from '../utils/non-verbal-highlight';
18
+
19
+ const DEFAULT_TIMEOUT_MS = 5000;
20
+ const POLL_INTERVAL_MS = 50;
21
+
22
+ const ENTITY_HASH_RE = /^#(?:figure|table|formula)-/;
23
+
24
+ export function installNonVerbalScroll(
25
+ router: Router,
26
+ options: { timeoutMs?: number } = {},
27
+ ): void {
28
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
29
+
30
+ router.afterEach((to) => {
31
+ if (!to.hash || !ENTITY_HASH_RE.test(to.hash)) return;
32
+ const anchorId = to.hash.slice(1);
33
+ const prefersReducedMotion = typeof window !== 'undefined'
34
+ && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
35
+
36
+ void waitForElement(anchorId, timeoutMs).then((el) => {
37
+ if (!el) return;
38
+ scrollToEntity(el, !prefersReducedMotion);
39
+ highlightEntity(el);
40
+ });
41
+ });
42
+ }
43
+
44
+ function waitForElement(id: string, timeoutMs: number): Promise<HTMLElement | null> {
45
+ return new Promise((resolve) => {
46
+ if (typeof document === 'undefined') return resolve(null);
47
+ const start = Date.now();
48
+ const tick = () => {
49
+ const el = document.getElementById(id);
50
+ if (el) return resolve(el);
51
+ if (Date.now() - start > timeoutMs) return resolve(null);
52
+ setTimeout(tick, POLL_INTERVAL_MS);
53
+ };
54
+ tick();
55
+ });
56
+ }
package/src/style.css CHANGED
@@ -133,6 +133,23 @@
133
133
  @apply text-ink-600 hover:text-ink-800 underline-offset-2 hover:underline transition-colors;
134
134
  }
135
135
 
136
+ /* Non-verbal entity highlight — single source of truth for the
137
+ cross-ref "you landed here" affordance. Toggled by
138
+ utils/non-verbal-highlight.ts; applies to figure/table/formula
139
+ root elements uniformly. */
140
+ .nv-entity--highlighted {
141
+ outline: 2px solid var(--brand-primary, #2563eb);
142
+ outline-offset: 4px;
143
+ animation: nv-entity-pulse 1.6s ease-out;
144
+ }
145
+ @keyframes nv-entity-pulse {
146
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(37, 99, 235, 0); }
147
+ 20% { box-shadow: 0 0 0 6px rgba(37, 99, 235, 0.25); }
148
+ }
149
+ @media (prefers-reduced-motion: reduce) {
150
+ .nv-entity--highlighted { animation: none; }
151
+ }
152
+
136
153
  /* Concept definition lists */
137
154
  .concept-list {
138
155
  list-style: disc;
@@ -5,28 +5,39 @@
5
5
  * math placeholders, tables, lists, and text formatting. This is the single
6
6
  * source of truth for content rendering in the browser.
7
7
  *
8
+ * Mention kinds routed here (all dispatched by glossarist's `parseMention`):
9
+ * - {{fig:id}} / {{fig:id, display}} → nonVerbalRefResolver (figure)
10
+ * - {{table:id}} / {{table:id, display}} → nonVerbalRefResolver (table)
11
+ * - {{formula:id}} / {{formula:id, display}} → nonVerbalRefResolver (formula)
12
+ * - {{cite:key[, render term]}} → citeResolver
13
+ * - {{urn:...[, render term]}} → urnRefResolver / xrefResolver
14
+ * - {{concept_id[, render term]}} → conceptRefResolver
15
+ * - {{designation[, render term]}} → conceptRefResolver
16
+ *
8
17
  * Math-specific helpers (replaceBracketed, mathPlaceholder) are internal.
9
- * The v-math directive upgrades the placeholders to KaTeX at runtime.
18
+ * The v-math directive upgrades the placeholders to Plurimath at runtime.
10
19
  */
11
20
  import { escapeHtml, escapeAttr } from './escape';
12
21
  import { parseMention } from 'glossarist';
22
+ import type { NonVerbalKind } from '../adapters/non-verbal/types';
23
+ import { entityKindFromMentionKind } from '../adapters/non-verbal/kind';
13
24
 
14
25
  // ── Resolver types ────────────────────────────────────────────────────────
15
26
 
16
27
  export type XrefResolver = (uri: string, term: string) => string;
17
28
  export type BibResolver = (refId: string, title: string) => string;
18
- export type FigResolver = (figId: string) => string;
19
29
  export type CiteResolver = (key: string, label: string | null) => string;
20
30
  export type ConceptRefResolver = (conceptId: string, term: string) => string;
21
31
  export type UrnRefResolver = (uri: string, term: string) => string;
32
+ export type NonVerbalRefResolver = (kind: NonVerbalKind, entityId: string, display?: string) => string;
22
33
 
23
34
  export interface RenderOptions {
24
35
  xrefResolver?: XrefResolver;
25
36
  bibResolver?: BibResolver;
26
- figResolver?: FigResolver;
27
37
  conceptRefResolver?: ConceptRefResolver;
28
38
  citeResolver?: CiteResolver;
29
39
  urnRefResolver?: UrnRefResolver;
40
+ nonVerbalRefResolver?: NonVerbalRefResolver;
30
41
  }
31
42
 
32
43
  // ── Math placeholders ────────────────────────────────────────────────────
@@ -144,19 +155,11 @@ function resolveBibRefs(text: string, opts: RenderOptions): string {
144
155
  });
145
156
  }
146
157
 
147
- function resolveFigRefs(text: string, opts: RenderOptions): string {
148
- return text.replace(/<<(fig_[^>]+)>>/g, (_, figId) => {
149
- if (opts.figResolver) {
150
- return opts.figResolver(figId.trim());
151
- }
152
- return `<span class="fig-ref">${escapeHtml(figId.trim())}</span>`;
153
- });
154
- }
155
-
156
158
  function resolveUrnRefs(text: string, opts: RenderOptions): string {
157
159
  // Double-brace URN refs: {{urn:...,term}} or {{urn:...,term,display}}
158
- // Note: glossarist ≥ 0.3.7 parseMention handles these as 'urn-ref', but we
159
- // keep this handler for when parseMention returns 'unresolved' (glossarist < 0.3.7)
160
+ // These bypass parseMention because the three-arg form has different
161
+ // semantics in the renderer (display shown, not term) vs. cleanContent
162
+ // (term shown for search indexing).
160
163
  let result = text.replace(/\{\{(urn:[^,}]+),([^,}]+)(?:,([^}]+))?\}\}/g, (_, uri, term, display) => {
161
164
  const t = (display || term).trim();
162
165
  if (opts.xrefResolver) {
@@ -178,60 +181,68 @@ function resolveUrnRefs(text: string, opts: RenderOptions): string {
178
181
  }
179
182
 
180
183
  function resolveMentions(text: string, opts: RenderOptions): string {
181
- // Single-pass {{...}} mention dispatcher via parseMention (SSOT)
182
184
  return text.replace(/\{\{([^{}]+?)\}\}/g, (_orig, body) => {
183
185
  const parsed = parseMention(body);
186
+ const p = parsed as Record<string, unknown>;
187
+
188
+ switch (p.kind) {
189
+ case 'fig-ref':
190
+ case 'table-ref':
191
+ case 'formula-ref': {
192
+ const nvKind = entityKindFromMentionKind(p.kind as string) as NonVerbalKind;
193
+ const entityId = p.key as string;
194
+ const display = (p.label as string) ?? undefined;
195
+ if (opts.nonVerbalRefResolver) {
196
+ return opts.nonVerbalRefResolver(nvKind, entityId, display);
197
+ }
198
+ const label = display ?? entityId;
199
+ return `<span class="nv-ref nv-ref--${nvKind}">${escapeHtml(label)}</span>`;
200
+ }
184
201
 
185
- // cite:key[,render term] — bibliography citation
186
- if (parsed.kind === 'cite-ref') {
187
- const key = parsed.key!;
188
- const label = parsed.label ?? null;
189
- if (opts.citeResolver) return opts.citeResolver(key, label);
190
- return `<span class="bib-ref">${escapeHtml(label ?? key)}</span>`;
191
- }
202
+ case 'cite-ref': {
203
+ const key = p.key as string;
204
+ const label = (p.label as string) ?? null;
205
+ if (opts.citeResolver) return opts.citeResolver(key, label);
206
+ return `<span class="bib-ref">${escapeHtml(label ?? key)}</span>`;
207
+ }
192
208
 
193
- // urn:...[,render term] — URN cross-reference (glossarist ≥ 0.3.7)
194
- const anyParsed = parsed as Record<string, unknown>;
195
- if ((anyParsed.kind as string) === 'urn-ref') {
196
- const uri = anyParsed.uri as string;
197
- const label = (anyParsed.label as string) ?? uri;
198
- if (opts.urnRefResolver) return opts.urnRefResolver(uri, label);
199
- if (opts.xrefResolver) return opts.xrefResolver(uri, label);
200
- return escapeHtml(label);
201
- }
209
+ case 'urn-ref': {
210
+ const uri = p.uri as string;
211
+ const label = (p.label as string) ?? uri;
212
+ if (opts.urnRefResolver) return opts.urnRefResolver(uri, label);
213
+ if (opts.xrefResolver) return opts.xrefResolver(uri, label);
214
+ return escapeHtml(label);
215
+ }
202
216
 
203
- // numeric_id[,render term] — local concept ID
204
- if (parsed.kind === 'numeric') {
205
- const id = parsed.id!;
206
- const label = parsed.label;
207
- if (label && opts.conceptRefResolver) {
208
- return opts.conceptRefResolver(id, label);
217
+ case 'numeric': {
218
+ const id = p.id as string;
219
+ const label = p.label as string | null;
220
+ if (label && opts.conceptRefResolver) {
221
+ return opts.conceptRefResolver(id, label);
222
+ }
223
+ return `<span class="gl-mention">${escapeHtml(id)}</span>`;
209
224
  }
210
- return `<span class="gl-mention">${escapeHtml(id)}</span>`;
211
- }
212
225
 
213
- // designation[,render term] — designation matching (glossarist ≥ 0.3.7)
214
- if ((anyParsed.kind as string) === 'designation') {
215
- const designation = anyParsed.id as string;
216
- const label = (anyParsed.label as string) ?? designation;
217
- if (opts.conceptRefResolver) {
218
- return opts.conceptRefResolver(designation, label);
226
+ case 'designation': {
227
+ const designation = p.id as string;
228
+ const label = (p.label as string) ?? designation;
229
+ if (opts.conceptRefResolver) {
230
+ return opts.conceptRefResolver(designation, label);
231
+ }
232
+ return escapeHtml(label);
219
233
  }
220
- return escapeHtml(label);
221
- }
222
234
 
223
- // Fallback for unresolved: handle two-arg form or render as plain text
224
- // This handles cases where parseMention doesn't recognize the kind
225
- // (e.g. glossarist < 0.3.7 before urn-ref/designation kinds were added)
226
- const commaIdx = body.indexOf(',');
227
- if (commaIdx > 0) {
228
- const id = body.slice(0, commaIdx).trim();
229
- const display = body.slice(commaIdx + 1).trim();
230
- if (opts.conceptRefResolver) return opts.conceptRefResolver(id, display);
231
- return escapeHtml(display);
235
+ default: {
236
+ const commaIdx = body.indexOf(',');
237
+ if (commaIdx > 0) {
238
+ const id = body.slice(0, commaIdx).trim();
239
+ const display = body.slice(commaIdx + 1).trim();
240
+ if (opts.conceptRefResolver) return opts.conceptRefResolver(id, display);
241
+ return escapeHtml(display);
242
+ }
243
+ return `<span class="gl-mention">${escapeHtml(body.trim())}</span>`;
244
+ }
232
245
  }
233
-
234
- return `<span class="gl-mention">${escapeHtml(body.trim())}</span>`;
235
246
  });
236
247
  }
237
248
 
@@ -246,9 +257,9 @@ function resolveMentions(text: string, opts: RenderOptions): string {
246
257
  * 3. Bullet and numbered lists
247
258
  * 4. Text formatting (bold, italic, subscript)
248
259
  * 5. Bibliography cross-references (<<ref,title>>)
249
- * 6. Figure references (<<fig_...>>)
250
- * 7. Single-brace URN inline references ({urn:...})
251
- * 8. Mention dispatcher via parseMention (cite-ref, urn-ref, numeric, designation)
260
+ * 6. Single-brace URN inline references ({urn:...})
261
+ * 7. Mention dispatcher non-verbal (fig/table/formula), then parseMention
262
+ * (cite-ref, urn-ref, numeric, designation)
252
263
  */
253
264
  export function renderContent(text: string, xrefResolverOrOpts?: XrefResolver | RenderOptions): string {
254
265
  if (!text) return '';
@@ -273,10 +284,9 @@ export function renderContent(text: string, xrefResolverOrOpts?: XrefResolver |
273
284
 
274
285
  // Stage 4: Reference resolution
275
286
  result = resolveBibRefs(result, opts);
276
- result = resolveFigRefs(result, opts);
277
287
  result = resolveUrnRefs(result, opts);
278
288
 
279
- // Stage 5: Mention dispatcher (parseMention SSOT)
289
+ // Stage 5: Mention dispatcher (non-verbal first, then parseMention SSOT)
280
290
  result = resolveMentions(result, opts);
281
291
 
282
292
  return result;
@@ -295,7 +305,9 @@ export function cleanContent(text: string): string {
295
305
  .replace(/~([^~]+)~/g, '_$1')
296
306
  .replace(/\n[ \t]*\* /g, '; ')
297
307
  .replace(/<<([^,>]+),([^>]+)>>/g, '$2')
298
- .replace(/<<(fig_[^>]+)>>/g, '$1')
308
+ // Non-verbal mentions: {{fig:id, display}} → display; {{fig:id}} → id
309
+ .replace(/\{\{(?:fig|figure|table|tbl|formula|eq):([^,}]+),\s*([^}]+)\}\}/g, '$2')
310
+ .replace(/\{\{(?:fig|figure|table|tbl|formula|eq):([^}]+)\}\}/g, '$1')
299
311
  // URN refs — show render term (second part for two-arg, third part for three-arg)
300
312
  .replace(/\{\{urn:[^,}]+,([^,}]+),([^}]+)\}\}/g, '$1') // three-arg: {{urn:...,term,display}} → term
301
313
  .replace(/\{\{urn:[^,}]+(?:,([^}]+))?\}\}/g, (_, label) => label ? label.trim() : '') // two-arg or bare
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Locale fallback SSOT.
3
+ *
4
+ * Single source of truth for localized text resolution across the runtime.
5
+ * Both the non-verbal entity resolver and any other localized content
6
+ * resolution should call `pickLocaleText` / `pickLocaleMap` rather than
7
+ * implement their own fallback chain.
8
+ */
9
+
10
+ import { fetchLocalizedString } from 'glossarist';
11
+
12
+ const DEFAULT_FALLBACK_CHAIN: readonly string[] = ['eng'] as const;
13
+
14
+ const RTL_LOCALES: ReadonlySet<string> = new Set(['ara', 'heb', 'fas', 'urd', 'arb']);
15
+
16
+ const ISO_639_2_TO_BCP47: Record<string, string> = {
17
+ eng: 'en', fra: 'fr', deu: 'de', zho: 'zh', ara: 'ar', jpn: 'ja', rus: 'ru',
18
+ kor: 'ko', spa: 'es', ita: 'it', por: 'pt', nld: 'nl', swe: 'sv', fin: 'fi',
19
+ dan: 'da', nob: 'nb', nno: 'nn', nor: 'no', pol: 'pl', tur: 'tr', ces: 'cs', ell: 'el',
20
+ heb: 'he', hin: 'hi', ind: 'id', fas: 'fa', ukr: 'uk', hun: 'hu', ron: 'ro',
21
+ slk: 'sk', slv: 'sl', hrv: 'hr', srp: 'sr', bul: 'bg', msa: 'ms', tha: 'th',
22
+ vie: 'vi', urd: 'ur', ben: 'bn', tam: 'ta', tel: 'te', mar: 'mr', guj: 'gu',
23
+ pan: 'pa', mal: 'ml', kan: 'kn', ori: 'or', asm: 'as', sin: 'si', nep: 'ne',
24
+ lit: 'lt', lav: 'lv', est: 'et', gle: 'ga', cym: 'cy', eus: 'eu', cat: 'ca',
25
+ glg: 'gl', afr: 'af', sqi: 'sq', mkd: 'mk', bel: 'be', kaz: 'kk', uzb: 'uz',
26
+ aze: 'az', hye: 'hy', kat: 'ka', mon: 'mn', tuk: 'tk', uig: 'ug', tgl: 'tl',
27
+ };
28
+
29
+ export type LocalizedText = Record<string, string>;
30
+
31
+ export interface ResolvedLocaleText {
32
+ locale: string;
33
+ text: string;
34
+ }
35
+
36
+ export function pickLocaleText(
37
+ map: LocalizedText | undefined,
38
+ locale: string,
39
+ fallbackChain: readonly string[] = DEFAULT_FALLBACK_CHAIN,
40
+ ): string {
41
+ const r = pickLocaleMap(map, locale, fallbackChain);
42
+ return r?.text ?? '';
43
+ }
44
+
45
+ export function pickLocaleMap(
46
+ map: LocalizedText | undefined,
47
+ locale: string,
48
+ fallbackChain: readonly string[] = DEFAULT_FALLBACK_CHAIN,
49
+ ): ResolvedLocaleText | null {
50
+ if (!map) return null;
51
+
52
+ // Disable fetchLocalizedString's built-in 'eng' default by passing null —
53
+ // at runtime `null` defeats the default param and the `!= null` guard
54
+ // inside the lib skips its own fallback. The published .d.ts types the
55
+ // parameter as `string | undefined`, so cast through `unknown`. The chain
56
+ // is owned by THIS module: callers see one predictable resolution order.
57
+ const noFallback = null as unknown as undefined;
58
+ const direct = fetchLocalizedString(map, locale, noFallback);
59
+ if (direct != null) return { locale, text: direct };
60
+
61
+ for (const l of fallbackChain) {
62
+ const fb = fetchLocalizedString(map, l, noFallback);
63
+ if (fb != null) return { locale: l, text: fb };
64
+ }
65
+
66
+ const entries = Object.entries(map);
67
+ if (entries.length === 0) return null;
68
+ return { locale: entries[0][0], text: entries[0][1] };
69
+ }
70
+
71
+ export function hasLocale(map: LocalizedText | undefined, locale: string): boolean {
72
+ return !!map && Object.prototype.hasOwnProperty.call(map, locale);
73
+ }
74
+
75
+ export function isRtl(locale: string): boolean {
76
+ return RTL_LOCALES.has(locale);
77
+ }
78
+
79
+ export function localeToBcp47(locale: string): string {
80
+ return ISO_639_2_TO_BCP47[locale] ?? locale;
81
+ }
82
+
83
+ export function resolveFallbackChain(datasetLocales?: readonly string[]): readonly string[] {
84
+ if (!datasetLocales || datasetLocales.length === 0) {
85
+ return DEFAULT_FALLBACK_CHAIN;
86
+ }
87
+ const chain = [...datasetLocales];
88
+ for (const l of DEFAULT_FALLBACK_CHAIN) {
89
+ if (!chain.includes(l)) chain.push(l);
90
+ }
91
+ return chain;
92
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Anchor scheme SSOT for non-verbal entities.
3
+ *
4
+ * The anchor id format is `{kind}-{datasetId}-{entityId}` (e.g.
5
+ * `figure-iala-2023-mixed-reflection`). Components, cross-ref click
6
+ * handlers, router guards, and prose mentions all use this module to
7
+ * compute or match anchor ids — keeping the scheme in one place means
8
+ * changing it later is a one-file edit.
9
+ *
10
+ * The kind prefix is the kind itself (e.g. `figure`), not a shortened
11
+ * alias. This matches the wire format and the anchor selector prefix
12
+ * used by the cross-ref composable.
13
+ */
14
+
15
+ import type { NonVerbalKind } from '../adapters/non-verbal/types';
16
+
17
+ const ANCHOR_KIND_PREFIX: Record<NonVerbalKind, string> = {
18
+ figure: 'figure',
19
+ table: 'table',
20
+ formula: 'formula',
21
+ };
22
+
23
+ export const ANCHOR_KIND_SELECTORS: readonly string[] = Object
24
+ .values(ANCHOR_KIND_PREFIX)
25
+ .map(k => `a[href^="#${k}-"]`);
26
+
27
+ export function anchorId(kind: NonVerbalKind, datasetId: string, entityId: string): string {
28
+ return `${ANCHOR_KIND_PREFIX[kind]}-${datasetId}-${entityId}`;
29
+ }
30
+
31
+ export function anchorSelector(kind: NonVerbalKind, datasetId: string, entityId: string): string {
32
+ return `#${CSS.escape(anchorId(kind, datasetId, entityId))}`;
33
+ }
34
+
35
+ export interface ParsedAnchor {
36
+ kind: NonVerbalKind;
37
+ datasetId: string;
38
+ entityId: string;
39
+ }
40
+
41
+ const PARSER_RE = /^(figure|table|formula)-(.+)-(.+)$/;
42
+
43
+ export function parseAnchorId(id: string): ParsedAnchor | null {
44
+ const m = id.match(PARSER_RE);
45
+ if (!m) return null;
46
+ return { kind: m[1] as NonVerbalKind, datasetId: m[2], entityId: m[3] };
47
+ }
48
+
49
+ export function hrefFromAnchor(id: string): string {
50
+ return `#${id}`;
51
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Cross-reference highlight utility — shared by the click handler and the
3
+ * router scroll guard. Both call `highlightEntity()` after scrolling into
4
+ * view; the user sees a brief, accessible focus ring.
5
+ *
6
+ * Honors prefers-reduced-motion: the highlight class is added either way
7
+ * (it's a state indicator), but the smooth-scroll behavior is gated
8
+ * upstream by `useReducedMotion`.
9
+ */
10
+
11
+ const HIGHLIGHT_CLASS = 'nv-entity--highlighted';
12
+ const HIGHLIGHT_DURATION_MS = 1600;
13
+
14
+ export function highlightEntity(el: HTMLElement | null): void {
15
+ if (!el) return;
16
+ el.classList.add(HIGHLIGHT_CLASS);
17
+ el.setAttribute('tabindex', '-1');
18
+ el.focus({ preventScroll: true });
19
+ window.setTimeout(() => {
20
+ el.classList.remove(HIGHLIGHT_CLASS);
21
+ }, HIGHLIGHT_DURATION_MS);
22
+ }
23
+
24
+ export function scrollToEntity(el: HTMLElement | null, smooth: boolean): void {
25
+ if (!el) return;
26
+ el.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto', block: 'start' });
27
+ }