@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
@@ -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, figResolver } = useRenderOptions(() => props.registerId);
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
- figResolver,
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 type { NonVerbRep, ConceptSource } from 'glossarist';
3
- import { sourceStatusInfo, sourceTypeInfo } from '../utils/designation-registry';
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="reps.length" class="space-y-3">
38
+ <div v-if="v3Reps.length" class="space-y-3">
12
39
  <div class="section-label">Non-verbal representations</div>
13
- <div v-for="(rep, i) in reps" :key="i" class="card p-4 space-y-2">
14
- <div class="flex items-center gap-2">
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
- <!-- Image -->
20
- <div v-if="rep.type === 'image' && rep.ref">
21
- <img :src="rep.ref" :alt="rep.text || 'Non-verbal representation'" class="max-h-64 rounded border border-ink-100" loading="lazy" />
22
- </div>
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
- <!-- Table / Formula reference -->
25
- <div v-if="(rep.type === 'table' || rep.type === 'formula') && rep.ref">
26
- <a :href="rep.ref" target="_blank" rel="noopener" class="text-sm concept-link break-all">{{ rep.ref }}</a>
27
- </div>
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
- <!-- Sources for this representation -->
30
- <div v-if="rep.sources?.length" class="flex flex-wrap gap-1.5">
31
- <div v-for="(src, si) in rep.sources" :key="si" class="text-xs text-ink-400">
32
- <span v-if="src.type" class="badge text-[9px]" :class="sourceTypeInfo(src.type).color">{{ sourceTypeInfo(src.type).label }}</span>
33
- <span v-if="src.origin?.ref" class="ml-1">{{ src.origin.ref.source }}{{ src.origin.ref.id ? ' ' + src.origin.ref.id : '' }}</span>
34
- </div>
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
- </div>
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>