@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,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source bridge — converts JSON-LD source entries to NonVerbalSource.
|
|
3
|
+
*
|
|
4
|
+
* Shared by Figure, Table, Formula bridges (all three use the same source
|
|
5
|
+
* shape, mirroring glossarist's ConceptSource). One function, one shape.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { NonVerbalSource, NonVerbalSourceOrigin, NonVerbalSourceRef, NonVerbalSourceLocality } from './types';
|
|
9
|
+
import { pickField } from './prefix';
|
|
10
|
+
|
|
11
|
+
function refFromJsonLd(raw: Record<string, unknown> | string | undefined): NonVerbalSourceRef | undefined {
|
|
12
|
+
if (!raw) return undefined;
|
|
13
|
+
if (typeof raw === 'string') return { source: raw };
|
|
14
|
+
const r: NonVerbalSourceRef = {};
|
|
15
|
+
const source = pickField<string>(raw, 'source');
|
|
16
|
+
const id = pickField<string>(raw, 'id');
|
|
17
|
+
const version = pickField<string>(raw, 'version');
|
|
18
|
+
const text = pickField<string>(raw, 'text');
|
|
19
|
+
if (source) r.source = source;
|
|
20
|
+
if (id) r.id = id;
|
|
21
|
+
if (version) r.version = version;
|
|
22
|
+
if (text) r.text = text;
|
|
23
|
+
return Object.keys(r).length ? r : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function localityFromJsonLd(raw: Record<string, unknown> | undefined): NonVerbalSourceLocality | undefined {
|
|
27
|
+
if (!raw) return undefined;
|
|
28
|
+
const out: NonVerbalSourceLocality = {};
|
|
29
|
+
const t = pickField<string>(raw, 'localityType') ?? (raw.type as string | undefined);
|
|
30
|
+
const rf = pickField<string>(raw, 'referenceFrom') ?? (raw.reference_from as string | undefined);
|
|
31
|
+
const rt = pickField<string>(raw, 'referenceTo') ?? (raw.reference_to as string | undefined);
|
|
32
|
+
if (t) out.type = t;
|
|
33
|
+
if (rf) out.referenceFrom = rf;
|
|
34
|
+
if (rt) out.referenceTo = rt;
|
|
35
|
+
return Object.keys(out).length ? out : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function originFromJsonLd(raw: Record<string, unknown> | undefined): NonVerbalSourceOrigin | undefined {
|
|
39
|
+
if (!raw) return undefined;
|
|
40
|
+
const out: NonVerbalSourceOrigin = {};
|
|
41
|
+
const ref = refFromJsonLd(pickField<Record<string, unknown>>(raw, 'ref'));
|
|
42
|
+
const locality = localityFromJsonLd(pickField<Record<string, unknown>>(raw, 'locality'));
|
|
43
|
+
const link = pickField<string>(raw, 'link');
|
|
44
|
+
const id = pickField<string>(raw, 'id');
|
|
45
|
+
const version = pickField<string>(raw, 'version');
|
|
46
|
+
const source = pickField<string>(raw, 'source');
|
|
47
|
+
if (ref) out.ref = ref;
|
|
48
|
+
if (locality) out.locality = locality;
|
|
49
|
+
if (link) out.link = link;
|
|
50
|
+
if (id) out.id = id;
|
|
51
|
+
if (version) out.version = version;
|
|
52
|
+
if (source) out.source = source;
|
|
53
|
+
return Object.keys(out).length ? out : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function sourceFromJsonLd(raw: Record<string, unknown> | undefined): NonVerbalSource | undefined {
|
|
57
|
+
if (!raw) return undefined;
|
|
58
|
+
const out: NonVerbalSource = {};
|
|
59
|
+
const id = pickField<string>(raw, 'id');
|
|
60
|
+
const type = pickField<string>(raw, 'sourceType') ?? pickField<string>(raw, 'type');
|
|
61
|
+
const status = pickField<string>(raw, 'sourceStatus') ?? pickField<string>(raw, 'status');
|
|
62
|
+
const modification = pickField<string>(raw, 'modification');
|
|
63
|
+
const origin = originFromJsonLd(pickField<Record<string, unknown>>(raw, 'origin'));
|
|
64
|
+
if (id) out.id = id;
|
|
65
|
+
if (type) out.type = type;
|
|
66
|
+
if (status) out.status = status;
|
|
67
|
+
if (modification) out.modification = modification;
|
|
68
|
+
if (origin) out.origin = origin;
|
|
69
|
+
return Object.keys(out).length ? out : undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function sourcesFromJsonLd(raw: unknown): NonVerbalSource[] {
|
|
73
|
+
if (!Array.isArray(raw)) return [];
|
|
74
|
+
const out: NonVerbalSource[] = [];
|
|
75
|
+
for (const entry of raw) {
|
|
76
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
77
|
+
const s = sourceFromJsonLd(entry as Record<string, unknown>);
|
|
78
|
+
if (s) out.push(s);
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Table bridge — JSON-LD → Table (TS model).
|
|
3
|
+
*
|
|
4
|
+
* Wire format:
|
|
5
|
+
*
|
|
6
|
+
* {
|
|
7
|
+
* "@type": "gl:Table",
|
|
8
|
+
* "gl:id": "{id}",
|
|
9
|
+
* "gl:identifier": "Table 2",
|
|
10
|
+
* "gl:caption": { ... },
|
|
11
|
+
* "gl:description": { ... },
|
|
12
|
+
* "gl:content": {
|
|
13
|
+
* "gl:type": "structured" | "markup",
|
|
14
|
+
* "gl:headers": [ { "eng": "..." }, ... ], // structured
|
|
15
|
+
* "gl:rows": [ [ { "eng": "..." }, ... ], ... ], // structured
|
|
16
|
+
* "gl:markup": { "eng": "<table>...</table>" } // markup
|
|
17
|
+
* },
|
|
18
|
+
* "gl:format": "html" | "markdown" | "asciidoc",
|
|
19
|
+
* "gl:source": [ ... ]
|
|
20
|
+
* }
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { Table, TableContent, TableFormat } from './types';
|
|
24
|
+
import { isType, pickField, localized } from './prefix';
|
|
25
|
+
import { sourcesFromJsonLd } from './source-bridge';
|
|
26
|
+
|
|
27
|
+
const FORMAT_SET: ReadonlySet<string> = new Set(['html', 'markdown', 'asciidoc']);
|
|
28
|
+
|
|
29
|
+
function isLocalizedObj(v: unknown): v is Record<string, string> {
|
|
30
|
+
if (!v || typeof v !== 'object' || Array.isArray(v)) return false;
|
|
31
|
+
return Object.values(v).every(x => typeof x === 'string');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function contentFromJsonLd(raw: unknown): TableContent | null {
|
|
35
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
36
|
+
const c = raw as Record<string, unknown>;
|
|
37
|
+
const typeRaw = (pickField<string>(c, 'type') ?? '').toLowerCase();
|
|
38
|
+
|
|
39
|
+
if (typeRaw === 'markup') {
|
|
40
|
+
const markup = localized(c, 'markup');
|
|
41
|
+
if (!markup) return null;
|
|
42
|
+
return { kind: 'markup', markup };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (typeRaw === 'structured' || !typeRaw) {
|
|
46
|
+
const headersRaw = pickField<unknown[]>(c, 'headers');
|
|
47
|
+
const rowsRaw = pickField<unknown[]>(c, 'rows');
|
|
48
|
+
if (!Array.isArray(headersRaw) || !Array.isArray(rowsRaw)) return null;
|
|
49
|
+
|
|
50
|
+
const headers: Record<string, string>[] = [];
|
|
51
|
+
for (const h of headersRaw) {
|
|
52
|
+
if (isLocalizedObj(h)) headers.push(h);
|
|
53
|
+
}
|
|
54
|
+
if (headers.length === 0) return null;
|
|
55
|
+
|
|
56
|
+
const rows: Record<string, string>[][] = [];
|
|
57
|
+
for (const r of rowsRaw) {
|
|
58
|
+
if (!Array.isArray(r)) continue;
|
|
59
|
+
const cells: Record<string, string>[] = [];
|
|
60
|
+
for (const cell of r) {
|
|
61
|
+
if (isLocalizedObj(cell)) cells.push(cell);
|
|
62
|
+
}
|
|
63
|
+
if (cells.length) rows.push(cells);
|
|
64
|
+
}
|
|
65
|
+
if (rows.length === 0) return null;
|
|
66
|
+
|
|
67
|
+
return { kind: 'structured', headers, rows };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function tableFromJsonLd(doc: Record<string, unknown>): Table | null {
|
|
74
|
+
if (!isType(doc, 'Table')) return null;
|
|
75
|
+
|
|
76
|
+
const id = pickField<string>(doc, 'id') ?? '';
|
|
77
|
+
if (!id) return null;
|
|
78
|
+
|
|
79
|
+
const identifier = pickField<string>(doc, 'identifier');
|
|
80
|
+
const caption = localized(doc, 'caption');
|
|
81
|
+
const description = localized(doc, 'description');
|
|
82
|
+
const content = contentFromJsonLd(pickField(doc, 'content'));
|
|
83
|
+
if (!content) return null;
|
|
84
|
+
|
|
85
|
+
const formatRaw = (pickField<string>(doc, 'format') ?? '').toLowerCase();
|
|
86
|
+
const format = FORMAT_SET.has(formatRaw) ? (formatRaw as TableFormat) : undefined;
|
|
87
|
+
|
|
88
|
+
const sources = sourcesFromJsonLd(pickField(doc, 'source'));
|
|
89
|
+
|
|
90
|
+
const t: Table = { kind: 'table', id, content };
|
|
91
|
+
if (identifier) t.identifier = identifier;
|
|
92
|
+
if (caption) t.caption = caption;
|
|
93
|
+
if (description) t.description = description;
|
|
94
|
+
if (format) t.format = format;
|
|
95
|
+
if (sources.length) t.sources = sources;
|
|
96
|
+
|
|
97
|
+
return t;
|
|
98
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Non-verbal entity model — TypeScript projection of the authoritative
|
|
3
|
+
* glossarist-ruby model.
|
|
4
|
+
*
|
|
5
|
+
* The authoritative model lives in glossarist-ruby (Figure, Table, Formula
|
|
6
|
+
* inherit from NonVerbalEntity). This file mirrors that model in TypeScript
|
|
7
|
+
* for the consumer side. It does not redefine the model — every field here
|
|
8
|
+
* corresponds to a field in the authoritative source.
|
|
9
|
+
*
|
|
10
|
+
* See:
|
|
11
|
+
* ../glossarist-ruby/lib/glossarist/non_verbal_entity.rb
|
|
12
|
+
* ../glossarist-ruby/lib/glossarist/figure.rb
|
|
13
|
+
* ../glossarist-ruby/lib/glossarist/table.rb
|
|
14
|
+
* ../glossarist-ruby/lib/glossarist/formula.rb
|
|
15
|
+
* ../glossarist-ruby/lib/glossarist/figure_image.rb
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type LocalizedString = Record<string, string>;
|
|
19
|
+
|
|
20
|
+
export type NonVerbalKind = 'figure' | 'table' | 'formula';
|
|
21
|
+
|
|
22
|
+
export type FigureImageFormat = 'svg' | 'png' | 'jpg' | 'jpeg' | 'gif' | 'webp' | 'avif';
|
|
23
|
+
|
|
24
|
+
export type FigureImageRole = 'vector' | 'raster' | 'dark' | 'light' | 'print';
|
|
25
|
+
|
|
26
|
+
export interface FigureImage {
|
|
27
|
+
src: string;
|
|
28
|
+
format: FigureImageFormat;
|
|
29
|
+
role?: FigureImageRole;
|
|
30
|
+
width?: number;
|
|
31
|
+
height?: number;
|
|
32
|
+
scale?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface NonVerbalSourceRef {
|
|
36
|
+
source?: string;
|
|
37
|
+
id?: string;
|
|
38
|
+
version?: string;
|
|
39
|
+
text?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface NonVerbalSourceLocality {
|
|
43
|
+
type?: string;
|
|
44
|
+
referenceFrom?: string;
|
|
45
|
+
referenceTo?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface NonVerbalSourceOrigin {
|
|
49
|
+
ref?: NonVerbalSourceRef;
|
|
50
|
+
locality?: NonVerbalSourceLocality;
|
|
51
|
+
link?: string;
|
|
52
|
+
id?: string;
|
|
53
|
+
version?: string;
|
|
54
|
+
source?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface NonVerbalSource {
|
|
58
|
+
id?: string;
|
|
59
|
+
type?: string;
|
|
60
|
+
status?: string;
|
|
61
|
+
modification?: string;
|
|
62
|
+
origin?: NonVerbalSourceOrigin;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface NonVerbalEntityBase {
|
|
66
|
+
id: string;
|
|
67
|
+
identifier?: string;
|
|
68
|
+
caption?: LocalizedString;
|
|
69
|
+
description?: LocalizedString;
|
|
70
|
+
alt?: LocalizedString;
|
|
71
|
+
sources?: NonVerbalSource[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface Figure extends NonVerbalEntityBase {
|
|
75
|
+
kind: 'figure';
|
|
76
|
+
images: FigureImage[];
|
|
77
|
+
subfigures?: Figure[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type TableFormat = 'html' | 'markdown' | 'asciidoc';
|
|
81
|
+
|
|
82
|
+
export type TableContent =
|
|
83
|
+
| { kind: 'structured'; headers: LocalizedString[]; rows: LocalizedString[][] }
|
|
84
|
+
| { kind: 'markup'; markup: LocalizedString };
|
|
85
|
+
|
|
86
|
+
export interface Table extends NonVerbalEntityBase {
|
|
87
|
+
kind: 'table';
|
|
88
|
+
content: TableContent;
|
|
89
|
+
format?: TableFormat;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type FormulaNotation = 'latex' | 'mathml' | 'asciimath';
|
|
93
|
+
|
|
94
|
+
export interface Formula extends NonVerbalEntityBase {
|
|
95
|
+
kind: 'formula';
|
|
96
|
+
expression: LocalizedString;
|
|
97
|
+
notation: FormulaNotation;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export type NonVerbalEntity = Figure | Table | Formula;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* V3 NonVerbRep runtime shape.
|
|
104
|
+
*
|
|
105
|
+
* glossarist-js's runtime `NonVerbRep` (post-V3 reshape) holds the same
|
|
106
|
+
* localized fields as `NonVerbalEntityBase` plus a `type` discriminator
|
|
107
|
+
* and an `images[]` array. The published `.d.ts` still describes the
|
|
108
|
+
* pre-V3 `ref`/`text` shape; this local interface lets consumer code
|
|
109
|
+
* type-check against reality. Remove when upstream ships a corrected
|
|
110
|
+
* `.d.ts` (TODO.figures/19).
|
|
111
|
+
*/
|
|
112
|
+
export interface NonVerbRepV3 extends NonVerbalEntityBase {
|
|
113
|
+
type: string | null;
|
|
114
|
+
images: FigureImage[];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface NonVerbalReference {
|
|
118
|
+
kind: NonVerbalKind;
|
|
119
|
+
entityId: string;
|
|
120
|
+
display?: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function isFigure(entity: NonVerbalEntity): entity is Figure {
|
|
124
|
+
return entity.kind === 'figure';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function isTable(entity: NonVerbalEntity): entity is Table {
|
|
128
|
+
return entity.kind === 'table';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function isFormula(entity: NonVerbalEntity): entity is Formula {
|
|
132
|
+
return entity.kind === 'formula';
|
|
133
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NonVerbalEntityResolver — the single runtime access point for non-verbal
|
|
3
|
+
* entities.
|
|
4
|
+
*
|
|
5
|
+
* Responsibilities:
|
|
6
|
+
* - Fetch entity JSON-LD from `public/data/{ds}/{kind-dir}/{id}.json`.
|
|
7
|
+
* - Bridge to the TS model via `KIND_TO_BRIDGE`.
|
|
8
|
+
* - Cache per `(kind, datasetId, id)` with Promise dedup for concurrent
|
|
9
|
+
* callers.
|
|
10
|
+
* - Compute image URLs (basePath-aware for GitHub Pages).
|
|
11
|
+
* - Compute stable anchor IDs via the anchor SSOT.
|
|
12
|
+
*
|
|
13
|
+
* Components and the content-renderer go through this resolver; they never
|
|
14
|
+
* call `fetch()` directly. The resolver is owned by `AdapterFactory` —
|
|
15
|
+
* exactly one instance per app.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { NonVerbalEntity, NonVerbalKind } from './non-verbal/types';
|
|
19
|
+
import { KIND_TO_DIR, KIND_TO_BRIDGE } from './non-verbal/kind';
|
|
20
|
+
import { anchorId } from '../utils/non-verbal-anchor';
|
|
21
|
+
|
|
22
|
+
export type { NonVerbalEntity, NonVerbalKind } from './non-verbal/types';
|
|
23
|
+
|
|
24
|
+
export interface NonVerbalEntityResolverOptions {
|
|
25
|
+
basePath?: string;
|
|
26
|
+
fetcher?: (url: string) => Promise<Response>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface CacheEntry {
|
|
30
|
+
promise: Promise<NonVerbalEntity | null>;
|
|
31
|
+
resolved: NonVerbalEntity | null | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class NonVerbalEntityResolver {
|
|
35
|
+
private readonly cache = new Map<string, CacheEntry>();
|
|
36
|
+
private readonly basePath: string;
|
|
37
|
+
private readonly fetcher: (url: string) => Promise<Response>;
|
|
38
|
+
|
|
39
|
+
constructor(opts: NonVerbalEntityResolverOptions = {}) {
|
|
40
|
+
this.basePath = opts.basePath ?? import.meta.env.BASE_URL ?? '/';
|
|
41
|
+
this.fetcher = opts.fetcher ?? ((url: string) => fetch(url));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
resolve(kind: NonVerbalKind, datasetId: string, entityId: string): Promise<NonVerbalEntity | null> {
|
|
45
|
+
const key = `${kind}|${datasetId}|${entityId}`;
|
|
46
|
+
const existing = this.cache.get(key);
|
|
47
|
+
if (existing) return existing.promise;
|
|
48
|
+
|
|
49
|
+
const promise = (async () => {
|
|
50
|
+
const dir = KIND_TO_DIR[kind];
|
|
51
|
+
const url = `${this.basePath}data/${datasetId}/${dir}/${entityId}.json`;
|
|
52
|
+
const resp = await this.fetcher(url);
|
|
53
|
+
if (resp.status === 404) return null;
|
|
54
|
+
if (!resp.ok) {
|
|
55
|
+
throw new Error(`Failed to load ${kind} ${entityId} from ${datasetId}: ${resp.status}`);
|
|
56
|
+
}
|
|
57
|
+
const doc = (await resp.json()) as Record<string, unknown>;
|
|
58
|
+
const entity = KIND_TO_BRIDGE[kind](doc);
|
|
59
|
+
return entity;
|
|
60
|
+
})();
|
|
61
|
+
|
|
62
|
+
const entry: CacheEntry = { promise, resolved: undefined };
|
|
63
|
+
promise.then(
|
|
64
|
+
v => { entry.resolved = v; return v; },
|
|
65
|
+
() => { this.cache.delete(key); return null; },
|
|
66
|
+
);
|
|
67
|
+
this.cache.set(key, entry);
|
|
68
|
+
return promise;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Synchronous peek at the cache. Returns the entity if the previous
|
|
73
|
+
* `resolve` already completed, otherwise `undefined`. Useful for SSR or
|
|
74
|
+
* pre-hydration checks where the cache is warm.
|
|
75
|
+
*/
|
|
76
|
+
peek(kind: NonVerbalKind, datasetId: string, entityId: string): NonVerbalEntity | null | undefined {
|
|
77
|
+
return this.cache.get(`${kind}|${datasetId}|${entityId}`)?.resolved;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
resolveImageUrl(datasetId: string, src: string): string {
|
|
81
|
+
const cleanSrc = src.startsWith('images/') ? src.slice('images/'.length) : src;
|
|
82
|
+
return `${this.basePath}data/${datasetId}/images/${cleanSrc}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
anchor(kind: NonVerbalKind, datasetId: string, entityId: string): string {
|
|
86
|
+
return anchorId(kind, datasetId, entityId);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Drop the cached entry for one entity. Locale switching does NOT need
|
|
91
|
+
* this — the cached entity is the raw JSON-LD, and localization happens
|
|
92
|
+
* at render time via the locale SSOT.
|
|
93
|
+
*/
|
|
94
|
+
invalidate(kind: NonVerbalKind, datasetId: string, entityId: string): void {
|
|
95
|
+
this.cache.delete(`${kind}|${datasetId}|${entityId}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
clear(): void {
|
|
99
|
+
this.cache.clear();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -16,6 +16,8 @@ import { getFactory } from '../adapters/factory';
|
|
|
16
16
|
import { useRenderOptions } from '../composables/use-render-options';
|
|
17
17
|
import { useConceptEdges } from '../composables/use-concept-edges';
|
|
18
18
|
import { useConceptContent } from '../composables/use-concept-content';
|
|
19
|
+
import { useConceptEntities } from '../composables/use-concept-entities';
|
|
20
|
+
import { useNonVerbalCrossRef } from '../composables/use-non-verbal-cross-ref';
|
|
19
21
|
import { relationshipLabel, INVERSE_RELATIONSHIPS } from '../utils/relationship-categories';
|
|
20
22
|
import { slugify } from '../utils/slugify';
|
|
21
23
|
import { useSiteConfig } from '../config/use-site-config';
|
|
@@ -23,6 +25,7 @@ import ConceptTimeline from './ConceptTimeline.vue';
|
|
|
23
25
|
import ConceptRdfView from './ConceptRdfView.vue';
|
|
24
26
|
import FormatDownloads from './FormatDownloads.vue';
|
|
25
27
|
import NonVerbalRepDisplay from './NonVerbalRepDisplay.vue';
|
|
28
|
+
import NonVerbalList from './non-verbal/NonVerbalList.vue';
|
|
26
29
|
import CitationDisplay from './CitationDisplay.vue';
|
|
27
30
|
import DesignationList from './DesignationList.vue';
|
|
28
31
|
import { useI18n } from '../i18n';
|
|
@@ -103,7 +106,7 @@ const conceptSources = computed(() => props.concept.sources);
|
|
|
103
106
|
const conceptTags = computed(() => props.concept.tags ?? []);
|
|
104
107
|
|
|
105
108
|
const factory = getFactory();
|
|
106
|
-
const { ensureBibLoaded, bibResolver,
|
|
109
|
+
const { ensureBibLoaded, bibResolver, nonVerbalRefResolver } = useRenderOptions(() => props.registerId);
|
|
107
110
|
|
|
108
111
|
const renderOpts = computed<RenderOptions>(() => ({
|
|
109
112
|
xrefResolver: (uri, term) => {
|
|
@@ -125,11 +128,18 @@ const renderOpts = computed<RenderOptions>(() => ({
|
|
|
125
128
|
return `<a href="#" class="xref-link" data-register="${escapeAttr(props.registerId)}" data-concept="${escapeAttr(resolvedId)}">${escapeAttr(term)}</a>`;
|
|
126
129
|
},
|
|
127
130
|
bibResolver,
|
|
128
|
-
|
|
131
|
+
nonVerbalRefResolver,
|
|
129
132
|
}));
|
|
130
133
|
|
|
131
134
|
watch(() => props.registerId, () => { ensureBibLoaded(); }, { immediate: true });
|
|
132
135
|
|
|
136
|
+
const structuralEntityRefs = useConceptEntities(
|
|
137
|
+
computed(() => props.concept),
|
|
138
|
+
computed(() => props.registerId),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
useNonVerbalCrossRef();
|
|
142
|
+
|
|
133
143
|
function handleContentClick(e: MouseEvent) {
|
|
134
144
|
const target = (e.target as HTMLElement).closest('.xref-link') as HTMLElement | null;
|
|
135
145
|
if (!target) return;
|
|
@@ -416,7 +426,7 @@ const nonVerbalReps = computed(() => {
|
|
|
416
426
|
</div>
|
|
417
427
|
|
|
418
428
|
<!-- Non-verbal representations -->
|
|
419
|
-
<NonVerbalRepDisplay v-if="lc.lc.nonVerbalRep?.length" :reps="lc.lc.nonVerbalRep" />
|
|
429
|
+
<NonVerbalRepDisplay v-if="lc.lc.nonVerbalRep?.length" :reps="lc.lc.nonVerbalRep" :locale="lc.lang" :register-id="registerId" :dataset-locales="languages" />
|
|
420
430
|
|
|
421
431
|
<!-- Sources -->
|
|
422
432
|
<div v-if="lc.sources.length" class="space-y-2">
|
|
@@ -467,7 +477,10 @@ const nonVerbalReps = computed(() => {
|
|
|
467
477
|
</div>
|
|
468
478
|
|
|
469
479
|
<!-- Non-verbal reps (concept-level) -->
|
|
470
|
-
<NonVerbalRepDisplay v-if="nonVerbalReps.length" :reps="nonVerbalReps" />
|
|
480
|
+
<NonVerbalRepDisplay v-if="nonVerbalReps.length" :reps="nonVerbalReps" :locale="languages[0] ?? 'eng'" :register-id="registerId" :dataset-locales="languages" />
|
|
481
|
+
|
|
482
|
+
<!-- Structural entity refs (concept-level figures/tables/formulas) -->
|
|
483
|
+
<NonVerbalList v-if="structuralEntityRefs.length" :refs="structuralEntityRefs" />
|
|
471
484
|
</div>
|
|
472
485
|
|
|
473
486
|
<!-- Right sidebar -->
|
|
@@ -63,9 +63,6 @@ const renderOpts: RenderOptions = {
|
|
|
63
63
|
bibResolver: (refId, title) => {
|
|
64
64
|
return `<span class="bib-ref">${escapeAttr(title)}</span>`;
|
|
65
65
|
},
|
|
66
|
-
figResolver: (figId) => {
|
|
67
|
-
return `<span class="fig-ref">${escapeAttr(figId)}</span>`;
|
|
68
|
-
},
|
|
69
66
|
};
|
|
70
67
|
|
|
71
68
|
function handleContentClick(e: MouseEvent) {
|
|
@@ -1,38 +1,96 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
2
|
+
import { computed } from 'vue';
|
|
3
|
+
import type { NonVerbRep, Citation } from 'glossarist';
|
|
4
|
+
import type { FigureImage, LocalizedString, NonVerbRepV3, NonVerbalSource } from '../adapters/non-verbal/types';
|
|
5
|
+
import { resolveFallbackChain } from '../utils/locale';
|
|
6
|
+
import FigureImages from './figure/FigureImages.vue';
|
|
7
|
+
import NonVerbalCaption from './non-verbal/NonVerbalCaption.vue';
|
|
8
|
+
import CitationDisplay from './CitationDisplay.vue';
|
|
4
9
|
|
|
5
|
-
defineProps<{
|
|
10
|
+
const props = defineProps<{
|
|
6
11
|
reps: NonVerbRep[];
|
|
12
|
+
locale: string;
|
|
13
|
+
registerId: string;
|
|
14
|
+
datasetLocales?: readonly string[];
|
|
7
15
|
}>();
|
|
16
|
+
|
|
17
|
+
const fallbackChain = computed(() => resolveFallbackChain(props.datasetLocales));
|
|
18
|
+
|
|
19
|
+
// Cast once at the boundary: glossarist-js's published `.d.ts` still
|
|
20
|
+
// describes pre-V3 NonVerbRep, but the runtime exposes the V3 shape
|
|
21
|
+
// (images/alt/caption/description/sources). See TODO.figures/19.
|
|
22
|
+
const v3Reps = computed<NonVerbRepV3[]>(() => props.reps as unknown as NonVerbRepV3[]);
|
|
23
|
+
|
|
24
|
+
function imagesOf(rep: NonVerbRepV3): FigureImage[] {
|
|
25
|
+
return rep.images ?? [];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function hasImages(rep: NonVerbRepV3): boolean {
|
|
29
|
+
return imagesOf(rep).length > 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function asCitation(origin: NonVerbalSource['origin']): Citation | null {
|
|
33
|
+
return (origin as unknown as Citation) ?? null;
|
|
34
|
+
}
|
|
8
35
|
</script>
|
|
9
36
|
|
|
10
37
|
<template>
|
|
11
|
-
<div v-if="
|
|
38
|
+
<div v-if="v3Reps.length" class="space-y-3">
|
|
12
39
|
<div class="section-label">Non-verbal representations</div>
|
|
13
|
-
<
|
|
14
|
-
<
|
|
15
|
-
<span class="badge text-[10px] bg-violet-50 text-violet-700">{{ rep.type ?? 'representation' }}</span>
|
|
16
|
-
<span v-if="rep.text" class="text-sm text-ink-700">{{ rep.text }}</span>
|
|
17
|
-
</div>
|
|
40
|
+
<figure v-for="(rep, i) in v3Reps" :key="i" class="card p-4 space-y-2">
|
|
41
|
+
<span class="badge text-[10px] bg-violet-50 text-violet-700">{{ rep.type ?? 'representation' }}</span>
|
|
18
42
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
43
|
+
<FigureImages
|
|
44
|
+
v-if="hasImages(rep)"
|
|
45
|
+
:images="imagesOf(rep)"
|
|
46
|
+
:alt="(rep.alt as LocalizedString | undefined) ?? undefined"
|
|
47
|
+
:dataset-id="registerId"
|
|
48
|
+
:locale="locale"
|
|
49
|
+
:fallback-chain="fallbackChain"
|
|
50
|
+
:has-description="!!rep.description && Object.keys(rep.description).length > 0"
|
|
51
|
+
:entity-label="rep.type ?? 'representation'"
|
|
52
|
+
/>
|
|
23
53
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
54
|
+
<NonVerbalCaption
|
|
55
|
+
:caption="(rep.caption as LocalizedString | undefined) ?? undefined"
|
|
56
|
+
:description="(rep.description as LocalizedString | undefined) ?? undefined"
|
|
57
|
+
:locale="locale"
|
|
58
|
+
:fallback-chain="fallbackChain"
|
|
59
|
+
/>
|
|
28
60
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
<
|
|
32
|
-
<
|
|
33
|
-
|
|
34
|
-
|
|
61
|
+
<div v-if="rep.sources?.length" class="nv-rep__sources">
|
|
62
|
+
<div class="nv-rep__sources-label">Sources</div>
|
|
63
|
+
<ol class="nv-rep__sources-list">
|
|
64
|
+
<li v-for="(src, si) in rep.sources" :key="si" class="nv-rep__source">
|
|
65
|
+
<CitationDisplay v-if="asCitation(src.origin)" :citation="asCitation(src.origin)!" :register-id="registerId" />
|
|
66
|
+
<span v-if="src.modification" class="nv-rep__source-modification">— {{ src.modification }}</span>
|
|
67
|
+
</li>
|
|
68
|
+
</ol>
|
|
35
69
|
</div>
|
|
36
|
-
</
|
|
70
|
+
</figure>
|
|
37
71
|
</div>
|
|
38
72
|
</template>
|
|
73
|
+
|
|
74
|
+
<style scoped>
|
|
75
|
+
.nv-rep__sources {
|
|
76
|
+
font-size: 0.75rem;
|
|
77
|
+
color: var(--ink-500, #666);
|
|
78
|
+
margin-top: 0.5rem;
|
|
79
|
+
}
|
|
80
|
+
.nv-rep__sources-label {
|
|
81
|
+
font-weight: 600;
|
|
82
|
+
text-transform: uppercase;
|
|
83
|
+
letter-spacing: 0.04em;
|
|
84
|
+
margin-bottom: 0.25rem;
|
|
85
|
+
}
|
|
86
|
+
.nv-rep__sources-list {
|
|
87
|
+
list-style: decimal inside;
|
|
88
|
+
display: flex;
|
|
89
|
+
flex-direction: column;
|
|
90
|
+
gap: 0.25rem;
|
|
91
|
+
}
|
|
92
|
+
.nv-rep__source-modification {
|
|
93
|
+
color: var(--ink-400, #888);
|
|
94
|
+
font-style: italic;
|
|
95
|
+
}
|
|
96
|
+
</style>
|