@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.
- package/package.json +2 -2
- package/scripts/generate-data.mjs +20 -11
- package/scripts/lib/build/image-assets.mjs +190 -0
- package/scripts/lib/build/non-verbal-consumer.mjs +221 -0
- package/src/__tests__/bibliography-adapter.test.ts +79 -0
- package/src/__tests__/content-renderer-nvr-mentions.test.ts +57 -0
- package/src/__tests__/locale.test.ts +46 -0
- package/src/__tests__/model-bridge-entity-refs.test.ts +114 -0
- package/src/__tests__/non-verbal-anchor.test.ts +33 -0
- package/src/__tests__/non-verbal-cross-ref.test.ts +146 -0
- package/src/__tests__/non-verbal-highlight.test.ts +56 -0
- package/src/__tests__/non-verbal-kind.test.ts +77 -0
- package/src/__tests__/non-verbal-list.test.ts +67 -0
- package/src/__tests__/non-verbal-rep-display.test.ts +85 -0
- package/src/__tests__/non-verbal-scroll-guard.test.ts +116 -0
- package/src/__tests__/use-concept-entities.test.ts +76 -0
- package/src/adapters/bibliography-adapter.ts +49 -0
- package/src/adapters/factory.ts +14 -0
- package/src/adapters/model-bridge.ts +51 -0
- package/src/adapters/non-verbal/figure-bridge.ts +101 -0
- package/src/adapters/non-verbal/formula-bridge.ts +48 -0
- package/src/adapters/non-verbal/index.ts +55 -0
- package/src/adapters/non-verbal/kind.ts +46 -0
- package/src/adapters/non-verbal/prefix.ts +67 -0
- package/src/adapters/non-verbal/source-bridge.ts +81 -0
- package/src/adapters/non-verbal/table-bridge.ts +98 -0
- package/src/adapters/non-verbal/types.ts +133 -0
- package/src/adapters/non-verbal-resolver.ts +101 -0
- package/src/components/ConceptDetail.vue +17 -4
- package/src/components/LanguageDetail.vue +0 -3
- package/src/components/NonVerbalRepDisplay.vue +82 -24
- package/src/components/figure/FigureDisplay.vue +132 -0
- package/src/components/figure/FigureImages.vue +111 -0
- package/src/components/figure/figure-image-pick.ts +56 -0
- package/src/components/figure/figure-layout.ts +26 -0
- package/src/components/formula/FormulaDisplay.vue +90 -0
- package/src/components/formula/FormulaExpression.vue +70 -0
- package/src/components/non-verbal/NonVerbalCaption.vue +104 -0
- package/src/components/non-verbal/NonVerbalFallback.vue +69 -0
- package/src/components/non-verbal/NonVerbalList.vue +118 -0
- package/src/components/non-verbal/NonVerbalSources.vue +61 -0
- package/src/components/table/TableDisplay.vue +99 -0
- package/src/components/table/TableMarkup.vue +63 -0
- package/src/components/table/TableStructured.vue +66 -0
- package/src/composables/use-concept-entities.ts +70 -0
- package/src/composables/use-non-verbal-cross-ref.ts +79 -0
- package/src/composables/use-non-verbal-entity.ts +58 -0
- package/src/composables/use-reduced-motion.ts +26 -0
- package/src/composables/use-render-options.ts +30 -33
- package/src/router/index.ts +3 -0
- package/src/router/non-verbal-scroll-guard.ts +56 -0
- package/src/style.css +17 -0
- package/src/utils/content-renderer.ts +76 -64
- package/src/utils/locale.ts +92 -0
- package/src/utils/non-verbal-anchor.ts +51 -0
- package/src/utils/non-verbal-highlight.ts +27 -0
|
@@ -1,55 +1,52 @@
|
|
|
1
|
-
import { ref
|
|
2
|
-
import type { RenderOptions, BibResolver,
|
|
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
|
|
9
|
+
const ready = ref(false);
|
|
29
10
|
|
|
30
11
|
async function ensureBibLoaded() {
|
|
31
12
|
const id = registerId();
|
|
32
13
|
if (!id) return;
|
|
33
|
-
|
|
14
|
+
await getFactory().bibliography(id).load();
|
|
15
|
+
ready.value = true;
|
|
34
16
|
}
|
|
35
17
|
|
|
36
18
|
const bibResolver: BibResolver = (refId, title) => {
|
|
37
|
-
const
|
|
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
|
|
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
|
|
28
|
+
return `<span class="bib-ref" title="${escapeAttr(entry.title ?? '')}">${escapeAttr(display)}</span>`;
|
|
46
29
|
};
|
|
47
30
|
|
|
48
|
-
const
|
|
31
|
+
const nonVerbalRefResolver: NonVerbalRefResolver = (kind: NonVerbalKind, entityId, display) => {
|
|
49
32
|
const id = registerId();
|
|
50
|
-
|
|
51
|
-
|
|
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 {
|
|
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
|
}
|
package/src/router/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
159
|
-
//
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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.
|
|
250
|
-
* 7.
|
|
251
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|