@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.
- package/cli/index.mjs +12 -13
- package/package.json +3 -2
- package/scripts/__tests__/fetch-datasets.test.mjs +105 -0
- package/scripts/fetch-datasets.mjs +53 -51
- package/scripts/generate-data.mjs +41 -19
- package/scripts/lib/build/image-assets.mjs +190 -0
- package/scripts/lib/build/non-verbal-consumer.mjs +221 -0
- package/scripts/lib/local-path-safety.mjs +68 -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,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>
|