@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
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Figure image picker — single SSOT for choosing the best variant from
3
+ * a Figure's image array.
4
+ *
5
+ * Decision order:
6
+ * 1. If user prefers dark color scheme and a dark variant exists, use it.
7
+ * 2. If user prefers light color scheme and a light variant exists, use it.
8
+ * 3. If a vector (SVG) variant exists and the user prefers vector, use it.
9
+ * 4. Otherwise, fall back to the first declared variant.
10
+ *
11
+ * The `print` role is reserved for print stylesheets (selected via CSS
12
+ * @media print in FigureImages.vue), not picked here.
13
+ */
14
+ import type { FigureImage } from '../../adapters/non-verbal/types';
15
+
16
+ export interface PickOptions {
17
+ prefersDark?: boolean;
18
+ prefersVector?: boolean;
19
+ }
20
+
21
+ export function pickBestImage(images: FigureImage[], opts: PickOptions = {}): FigureImage | null {
22
+ if (images.length === 0) return null;
23
+ if (opts.prefersDark) {
24
+ const dark = images.find(i => i.role === 'dark');
25
+ if (dark) return dark;
26
+ }
27
+ if (!opts.prefersDark) {
28
+ const light = images.find(i => i.role === 'light');
29
+ if (light) return light;
30
+ }
31
+ if (opts.prefersVector) {
32
+ const vector = images.find(i => i.format === 'svg' || i.role === 'vector');
33
+ if (vector) return vector;
34
+ }
35
+ return images[0] ?? null;
36
+ }
37
+
38
+ /**
39
+ * Group image variants by role for the <picture> element. Returns the
40
+ * responsive sources in the order they should appear (most-specific
41
+ * first), plus the default `<img>` source.
42
+ */
43
+ export function groupImageVariants(images: FigureImage[]): {
44
+ sources: FigureImage[];
45
+ img: FigureImage | null;
46
+ print: FigureImage | null;
47
+ } {
48
+ const print = images.find(i => i.role === 'print') ?? null;
49
+ const dark = images.find(i => i.role === 'dark');
50
+ const light = images.find(i => i.role === 'light');
51
+ const sources: FigureImage[] = [];
52
+ if (dark) sources.push(dark);
53
+ if (light) sources.push(light);
54
+ const img = pickBestImage(images, {}) ?? images[0] ?? null;
55
+ return { sources, img, print };
56
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Figure layout derivation — derives a layout label from subfigure count.
3
+ *
4
+ * Per the architectural rule "derive layout, don't store it", this helper
5
+ * inspects a Figure's subfigures and returns the layout kind:
6
+ *
7
+ * - `single` — no subfigures
8
+ * - `row` — 2 subfigures side-by-side
9
+ * - `column` — 3+ subfigures in a vertical stack (default for many)
10
+ * - `grid` — 4+ subfigures in a 2-col grid
11
+ *
12
+ * Authors who want different behavior can split a composite figure into
13
+ * multiple top-level figures. V1 does not expose a `layout` field.
14
+ */
15
+ import type { Figure } from '../../adapters/non-verbal/types';
16
+
17
+ export type FigureLayout = 'single' | 'row' | 'column' | 'grid';
18
+
19
+ export function deriveLayout(fig: Figure): FigureLayout {
20
+ const count = fig.subfigures?.length ?? 0;
21
+ if (count === 0) return 'single';
22
+ if (count === 1) return 'column';
23
+ if (count === 2) return 'row';
24
+ if (count === 3) return 'column';
25
+ return 'grid';
26
+ }
@@ -0,0 +1,90 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * FormulaDisplay — renders a dataset-level Formula entity.
4
+ *
5
+ * Caption + expression + description + sources. The expression renders
6
+ * via Plurimath (LaTeX/MathML/AsciiMath). Self-anchoring — the outer
7
+ * `<figure>` receives the anchor ID for `{{formula:id}}` mentions.
8
+ */
9
+ import { computed } from 'vue';
10
+ import type { Formula } from '../../adapters/non-verbal/types';
11
+ import { useNonVerbalEntity } from '../../composables/use-non-verbal-entity';
12
+ import { resolveFallbackChain } from '../../utils/locale';
13
+ import { anchorId } from '../../utils/non-verbal-anchor';
14
+ import NonVerbalCaption from '../non-verbal/NonVerbalCaption.vue';
15
+ import NonVerbalSources from '../non-verbal/NonVerbalSources.vue';
16
+ import NonVerbalFallback from '../non-verbal/NonVerbalFallback.vue';
17
+ import FormulaExpression from './FormulaExpression.vue';
18
+
19
+ const props = defineProps<{
20
+ datasetId: string;
21
+ entityId: string;
22
+ locale: string;
23
+ datasetLocales?: readonly string[];
24
+ }>();
25
+
26
+ const k = () => 'formula' as const;
27
+ const { entity, state, error } = useNonVerbalEntity(k, () => props.datasetId, () => props.entityId);
28
+
29
+ const fallbackChain = computed(() => resolveFallbackChain(props.datasetLocales));
30
+ const anchor = computed(() => anchorId('formula', props.datasetId, props.entityId));
31
+ const descriptionId = computed(() => `${anchor.value}-desc`);
32
+ </script>
33
+
34
+ <template>
35
+ <figure
36
+ v-if="entity && state === 'loaded'"
37
+ :id="anchor"
38
+ class="formula-entity"
39
+ >
40
+ <div class="formula__expr-line">
41
+ <FormulaExpression
42
+ :expression="(entity as Formula).expression"
43
+ :notation="(entity as Formula).notation"
44
+ :locale="locale"
45
+ :fallback-chain="fallbackChain"
46
+ />
47
+ </div>
48
+
49
+ <NonVerbalCaption
50
+ :identifier="(entity as Formula).identifier"
51
+ :caption="(entity as Formula).caption"
52
+ :description="(entity as Formula).description"
53
+ :locale="locale"
54
+ :fallback-chain="fallbackChain"
55
+ :description-id="descriptionId"
56
+ />
57
+
58
+ <NonVerbalSources
59
+ v-if="(entity as Formula).sources?.length"
60
+ :sources="(entity as Formula).sources!"
61
+ />
62
+ </figure>
63
+
64
+ <NonVerbalFallback
65
+ v-else-if="state === 'loading' || state === 'not-found' || state === 'error'"
66
+ :state="state"
67
+ kind="formula"
68
+ :entity-id="entityId"
69
+ :message="error ?? undefined"
70
+ />
71
+ </template>
72
+
73
+ <style scoped>
74
+ .formula-entity {
75
+ margin: 1rem 0;
76
+ padding: 0.75rem 1rem;
77
+ border-left: 3px solid var(--ink-200, #e5e5e5);
78
+ display: flex;
79
+ flex-direction: column;
80
+ gap: 0.5rem;
81
+ }
82
+ .formula__expr-line {
83
+ font-size: 1.125rem;
84
+ padding: 0.25rem 0;
85
+ }
86
+ .formula-entity:focus-visible {
87
+ outline: 2px solid var(--blue-500, #3b82f6);
88
+ outline-offset: 4px;
89
+ }
90
+ </style>
@@ -0,0 +1,70 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * FormulaExpression — renders one formula expression in its notation.
4
+ *
5
+ * Uses Plurimath (the project's existing math library, NOT KaTeX) to
6
+ * render LaTeX / AsciiMath / MathML to MathML+HTML. Plurimath output
7
+ * includes semantic MathML for screen readers as a by-product of its
8
+ * internal pipeline, so no extra a11y work is needed.
9
+ *
10
+ * The expression is a LocalizedString — different locales can name
11
+ * variables in their local language while keeping the same notation type.
12
+ */
13
+ import { computed, ref, watch, onMounted } from 'vue';
14
+ import type { LocalizedString, FormulaNotation } from '../../adapters/non-verbal/types';
15
+ import { pickLocaleMap, localeToBcp47 } from '../../utils/locale';
16
+ import { loadPlurimath } from '../../utils/plurimath';
17
+
18
+ const props = defineProps<{
19
+ expression: LocalizedString;
20
+ notation: FormulaNotation;
21
+ locale: string;
22
+ fallbackChain?: readonly string[];
23
+ }>();
24
+
25
+ const resolved = computed(() => pickLocaleMap(props.expression, props.locale, props.fallbackChain));
26
+ const html = ref<string>('');
27
+ const lang = computed(() => resolved.value ? localeToBcp47(resolved.value.locale) : undefined);
28
+
29
+ const PLURIMATH_FORMAT: Record<FormulaNotation, string> = {
30
+ latex: 'latex',
31
+ asciimath: 'asciimath',
32
+ mathml: 'mathml',
33
+ };
34
+
35
+ async function render() {
36
+ const r = resolved.value;
37
+ if (!r) { html.value = ''; return; }
38
+ try {
39
+ const Plurimath = await loadPlurimath();
40
+ const p = new Plurimath(r.text, PLURIMATH_FORMAT[props.notation]);
41
+ html.value = p.toMathml().replace('display="block"', 'display="inline"').trim();
42
+ } catch {
43
+ html.value = `<code class="formula-fallback">${r.text}</code>`;
44
+ }
45
+ }
46
+
47
+ onMounted(render);
48
+ watch([resolved, () => props.notation], render);
49
+ </script>
50
+
51
+ <template>
52
+ <span class="formula__expression" :lang="lang">
53
+ <span v-if="html" v-html="html"></span>
54
+ <code v-else></code>
55
+ </span>
56
+ </template>
57
+
58
+ <style scoped>
59
+ .formula__expression {
60
+ display: inline-block;
61
+ font-family: 'Latin Modern Math', 'STIX Two Math', serif;
62
+ }
63
+ .formula-fallback {
64
+ font-family: var(--font-mono, monospace);
65
+ background: var(--surface-alt, #f5f5f5);
66
+ padding: 0.125rem 0.375rem;
67
+ border-radius: 0.25rem;
68
+ font-size: 0.875rem;
69
+ }
70
+ </style>
@@ -0,0 +1,104 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * NonVerbalCaption — shared caption + identifier + description.
4
+ *
5
+ * Renders identically for Figure, Table, Formula. The shape:
6
+ *
7
+ * <figcaption>
8
+ * <span class="nv-caption__identifier">Figure 7c.</span>
9
+ * <span class="nv-caption__text" lang="en">Caption text.</span>
10
+ * <details>
11
+ * <summary>Detailed description</summary>
12
+ * <p lang="en">Long description text.</p>
13
+ * </details>
14
+ * </figcaption>
15
+ *
16
+ * The `lang` attribute reflects the ACTUAL resolved locale (not the
17
+ * requested one), so a French page with an English-only caption shows
18
+ * `lang="en"` on the caption — correct for screen readers.
19
+ */
20
+ import { computed } from 'vue';
21
+ import type { LocalizedString } from '../../adapters/non-verbal/types';
22
+ import { pickLocaleMap, localeToBcp47 } from '../../utils/locale';
23
+
24
+ const props = defineProps<{
25
+ identifier?: string;
26
+ caption?: LocalizedString;
27
+ description?: LocalizedString;
28
+ locale: string;
29
+ fallbackChain?: readonly string[];
30
+ descriptionId?: string;
31
+ }>();
32
+
33
+ const captionResolved = computed(() =>
34
+ pickLocaleMap(props.caption, props.locale, props.fallbackChain),
35
+ );
36
+
37
+ const descriptionResolved = computed(() =>
38
+ pickLocaleMap(props.description, props.locale, props.fallbackChain),
39
+ );
40
+
41
+ const captionLang = computed(() =>
42
+ captionResolved.value ? localeToBcp47(captionResolved.value.locale) : undefined,
43
+ );
44
+
45
+ const descriptionLang = computed(() =>
46
+ descriptionResolved.value ? localeToBcp47(descriptionResolved.value.locale) : undefined,
47
+ );
48
+ </script>
49
+
50
+ <template>
51
+ <figcaption class="nv-caption">
52
+ <span v-if="identifier" class="nv-caption__identifier">{{ identifier }}.</span>
53
+ <span
54
+ v-if="captionResolved"
55
+ class="nv-caption__text"
56
+ :lang="captionLang"
57
+ >{{ captionResolved.text }}</span>
58
+ <details
59
+ v-if="descriptionResolved"
60
+ :id="descriptionId"
61
+ class="nv-caption__desc"
62
+ >
63
+ <summary>Detailed description</summary>
64
+ <p :lang="descriptionLang">{{ descriptionResolved.text }}</p>
65
+ </details>
66
+ </figcaption>
67
+ </template>
68
+
69
+ <style scoped>
70
+ .nv-caption {
71
+ font-size: 0.875rem;
72
+ color: var(--ink-700, #444);
73
+ line-height: 1.5;
74
+ display: flex;
75
+ flex-direction: column;
76
+ gap: 0.25rem;
77
+ }
78
+ .nv-caption__identifier {
79
+ font-weight: 600;
80
+ color: var(--ink-800, #222);
81
+ }
82
+ .nv-caption__text {
83
+ font-style: italic;
84
+ }
85
+ .nv-caption__desc {
86
+ margin-top: 0.25rem;
87
+ font-size: 0.8125rem;
88
+ color: var(--ink-500, #666);
89
+ }
90
+ .nv-caption__desc > summary {
91
+ cursor: pointer;
92
+ font-weight: 500;
93
+ color: var(--ink-600, #555);
94
+ }
95
+ .nv-caption__desc > p {
96
+ margin-top: 0.5rem;
97
+ line-height: 1.6;
98
+ }
99
+
100
+ @media (prefers-contrast: more) {
101
+ .nv-caption__text { font-weight: 600; }
102
+ .nv-caption__identifier { font-weight: 700; }
103
+ }
104
+ </style>
@@ -0,0 +1,69 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * NonVerbalFallback — shared loading / not-found / error state.
4
+ *
5
+ * Each entity display component renders its own fallback rather than
6
+ * hoisting errors to a global banner. Per the project rule
7
+ * "Error handling: local over global", a missing figure should not
8
+ * break the page — it shows a compact notice inline.
9
+ */
10
+ defineProps<{
11
+ state: 'loading' | 'not-found' | 'error';
12
+ kind: 'figure' | 'table' | 'formula';
13
+ entityId: string;
14
+ message?: string;
15
+ }>();
16
+ </script>
17
+
18
+ <template>
19
+ <div
20
+ class="nv-fallback"
21
+ :class="`nv-fallback--${state}`"
22
+ role="status"
23
+ :aria-live="state === 'error' ? 'assertive' : 'polite'"
24
+ >
25
+ <template v-if="state === 'loading'">
26
+ <span class="nv-fallback__spinner" aria-hidden="true"></span>
27
+ Loading {{ kind }} <code>{{ entityId }}</code>…
28
+ </template>
29
+ <template v-else-if="state === 'not-found'">
30
+ {{ kind }} <code>{{ entityId }}</code> not found
31
+ </template>
32
+ <template v-else>
33
+ Failed to load {{ kind }} <code>{{ entityId }}</code>
34
+ <span v-if="message" class="nv-fallback__detail">: {{ message }}</span>
35
+ </template>
36
+ </div>
37
+ </template>
38
+
39
+ <style scoped>
40
+ .nv-fallback {
41
+ display: inline-flex;
42
+ align-items: center;
43
+ gap: 0.5rem;
44
+ padding: 0.5rem 0.75rem;
45
+ border-radius: 0.375rem;
46
+ font-size: 0.8125rem;
47
+ color: var(--ink-500, #666);
48
+ background: var(--surface-alt, #f5f5f5);
49
+ border: 1px dashed var(--ink-200, #ccc);
50
+ }
51
+ .nv-fallback--error {
52
+ color: #b91c1c;
53
+ background: #fef2f2;
54
+ border-color: #fecaca;
55
+ }
56
+ .nv-fallback__spinner {
57
+ width: 0.75rem;
58
+ height: 0.75rem;
59
+ border: 2px solid var(--ink-200, #ccc);
60
+ border-top-color: transparent;
61
+ border-radius: 50%;
62
+ animation: nv-spin 0.8s linear infinite;
63
+ }
64
+ @keyframes nv-spin { to { transform: rotate(360deg); } }
65
+
66
+ @media (prefers-reduced-motion: reduce) {
67
+ .nv-fallback__spinner { animation: none; }
68
+ }
69
+ </style>
@@ -0,0 +1,118 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * NonVerbalList — renders the structural non-verbal entity refs
4
+ * (`figures[]`, `tables[]`, `formulas[]` from the concept) as a compact
5
+ * list of in-page anchor links.
6
+ *
7
+ * This is the "Figures / Tables / Formulas" section of a concept page.
8
+ * Inline mentions in prose are handled separately by the content
9
+ * renderer; this list surfaces entities the author declared as
10
+ * structural members of the concept regardless of whether they are
11
+ * also mentioned inline.
12
+ *
13
+ * Click handling is delegated to `useNonVerbalCrossRef` (a single
14
+ * document-level handler), so the links here are plain `<a href="#…">`.
15
+ */
16
+ import { computed } from 'vue';
17
+ import type { StructuralEntityRef } from '../../composables/use-concept-entities';
18
+ import type { NonVerbalKind } from '../../adapters/non-verbal/types';
19
+
20
+ const props = defineProps<{
21
+ refs: StructuralEntityRef[];
22
+ }>();
23
+
24
+ const KIND_LABEL: Readonly<Record<NonVerbalKind, string>> = {
25
+ figure: 'Figure',
26
+ table: 'Table',
27
+ formula: 'Formula',
28
+ };
29
+
30
+ const KIND_BADGE_CLASS: Readonly<Record<NonVerbalKind, string>> = {
31
+ figure: 'bg-violet-50 text-violet-700',
32
+ table: 'bg-sky-50 text-sky-700',
33
+ formula: 'bg-amber-50 text-amber-700',
34
+ };
35
+
36
+ interface GroupedRefs {
37
+ kind: NonVerbalKind;
38
+ label: string;
39
+ badgeClass: string;
40
+ items: StructuralEntityRef[];
41
+ }
42
+
43
+ const grouped = computed<GroupedRefs[]>(() => {
44
+ const map = new Map<NonVerbalKind, StructuralEntityRef[]>();
45
+ for (const r of props.refs) {
46
+ const bucket = map.get(r.kind) ?? [];
47
+ bucket.push(r);
48
+ map.set(r.kind, bucket);
49
+ }
50
+ const out: GroupedRefs[] = [];
51
+ for (const [kind, items] of map) {
52
+ out.push({
53
+ kind,
54
+ label: KIND_LABEL[kind],
55
+ badgeClass: KIND_BADGE_CLASS[kind],
56
+ items,
57
+ });
58
+ }
59
+ return out;
60
+ });
61
+
62
+ function labelOf(r: StructuralEntityRef): string {
63
+ return r.display ?? r.entityId;
64
+ }
65
+ </script>
66
+
67
+ <template>
68
+ <div v-if="refs.length" class="space-y-3">
69
+ <div class="section-label">Non-verbal entities</div>
70
+ <div
71
+ v-for="group in grouped"
72
+ :key="group.kind"
73
+ class="card p-3 space-y-1.5"
74
+ >
75
+ <div class="flex items-center gap-2">
76
+ <span class="badge text-[10px]" :class="group.badgeClass">{{ group.label }}s</span>
77
+ <span class="text-xs text-ink-400">{{ group.items.length }}</span>
78
+ </div>
79
+ <ol class="space-y-1">
80
+ <li v-for="r in group.items" :key="r.anchor">
81
+ <a
82
+ :href="`#${r.anchor}`"
83
+ class="nv-list__link"
84
+ >
85
+ <span class="nv-list__label">{{ labelOf(r) }}</span>
86
+ <span v-if="r.display" class="nv-list__id">{{ r.entityId }}</span>
87
+ </a>
88
+ </li>
89
+ </ol>
90
+ </div>
91
+ </div>
92
+ </template>
93
+
94
+ <style scoped>
95
+ .nv-list__link {
96
+ display: flex;
97
+ align-items: baseline;
98
+ gap: 0.5rem;
99
+ padding: 0.25rem 0.375rem;
100
+ border-radius: 0.25rem;
101
+ font-size: 0.8125rem;
102
+ color: var(--ink-700, #444);
103
+ text-decoration: none;
104
+ transition: background-color 120ms ease;
105
+ }
106
+ .nv-list__link:hover {
107
+ background: var(--ink-50, #f5f5f5);
108
+ color: var(--ink-900, #111);
109
+ }
110
+ .nv-list__label {
111
+ font-weight: 500;
112
+ }
113
+ .nv-list__id {
114
+ font-family: 'JetBrains Mono', monospace;
115
+ font-size: 0.6875rem;
116
+ color: var(--ink-400, #888);
117
+ }
118
+ </style>
@@ -0,0 +1,61 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * NonVerbalSources — shared source list for all three entity kinds.
4
+ *
5
+ * Reuses the existing CitationDisplay component for individual citations
6
+ * so the rendering matches the rest of the app. Each source may carry a
7
+ * modification note (e.g. "Adapted.") which is rendered alongside.
8
+ */
9
+ import type { Citation } from 'glossarist';
10
+ import type { NonVerbalSource } from '../../adapters/non-verbal/types';
11
+ import CitationDisplay from '../CitationDisplay.vue';
12
+
13
+ defineProps<{
14
+ sources: NonVerbalSource[];
15
+ }>();
16
+
17
+ // CitationDisplay expects glossarist's Citation class. NonVerbalSource.origin
18
+ // is wire-compatible at runtime but typed differently on the consumer side;
19
+ // cast once at this boundary.
20
+ function asCitation(origin: NonVerbalSource['origin']): Citation | null {
21
+ return (origin as unknown as Citation) ?? null;
22
+ }
23
+ </script>
24
+
25
+ <template>
26
+ <div v-if="sources.length" class="nv-sources">
27
+ <div class="nv-sources__label">Sources</div>
28
+ <ol class="nv-sources__list">
29
+ <li v-for="(src, i) in sources" :key="i" class="nv-source">
30
+ <CitationDisplay v-if="asCitation(src.origin)" :citation="asCitation(src.origin)!" />
31
+ <span v-if="src.modification" class="nv-source__modification">
32
+ — {{ src.modification }}
33
+ </span>
34
+ </li>
35
+ </ol>
36
+ </div>
37
+ </template>
38
+
39
+ <style scoped>
40
+ .nv-sources {
41
+ font-size: 0.75rem;
42
+ color: var(--ink-500, #666);
43
+ margin-top: 0.5rem;
44
+ }
45
+ .nv-sources__label {
46
+ font-weight: 600;
47
+ text-transform: uppercase;
48
+ letter-spacing: 0.04em;
49
+ margin-bottom: 0.25rem;
50
+ }
51
+ .nv-sources__list {
52
+ list-style: decimal inside;
53
+ display: flex;
54
+ flex-direction: column;
55
+ gap: 0.25rem;
56
+ }
57
+ .nv-source__modification {
58
+ color: var(--ink-400, #888);
59
+ font-style: italic;
60
+ }
61
+ </style>
@@ -0,0 +1,99 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * TableDisplay — renders a dataset-level Table entity.
4
+ *
5
+ * Delegates content rendering to TableStructured (headers + rows) or
6
+ * TableMarkup (HTML / Markdown / AsciiDoc) based on `content.kind`.
7
+ */
8
+ import { computed } from 'vue';
9
+ import type { Table } from '../../adapters/non-verbal/types';
10
+ import { useNonVerbalEntity } from '../../composables/use-non-verbal-entity';
11
+ import { resolveFallbackChain } from '../../utils/locale';
12
+ import { anchorId } from '../../utils/non-verbal-anchor';
13
+ import NonVerbalCaption from '../non-verbal/NonVerbalCaption.vue';
14
+ import NonVerbalSources from '../non-verbal/NonVerbalSources.vue';
15
+ import NonVerbalFallback from '../non-verbal/NonVerbalFallback.vue';
16
+ import TableStructured from './TableStructured.vue';
17
+ import TableMarkup from './TableMarkup.vue';
18
+
19
+ const props = defineProps<{
20
+ datasetId: string;
21
+ entityId: string;
22
+ locale: string;
23
+ datasetLocales?: readonly string[];
24
+ }>();
25
+
26
+ const k = () => 'table' as const;
27
+ const { entity, state, error } = useNonVerbalEntity(k, () => props.datasetId, () => props.entityId);
28
+
29
+ const fallbackChain = computed(() => resolveFallbackChain(props.datasetLocales));
30
+ const anchor = computed(() => anchorId('table', props.datasetId, props.entityId));
31
+ const descriptionId = computed(() => `${anchor.value}-desc`);
32
+
33
+ const table = computed<Table | null>(() => entity.value as Table | null);
34
+ const structuredContent = computed(() => {
35
+ const c = table.value?.content;
36
+ return c && c.kind === 'structured' ? c : null;
37
+ });
38
+ const markup = computed(() => {
39
+ const c = table.value?.content;
40
+ return c && c.kind === 'markup' ? c.markup : null;
41
+ });
42
+ </script>
43
+
44
+ <template>
45
+ <figure
46
+ v-if="table && state === 'loaded'"
47
+ :id="anchor"
48
+ class="table-entity"
49
+ >
50
+ <TableStructured
51
+ v-if="structuredContent"
52
+ :content="structuredContent"
53
+ :locale="locale"
54
+ :fallback-chain="fallbackChain"
55
+ />
56
+ <TableMarkup
57
+ v-else-if="markup"
58
+ :content="markup"
59
+ :format="table.format"
60
+ :locale="locale"
61
+ :fallback-chain="fallbackChain"
62
+ />
63
+
64
+ <NonVerbalCaption
65
+ :identifier="table.identifier"
66
+ :caption="table.caption"
67
+ :description="table.description"
68
+ :locale="locale"
69
+ :fallback-chain="fallbackChain"
70
+ :description-id="descriptionId"
71
+ />
72
+
73
+ <NonVerbalSources
74
+ v-if="table.sources?.length"
75
+ :sources="table.sources"
76
+ />
77
+ </figure>
78
+
79
+ <NonVerbalFallback
80
+ v-else-if="state === 'loading' || state === 'not-found' || state === 'error'"
81
+ :state="state"
82
+ kind="table"
83
+ :entity-id="entityId"
84
+ :message="error ?? undefined"
85
+ />
86
+ </template>
87
+
88
+ <style scoped>
89
+ .table-entity {
90
+ margin: 1rem 0;
91
+ display: flex;
92
+ flex-direction: column;
93
+ gap: 0.5rem;
94
+ }
95
+ .table-entity:focus-visible {
96
+ outline: 2px solid var(--blue-500, #3b82f6);
97
+ outline-offset: 4px;
98
+ }
99
+ </style>