@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
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { createRouter, createMemoryHistory, type Router } from 'vue-router';
|
|
3
|
+
import { defineComponent, h } from 'vue';
|
|
4
|
+
|
|
5
|
+
vi.mock('../utils/non-verbal-highlight', () => ({
|
|
6
|
+
scrollToEntity: vi.fn(),
|
|
7
|
+
highlightEntity: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
import { scrollToEntity, highlightEntity } from '../utils/non-verbal-highlight';
|
|
11
|
+
import { installNonVerbalScroll } from '../router/non-verbal-scroll-guard';
|
|
12
|
+
|
|
13
|
+
const Stub = defineComponent({ render: () => h('div') });
|
|
14
|
+
|
|
15
|
+
function makeRouter(): Router {
|
|
16
|
+
return createRouter({
|
|
17
|
+
history: createMemoryHistory(),
|
|
18
|
+
routes: [
|
|
19
|
+
{ path: '/', name: 'home', component: Stub },
|
|
20
|
+
{ path: '/concept/:id', name: 'concept', component: Stub },
|
|
21
|
+
],
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('installNonVerbalScroll', () => {
|
|
26
|
+
let matchMediaSpy: ReturnType<typeof vi.spyOn>;
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
vi.useFakeTimers();
|
|
30
|
+
vi.mocked(scrollToEntity).mockClear();
|
|
31
|
+
vi.mocked(highlightEntity).mockClear();
|
|
32
|
+
vi.mocked(scrollToEntity).mockImplementation(() => undefined);
|
|
33
|
+
vi.mocked(highlightEntity).mockImplementation(() => undefined);
|
|
34
|
+
matchMediaSpy = vi.spyOn(window, 'matchMedia').mockReturnValue({
|
|
35
|
+
matches: false,
|
|
36
|
+
addEventListener: () => undefined,
|
|
37
|
+
removeEventListener: () => undefined,
|
|
38
|
+
media: '',
|
|
39
|
+
addListener: () => undefined,
|
|
40
|
+
removeListener: () => undefined,
|
|
41
|
+
dispatchEvent: () => false,
|
|
42
|
+
onchange: null,
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
vi.useRealTimers();
|
|
48
|
+
vi.restoreAllMocks();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('scrolls and highlights when navigating to an entity hash', async () => {
|
|
52
|
+
const router = makeRouter();
|
|
53
|
+
installNonVerbalScroll(router);
|
|
54
|
+
|
|
55
|
+
const target = document.createElement('figure');
|
|
56
|
+
target.id = 'figure-ds-foo';
|
|
57
|
+
document.body.appendChild(target);
|
|
58
|
+
|
|
59
|
+
await router.push('/concept/1');
|
|
60
|
+
await router.push({ path: '/concept/1', hash: '#figure-ds-foo' });
|
|
61
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
62
|
+
|
|
63
|
+
expect(scrollToEntity).toHaveBeenCalledWith(target, true);
|
|
64
|
+
expect(highlightEntity).toHaveBeenCalledWith(target);
|
|
65
|
+
|
|
66
|
+
document.body.removeChild(target);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('passes smooth=false when the user prefers reduced motion', async () => {
|
|
70
|
+
matchMediaSpy.mockReturnValue({
|
|
71
|
+
matches: true,
|
|
72
|
+
addEventListener: () => undefined,
|
|
73
|
+
removeEventListener: () => undefined,
|
|
74
|
+
media: '',
|
|
75
|
+
addListener: () => undefined,
|
|
76
|
+
removeListener: () => undefined,
|
|
77
|
+
dispatchEvent: () => false,
|
|
78
|
+
onchange: null,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const router = makeRouter();
|
|
82
|
+
installNonVerbalScroll(router);
|
|
83
|
+
|
|
84
|
+
const target = document.createElement('figure');
|
|
85
|
+
target.id = 'figure-ds-reduced';
|
|
86
|
+
document.body.appendChild(target);
|
|
87
|
+
|
|
88
|
+
await router.push({ path: '/concept/2', hash: '#figure-ds-reduced' });
|
|
89
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
90
|
+
|
|
91
|
+
expect(scrollToEntity).toHaveBeenCalledWith(target, false);
|
|
92
|
+
|
|
93
|
+
document.body.removeChild(target);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('ignores hashes that are not entity anchors', async () => {
|
|
97
|
+
const router = makeRouter();
|
|
98
|
+
installNonVerbalScroll(router);
|
|
99
|
+
|
|
100
|
+
await router.push({ path: '/concept/3', hash: '#some-other-hash' });
|
|
101
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
102
|
+
|
|
103
|
+
expect(scrollToEntity).not.toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('gives up after the polling timeout if the target never appears', async () => {
|
|
107
|
+
const router = makeRouter();
|
|
108
|
+
installNonVerbalScroll(router, { timeoutMs: 60 });
|
|
109
|
+
|
|
110
|
+
await router.push({ path: '/concept/4', hash: '#figure-ds-never' });
|
|
111
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
112
|
+
|
|
113
|
+
expect(scrollToEntity).not.toHaveBeenCalled();
|
|
114
|
+
expect(highlightEntity).not.toHaveBeenCalled();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { Concept } from 'glossarist';
|
|
3
|
+
import { computed } from 'vue';
|
|
4
|
+
import { useConceptEntities } from '../composables/use-concept-entities';
|
|
5
|
+
|
|
6
|
+
function makeConcept(args: {
|
|
7
|
+
figures?: unknown[];
|
|
8
|
+
tables?: unknown[];
|
|
9
|
+
formulas?: unknown[];
|
|
10
|
+
}): Concept {
|
|
11
|
+
return Concept.fromJSON({
|
|
12
|
+
id: '2-1-145',
|
|
13
|
+
localizations: { eng: { language_code: 'eng' } },
|
|
14
|
+
figures: args.figures ?? [],
|
|
15
|
+
tables: args.tables ?? [],
|
|
16
|
+
formulas: args.formulas ?? [],
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('useConceptEntities', () => {
|
|
21
|
+
it('returns an empty list when the concept has no structural refs', () => {
|
|
22
|
+
const concept = computed(() => makeConcept({}));
|
|
23
|
+
const datasetId = computed(() => 'iala-2023');
|
|
24
|
+
const refs = useConceptEntities(concept, datasetId);
|
|
25
|
+
expect(refs.value).toEqual([]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('pulls figure refs from concept.figures', () => {
|
|
29
|
+
const concept = computed(() => makeConcept({
|
|
30
|
+
figures: [{ ref: 'mixed-reflection' }],
|
|
31
|
+
}));
|
|
32
|
+
const refs = useConceptEntities(concept, computed(() => 'iala-2023'));
|
|
33
|
+
expect(refs.value).toHaveLength(1);
|
|
34
|
+
expect(refs.value[0]).toMatchObject({
|
|
35
|
+
kind: 'figure',
|
|
36
|
+
entityId: 'mixed-reflection',
|
|
37
|
+
anchor: 'figure-iala-2023-mixed-reflection',
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('pulls table and formula refs alongside figures', () => {
|
|
42
|
+
const concept = computed(() => makeConcept({
|
|
43
|
+
figures: [{ ref: 'fig-1' }],
|
|
44
|
+
tables: [{ ref: 'tbl-1' }],
|
|
45
|
+
formulas: [{ ref: 'fml-1' }],
|
|
46
|
+
}));
|
|
47
|
+
const refs = useConceptEntities(concept, computed(() => 'ds'));
|
|
48
|
+
const kinds = refs.value.map(r => r.kind);
|
|
49
|
+
expect(kinds).toEqual(expect.arrayContaining(['figure', 'table', 'formula']));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('preserves display override', () => {
|
|
53
|
+
const concept = computed(() => makeConcept({
|
|
54
|
+
figures: [{ ref: 'dispersion-prism', display: 'Figure 3' }],
|
|
55
|
+
}));
|
|
56
|
+
const refs = useConceptEntities(concept, computed(() => 'ds'));
|
|
57
|
+
expect(refs.value[0].display).toBe('Figure 3');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('sets display to null when no override is provided', () => {
|
|
61
|
+
const concept = computed(() => makeConcept({
|
|
62
|
+
figures: [{ ref: 'foo' }],
|
|
63
|
+
}));
|
|
64
|
+
const refs = useConceptEntities(concept, computed(() => 'ds'));
|
|
65
|
+
expect(refs.value[0].display).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('recomputes when datasetId changes', () => {
|
|
69
|
+
const concept = computed(() => makeConcept({
|
|
70
|
+
figures: [{ ref: 'foo' }],
|
|
71
|
+
}));
|
|
72
|
+
const datasetId = computed(() => 'first');
|
|
73
|
+
const refs = useConceptEntities(concept, datasetId);
|
|
74
|
+
expect(refs.value[0].anchor).toBe('figure-first-foo');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { BibliographyData, type BibliographyEntry } from 'glossarist';
|
|
2
|
+
|
|
3
|
+
export interface BibliographyAdapterOptions {
|
|
4
|
+
basePath?: string;
|
|
5
|
+
fetcher?: (url: string) => Promise<Response>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class BibliographyAdapter {
|
|
9
|
+
private data: BibliographyData | null = null;
|
|
10
|
+
private loaded = false;
|
|
11
|
+
private readonly basePath: string;
|
|
12
|
+
private readonly fetcher: (url: string) => Promise<Response>;
|
|
13
|
+
|
|
14
|
+
constructor(
|
|
15
|
+
private readonly datasetId: string,
|
|
16
|
+
opts: BibliographyAdapterOptions = {},
|
|
17
|
+
) {
|
|
18
|
+
this.basePath = opts.basePath ?? import.meta.env.BASE_URL ?? '/';
|
|
19
|
+
this.fetcher = opts.fetcher ?? ((url: string) => fetch(url));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async load(): Promise<void> {
|
|
23
|
+
if (this.loaded) return;
|
|
24
|
+
try {
|
|
25
|
+
const resp = await this.fetcher(`${this.basePath}data/${this.datasetId}/bibliography.json`);
|
|
26
|
+
if (resp.ok) {
|
|
27
|
+
const json = await resp.json();
|
|
28
|
+
this.data = BibliographyData.fromJSON(json);
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
// Honest failure: loaded=true prevents retry storms; findById returns null.
|
|
32
|
+
} finally {
|
|
33
|
+
this.loaded = true;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
findById(id: string): BibliographyEntry | null {
|
|
38
|
+
return this.data?.find(id) ?? null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
all(): BibliographyEntry[] {
|
|
42
|
+
return this.data?.entries ?? [];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
clear(): void {
|
|
46
|
+
this.data = null;
|
|
47
|
+
this.loaded = false;
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/adapters/factory.ts
CHANGED
|
@@ -3,16 +3,30 @@ import type { RoutingEntry as ConfigRoutingEntry } from '../config/types';
|
|
|
3
3
|
import { DatasetAdapter } from './DatasetAdapter';
|
|
4
4
|
import { ReferenceResolver } from './ReferenceResolver';
|
|
5
5
|
import { UriRouter } from './UriRouter';
|
|
6
|
+
import { NonVerbalEntityResolver } from './non-verbal-resolver';
|
|
7
|
+
import { BibliographyAdapter } from './bibliography-adapter';
|
|
6
8
|
|
|
7
9
|
export class AdapterFactory {
|
|
8
10
|
private adapters = new Map<string, DatasetAdapter>();
|
|
11
|
+
private bibliographyAdapters = new Map<string, BibliographyAdapter>();
|
|
9
12
|
private crossRefIndex: Record<string, string[]> | null = null;
|
|
10
13
|
readonly uriRouter: UriRouter;
|
|
11
14
|
readonly resolver: ReferenceResolver;
|
|
15
|
+
readonly nonVerbalResolver: NonVerbalEntityResolver;
|
|
12
16
|
|
|
13
17
|
constructor() {
|
|
14
18
|
this.uriRouter = new UriRouter();
|
|
15
19
|
this.resolver = new ReferenceResolver(this.uriRouter);
|
|
20
|
+
this.nonVerbalResolver = new NonVerbalEntityResolver();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
bibliography(datasetId: string): BibliographyAdapter {
|
|
24
|
+
let a = this.bibliographyAdapters.get(datasetId);
|
|
25
|
+
if (!a) {
|
|
26
|
+
a = new BibliographyAdapter(datasetId);
|
|
27
|
+
this.bibliographyAdapters.set(datasetId, a);
|
|
28
|
+
}
|
|
29
|
+
return a;
|
|
16
30
|
}
|
|
17
31
|
|
|
18
32
|
async discoverDatasets(datasetsUrl: string): Promise<DatasetAdapter[]> {
|
|
@@ -169,6 +169,9 @@ interface JsonLdConcept {
|
|
|
169
169
|
'gl:localizedConcept'?: Record<string, JsonLdLocalizedConcept>;
|
|
170
170
|
'gl:related'?: JsonLdRelated[];
|
|
171
171
|
'gl:tags'?: string[];
|
|
172
|
+
'gl:figureRef'?: unknown[];
|
|
173
|
+
'gl:tableRef'?: unknown[];
|
|
174
|
+
'gl:formulaRef'?: unknown[];
|
|
172
175
|
}
|
|
173
176
|
|
|
174
177
|
// ── Bridges for fields not yet in glossarist-js ────────────────────────────
|
|
@@ -540,6 +543,9 @@ function conceptFromJsonLd(doc: JsonLdConcept): Concept {
|
|
|
540
543
|
localizations,
|
|
541
544
|
related,
|
|
542
545
|
tags,
|
|
546
|
+
figures: normalizeEntityRefs(doc['gl:figureRef']),
|
|
547
|
+
tables: normalizeEntityRefs(doc['gl:tableRef']),
|
|
548
|
+
formulas: normalizeEntityRefs(doc['gl:formulaRef']),
|
|
543
549
|
status: null,
|
|
544
550
|
});
|
|
545
551
|
|
|
@@ -547,6 +553,51 @@ function conceptFromJsonLd(doc: JsonLdConcept): Concept {
|
|
|
547
553
|
return concept;
|
|
548
554
|
}
|
|
549
555
|
|
|
556
|
+
/**
|
|
557
|
+
* Normalize JSON-LD structural entity refs (`gl:figureRef` / `gl:tableRef`
|
|
558
|
+
* / `gl:formulaRef`) into the shape `NonVerbalReference.fromJSON` expects.
|
|
559
|
+
*
|
|
560
|
+
* Accepts three wire forms — bare string ID, `{ "@id": "../kind/foo" }`,
|
|
561
|
+
* or `{ "@id": "../kind/foo", "gl:display": "Figure 3" }` — and emits the
|
|
562
|
+
* canonical `{ ref, display? }` shape. The path's last segment is the
|
|
563
|
+
* entity id; the field name (`figureRef` vs `tableRef` vs `formulaRef`)
|
|
564
|
+
* is the kind discriminator upstream.
|
|
565
|
+
*/
|
|
566
|
+
function normalizeEntityRefs(raw: unknown): unknown[] {
|
|
567
|
+
if (!Array.isArray(raw)) return [];
|
|
568
|
+
return raw.map(normalizeOneEntityRef).filter((v): v is Record<string, string> => v !== null);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function normalizeOneEntityRef(entry: unknown): Record<string, string> | null {
|
|
572
|
+
if (typeof entry === 'string') {
|
|
573
|
+
const trimmed = entry.trim();
|
|
574
|
+
return trimmed ? { ref: trimmed } : null;
|
|
575
|
+
}
|
|
576
|
+
if (!entry || typeof entry !== 'object') return null;
|
|
577
|
+
const obj = entry as Record<string, unknown>;
|
|
578
|
+
const atId = typeof obj['@id'] === 'string' ? (obj['@id'] as string) : null;
|
|
579
|
+
const explicitRef = typeof obj.ref === 'string' ? obj.ref
|
|
580
|
+
: typeof obj.entityId === 'string' ? obj.entityId
|
|
581
|
+
: typeof obj.entity_id === 'string' ? obj.entity_id
|
|
582
|
+
: null;
|
|
583
|
+
const entityId = (atId ? lastPathSegment(atId) : null) ?? explicitRef;
|
|
584
|
+
if (!entityId) return null;
|
|
585
|
+
const out: Record<string, string> = { ref: entityId };
|
|
586
|
+
const display = typeof obj['gl:display'] === 'string' ? obj['gl:display']
|
|
587
|
+
: typeof obj['gloss:display'] === 'string' ? obj['gloss:display']
|
|
588
|
+
: typeof obj.display === 'string' ? obj.display
|
|
589
|
+
: null;
|
|
590
|
+
if (display) out.display = display;
|
|
591
|
+
return out;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function lastPathSegment(p: string): string | null {
|
|
595
|
+
const cleaned = p.replace(/[?#].*$/, '').replace(/\/+$/, '');
|
|
596
|
+
const segments = cleaned.split('/');
|
|
597
|
+
const last = segments[segments.length - 1];
|
|
598
|
+
return last ? decodeURIComponent(last) : null;
|
|
599
|
+
}
|
|
600
|
+
|
|
550
601
|
// ── Public API ────────────────────────────────────────────────────────────
|
|
551
602
|
|
|
552
603
|
export function conceptFromJson(doc: Record<string, unknown>): Concept {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Figure bridge — JSON-LD → Figure (TS model).
|
|
3
|
+
*
|
|
4
|
+
* Wire format (per task 11 + glossarist-ruby's planned export):
|
|
5
|
+
*
|
|
6
|
+
* {
|
|
7
|
+
* "@id": "https://glossarist.org/{ds}/figure/{id}",
|
|
8
|
+
* "@type": "gl:Figure" | "gloss:Figure",
|
|
9
|
+
* "gl:id": "{id}",
|
|
10
|
+
* "gl:identifier": "Figure 7c", // plain string
|
|
11
|
+
* "gl:caption": { "eng": "...", "fra": "..." },
|
|
12
|
+
* "gl:altText": { "eng": "..." }, // mapped to model.alt
|
|
13
|
+
* "gl:description": { "eng": "..." },
|
|
14
|
+
* "gl:image": [
|
|
15
|
+
* { "gl:src": "x.png", "gl:format": "png", "gl:role": "raster",
|
|
16
|
+
* "gl:width": 1600, "gl:height": 1200, "gl:scale": 1 }
|
|
17
|
+
* ],
|
|
18
|
+
* "gl:subfigure": [ ... recursive Figure docs ... ],
|
|
19
|
+
* "gl:source": [ ... NonVerbalSource docs ... ]
|
|
20
|
+
* }
|
|
21
|
+
*
|
|
22
|
+
* The wire field `gl:altText` is mapped to the model field `alt` to avoid
|
|
23
|
+
* ambiguity with the HTML `<img alt>` attribute.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { Figure, FigureImage, FigureImageFormat, FigureImageRole } from './types';
|
|
27
|
+
import { isType, pickField, pickFieldArray, pickFieldRecord, localized } from './prefix';
|
|
28
|
+
import { sourcesFromJsonLd } from './source-bridge';
|
|
29
|
+
|
|
30
|
+
const FORMAT_SET: ReadonlySet<string> = new Set(['svg', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'avif']);
|
|
31
|
+
|
|
32
|
+
const ROLE_SET: ReadonlySet<string> = new Set(['vector', 'raster', 'dark', 'light', 'print']);
|
|
33
|
+
|
|
34
|
+
function imageFromJsonLd(raw: Record<string, unknown>): FigureImage | null {
|
|
35
|
+
const src = pickField<string>(raw, 'src');
|
|
36
|
+
if (!src) return null;
|
|
37
|
+
const formatRaw = (pickField<string>(raw, 'format') ?? '').toLowerCase();
|
|
38
|
+
const format = (FORMAT_SET.has(formatRaw) ? formatRaw : 'svg') as FigureImageFormat;
|
|
39
|
+
const roleRaw = pickField<string>(raw, 'role');
|
|
40
|
+
const role = roleRaw && ROLE_SET.has(roleRaw) ? (roleRaw as FigureImageRole) : undefined;
|
|
41
|
+
const width = pickField<number>(raw, 'width');
|
|
42
|
+
const height = pickField<number>(raw, 'height');
|
|
43
|
+
const scale = pickField<number>(raw, 'scale');
|
|
44
|
+
const img: FigureImage = { src, format };
|
|
45
|
+
if (role) img.role = role;
|
|
46
|
+
if (typeof width === 'number') img.width = width;
|
|
47
|
+
if (typeof height === 'number') img.height = height;
|
|
48
|
+
if (typeof scale === 'number') img.scale = scale;
|
|
49
|
+
return img;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function imagesFromJsonLd(raw: unknown): FigureImage[] {
|
|
53
|
+
if (!Array.isArray(raw)) return [];
|
|
54
|
+
const out: FigureImage[] = [];
|
|
55
|
+
for (const entry of raw) {
|
|
56
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
57
|
+
const img = imageFromJsonLd(entry as Record<string, unknown>);
|
|
58
|
+
if (img) out.push(img);
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function subfiguresFromJsonLd(raw: unknown): Figure[] | undefined {
|
|
64
|
+
if (!Array.isArray(raw)) return undefined;
|
|
65
|
+
const out: Figure[] = [];
|
|
66
|
+
for (const entry of raw) {
|
|
67
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
68
|
+
const sub = figureFromJsonLd(entry as Record<string, unknown>);
|
|
69
|
+
if (sub) out.push(sub);
|
|
70
|
+
}
|
|
71
|
+
return out.length ? out : undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function figureFromJsonLd(doc: Record<string, unknown>): Figure | null {
|
|
75
|
+
if (!isType(doc, 'Figure')) return null;
|
|
76
|
+
|
|
77
|
+
const id = pickField<string>(doc, 'id') ?? '';
|
|
78
|
+
if (!id) return null;
|
|
79
|
+
|
|
80
|
+
const identifier = pickField<string>(doc, 'identifier');
|
|
81
|
+
const caption = localized(doc, 'caption');
|
|
82
|
+
const alt = localized(doc, 'altText');
|
|
83
|
+
const description = localized(doc, 'description');
|
|
84
|
+
const images = imagesFromJsonLd(pickFieldArray(doc, 'image'));
|
|
85
|
+
const subfigures = subfiguresFromJsonLd(pickField(doc, 'subfigure'));
|
|
86
|
+
const sources = sourcesFromJsonLd(pickField(doc, 'source'));
|
|
87
|
+
|
|
88
|
+
const fig: Figure = { kind: 'figure', id, images };
|
|
89
|
+
if (identifier) fig.identifier = identifier;
|
|
90
|
+
if (caption) fig.caption = caption;
|
|
91
|
+
if (alt) fig.alt = alt;
|
|
92
|
+
if (description) fig.description = description;
|
|
93
|
+
if (subfigures) fig.subfigures = subfigures;
|
|
94
|
+
if (sources.length) fig.sources = sources;
|
|
95
|
+
|
|
96
|
+
// pickFieldRecord is unused for figures; keep the import meaningful by
|
|
97
|
+
// ensuring no stray image fields leak through (silent no-op).
|
|
98
|
+
void pickFieldRecord;
|
|
99
|
+
|
|
100
|
+
return fig;
|
|
101
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formula bridge — JSON-LD → Formula (TS model).
|
|
3
|
+
*
|
|
4
|
+
* Wire format:
|
|
5
|
+
*
|
|
6
|
+
* {
|
|
7
|
+
* "@type": "gl:Formula",
|
|
8
|
+
* "gl:id": "{id}",
|
|
9
|
+
* "gl:identifier": "Formula 5",
|
|
10
|
+
* "gl:caption": { ... },
|
|
11
|
+
* "gl:description": { ... },
|
|
12
|
+
* "gl:expression": { "eng": "E = mc^2", "fra": "E = mc^2" },
|
|
13
|
+
* "gl:notation": "latex" | "mathml" | "asciimath",
|
|
14
|
+
* "gl:source": [ ... ]
|
|
15
|
+
* }
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { Formula, FormulaNotation } from './types';
|
|
19
|
+
import { isType, pickField, localized } from './prefix';
|
|
20
|
+
import { sourcesFromJsonLd } from './source-bridge';
|
|
21
|
+
|
|
22
|
+
const NOTATION_SET: ReadonlySet<string> = new Set(['latex', 'mathml', 'asciimath']);
|
|
23
|
+
|
|
24
|
+
export function formulaFromJsonLd(doc: Record<string, unknown>): Formula | null {
|
|
25
|
+
if (!isType(doc, 'Formula')) return null;
|
|
26
|
+
|
|
27
|
+
const id = pickField<string>(doc, 'id') ?? '';
|
|
28
|
+
if (!id) return null;
|
|
29
|
+
|
|
30
|
+
const expression = localized(doc, 'expression');
|
|
31
|
+
if (!expression) return null;
|
|
32
|
+
|
|
33
|
+
const notationRaw = (pickField<string>(doc, 'notation') ?? '').toLowerCase();
|
|
34
|
+
const notation = NOTATION_SET.has(notationRaw) ? (notationRaw as FormulaNotation) : 'latex';
|
|
35
|
+
|
|
36
|
+
const identifier = pickField<string>(doc, 'identifier');
|
|
37
|
+
const caption = localized(doc, 'caption');
|
|
38
|
+
const description = localized(doc, 'description');
|
|
39
|
+
const sources = sourcesFromJsonLd(pickField(doc, 'source'));
|
|
40
|
+
|
|
41
|
+
const f: Formula = { kind: 'formula', id, expression, notation };
|
|
42
|
+
if (identifier) f.identifier = identifier;
|
|
43
|
+
if (caption) f.caption = caption;
|
|
44
|
+
if (description) f.description = description;
|
|
45
|
+
if (sources.length) f.sources = sources;
|
|
46
|
+
|
|
47
|
+
return f;
|
|
48
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public API for the non-verbal entity model layer.
|
|
3
|
+
*
|
|
4
|
+
* Re-exports the types, bridges, and dispatch table. Components and
|
|
5
|
+
* composables import from here — never from individual files — so the
|
|
6
|
+
* internal layout can evolve without breaking the public surface.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type {
|
|
10
|
+
LocalizedString,
|
|
11
|
+
NonVerbalKind,
|
|
12
|
+
FigureImage,
|
|
13
|
+
FigureImageFormat,
|
|
14
|
+
FigureImageRole,
|
|
15
|
+
NonVerbalSource,
|
|
16
|
+
NonVerbalSourceOrigin,
|
|
17
|
+
NonVerbalSourceRef,
|
|
18
|
+
NonVerbalSourceLocality,
|
|
19
|
+
NonVerbalEntityBase,
|
|
20
|
+
Figure,
|
|
21
|
+
Table,
|
|
22
|
+
TableContent,
|
|
23
|
+
TableFormat,
|
|
24
|
+
Formula,
|
|
25
|
+
FormulaNotation,
|
|
26
|
+
NonVerbalEntity,
|
|
27
|
+
NonVerbRepV3,
|
|
28
|
+
NonVerbalReference,
|
|
29
|
+
} from './types';
|
|
30
|
+
|
|
31
|
+
export { isFigure, isTable, isFormula } from './types';
|
|
32
|
+
|
|
33
|
+
export { figureFromJsonLd } from './figure-bridge';
|
|
34
|
+
export { tableFromJsonLd } from './table-bridge';
|
|
35
|
+
export { formulaFromJsonLd } from './formula-bridge';
|
|
36
|
+
|
|
37
|
+
export {
|
|
38
|
+
KIND_TO_DIR,
|
|
39
|
+
KIND_TO_TYPE_FIELD,
|
|
40
|
+
KIND_TO_BRIDGE,
|
|
41
|
+
ALL_KINDS,
|
|
42
|
+
MENTION_KIND_TO_ENTITY_KIND,
|
|
43
|
+
kindFromType,
|
|
44
|
+
entityKindFromMentionKind,
|
|
45
|
+
} from './kind';
|
|
46
|
+
|
|
47
|
+
export type { BridgeFn } from './kind';
|
|
48
|
+
|
|
49
|
+
export {
|
|
50
|
+
pickField,
|
|
51
|
+
pickFieldArray,
|
|
52
|
+
pickFieldRecord,
|
|
53
|
+
isType,
|
|
54
|
+
localized,
|
|
55
|
+
} from './prefix';
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { NonVerbalEntity, NonVerbalKind } from './types';
|
|
2
|
+
import {
|
|
3
|
+
ENTITY_DIRECTORIES,
|
|
4
|
+
ENTITY_TYPES,
|
|
5
|
+
} from 'glossarist';
|
|
6
|
+
import { figureFromJsonLd } from './figure-bridge';
|
|
7
|
+
import { tableFromJsonLd } from './table-bridge';
|
|
8
|
+
import { formulaFromJsonLd } from './formula-bridge';
|
|
9
|
+
|
|
10
|
+
export type BridgeFn = (doc: Record<string, unknown>) => NonVerbalEntity | null;
|
|
11
|
+
|
|
12
|
+
export const KIND_TO_DIR: Readonly<Record<NonVerbalKind, string>> = Object.freeze(
|
|
13
|
+
Object.fromEntries(ENTITY_DIRECTORIES),
|
|
14
|
+
) as Readonly<Record<NonVerbalKind, string>>;
|
|
15
|
+
|
|
16
|
+
export const KIND_TO_TYPE_FIELD: Readonly<Record<NonVerbalKind, string>> = {
|
|
17
|
+
figure: 'Figure',
|
|
18
|
+
table: 'Table',
|
|
19
|
+
formula: 'Formula',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const KIND_TO_BRIDGE: Readonly<Record<NonVerbalKind, BridgeFn>> = {
|
|
23
|
+
figure: figureFromJsonLd,
|
|
24
|
+
table: tableFromJsonLd,
|
|
25
|
+
formula: formulaFromJsonLd,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const ALL_KINDS: readonly NonVerbalKind[] = ENTITY_TYPES as readonly NonVerbalKind[];
|
|
29
|
+
|
|
30
|
+
export const MENTION_KIND_TO_ENTITY_KIND: Readonly<Record<string, NonVerbalKind>> = {
|
|
31
|
+
'fig-ref': 'figure',
|
|
32
|
+
'table-ref': 'table',
|
|
33
|
+
'formula-ref': 'formula',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function kindFromType(typeField: string): NonVerbalKind | null {
|
|
37
|
+
const bare = typeField.replace(/^(gl|gloss):/, '');
|
|
38
|
+
for (const k of ALL_KINDS) {
|
|
39
|
+
if (bare === KIND_TO_TYPE_FIELD[k]) return k;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function entityKindFromMentionKind(mentionKind: string): NonVerbalKind | null {
|
|
45
|
+
return MENTION_KIND_TO_ENTITY_KIND[mentionKind] ?? null;
|
|
46
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vocabulary prefix helper.
|
|
3
|
+
*
|
|
4
|
+
* The concept-browser JSON-LD corpus uses the `gl:` prefix. glossarist-ruby's
|
|
5
|
+
* `glossarist export` is planned to emit `gloss:` prefix. Until the
|
|
6
|
+
* cross-repo vocabulary issue is resolved (see AUDIT.figures.md §"Open
|
|
7
|
+
* issue: vocabulary prefix"), bridges must accept both prefixes.
|
|
8
|
+
*
|
|
9
|
+
* `pickField` is the single accessor every bridge uses — centralizing the
|
|
10
|
+
* dual-prefix handling here means removing the legacy prefix later is a
|
|
11
|
+
* one-file change.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const PREFIXES = ['gl', 'gloss'] as const;
|
|
15
|
+
|
|
16
|
+
export function pickField<T = unknown>(
|
|
17
|
+
doc: Record<string, unknown>,
|
|
18
|
+
field: string,
|
|
19
|
+
): T | undefined {
|
|
20
|
+
for (const p of PREFIXES) {
|
|
21
|
+
const k = `${p}:${field}`;
|
|
22
|
+
if (doc[k] !== undefined) return doc[k] as T;
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function pickFieldArray<T = unknown>(
|
|
28
|
+
doc: Record<string, unknown>,
|
|
29
|
+
field: string,
|
|
30
|
+
): T[] {
|
|
31
|
+
const v = pickField<T[]>(doc, field);
|
|
32
|
+
return Array.isArray(v) ? v : [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function pickFieldRecord<V = unknown>(
|
|
36
|
+
doc: Record<string, unknown>,
|
|
37
|
+
field: string,
|
|
38
|
+
): Record<string, V> | undefined {
|
|
39
|
+
const v = pickField<unknown>(doc, field);
|
|
40
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
|
41
|
+
return v as Record<string, V>;
|
|
42
|
+
}
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isType(doc: Record<string, unknown>, typeShort: string): boolean {
|
|
47
|
+
const t = doc['@type'];
|
|
48
|
+
if (typeof t !== 'string') return false;
|
|
49
|
+
for (const p of PREFIXES) {
|
|
50
|
+
if (t === `${p}:${typeShort}`) return true;
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function localized(doc: Record<string, unknown>, field: string): Record<string, string> | undefined {
|
|
56
|
+
const v = pickField<unknown>(doc, field);
|
|
57
|
+
if (!v) return undefined;
|
|
58
|
+
if (typeof v === 'string') return { eng: v };
|
|
59
|
+
if (typeof v === 'object' && !Array.isArray(v)) {
|
|
60
|
+
const out: Record<string, string> = {};
|
|
61
|
+
for (const [k, val] of Object.entries(v)) {
|
|
62
|
+
if (typeof val === 'string') out[k] = val;
|
|
63
|
+
}
|
|
64
|
+
return Object.keys(out).length ? out : undefined;
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|