@glossarist/concept-browser 0.7.43 → 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 (60) hide show
  1. package/cli/index.mjs +12 -13
  2. package/package.json +3 -2
  3. package/scripts/__tests__/fetch-datasets.test.mjs +105 -0
  4. package/scripts/fetch-datasets.mjs +53 -51
  5. package/scripts/generate-data.mjs +41 -19
  6. package/scripts/lib/build/image-assets.mjs +190 -0
  7. package/scripts/lib/build/non-verbal-consumer.mjs +221 -0
  8. package/scripts/lib/local-path-safety.mjs +68 -0
  9. package/src/__tests__/bibliography-adapter.test.ts +79 -0
  10. package/src/__tests__/content-renderer-nvr-mentions.test.ts +57 -0
  11. package/src/__tests__/locale.test.ts +46 -0
  12. package/src/__tests__/model-bridge-entity-refs.test.ts +114 -0
  13. package/src/__tests__/non-verbal-anchor.test.ts +33 -0
  14. package/src/__tests__/non-verbal-cross-ref.test.ts +146 -0
  15. package/src/__tests__/non-verbal-highlight.test.ts +56 -0
  16. package/src/__tests__/non-verbal-kind.test.ts +77 -0
  17. package/src/__tests__/non-verbal-list.test.ts +67 -0
  18. package/src/__tests__/non-verbal-rep-display.test.ts +85 -0
  19. package/src/__tests__/non-verbal-scroll-guard.test.ts +116 -0
  20. package/src/__tests__/use-concept-entities.test.ts +76 -0
  21. package/src/adapters/bibliography-adapter.ts +49 -0
  22. package/src/adapters/factory.ts +14 -0
  23. package/src/adapters/model-bridge.ts +51 -0
  24. package/src/adapters/non-verbal/figure-bridge.ts +101 -0
  25. package/src/adapters/non-verbal/formula-bridge.ts +48 -0
  26. package/src/adapters/non-verbal/index.ts +55 -0
  27. package/src/adapters/non-verbal/kind.ts +46 -0
  28. package/src/adapters/non-verbal/prefix.ts +67 -0
  29. package/src/adapters/non-verbal/source-bridge.ts +81 -0
  30. package/src/adapters/non-verbal/table-bridge.ts +98 -0
  31. package/src/adapters/non-verbal/types.ts +133 -0
  32. package/src/adapters/non-verbal-resolver.ts +101 -0
  33. package/src/components/ConceptDetail.vue +17 -4
  34. package/src/components/LanguageDetail.vue +0 -3
  35. package/src/components/NonVerbalRepDisplay.vue +82 -24
  36. package/src/components/figure/FigureDisplay.vue +132 -0
  37. package/src/components/figure/FigureImages.vue +111 -0
  38. package/src/components/figure/figure-image-pick.ts +56 -0
  39. package/src/components/figure/figure-layout.ts +26 -0
  40. package/src/components/formula/FormulaDisplay.vue +90 -0
  41. package/src/components/formula/FormulaExpression.vue +70 -0
  42. package/src/components/non-verbal/NonVerbalCaption.vue +104 -0
  43. package/src/components/non-verbal/NonVerbalFallback.vue +69 -0
  44. package/src/components/non-verbal/NonVerbalList.vue +118 -0
  45. package/src/components/non-verbal/NonVerbalSources.vue +61 -0
  46. package/src/components/table/TableDisplay.vue +99 -0
  47. package/src/components/table/TableMarkup.vue +63 -0
  48. package/src/components/table/TableStructured.vue +66 -0
  49. package/src/composables/use-concept-entities.ts +70 -0
  50. package/src/composables/use-non-verbal-cross-ref.ts +79 -0
  51. package/src/composables/use-non-verbal-entity.ts +58 -0
  52. package/src/composables/use-reduced-motion.ts +26 -0
  53. package/src/composables/use-render-options.ts +30 -33
  54. package/src/router/index.ts +3 -0
  55. package/src/router/non-verbal-scroll-guard.ts +56 -0
  56. package/src/style.css +17 -0
  57. package/src/utils/content-renderer.ts +76 -64
  58. package/src/utils/locale.ts +92 -0
  59. package/src/utils/non-verbal-anchor.ts +51 -0
  60. package/src/utils/non-verbal-highlight.ts +27 -0
@@ -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
+ }
@@ -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
+ }