@glossarist/concept-browser 0.7.112 → 0.7.113
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glossarist/concept-browser",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.113",
|
|
4
4
|
"description": "Vue SPA for browsing Glossarist terminology datasets with cross-reference resolution, graph visualization, and multi-language support",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { computed, ref } from 'vue';
|
|
3
|
+
import { useConceptEdges } from '../../composables/use-concept-edges';
|
|
4
|
+
import { useVocabularyStore } from '../../stores/vocabulary';
|
|
5
|
+
import type { Manifest, GraphEdge } from '../../adapters/types';
|
|
6
|
+
import { makeManifest, setupPinia } from '../test-helpers';
|
|
7
|
+
import { getFactory, resetFactory } from '../../adapters/factory';
|
|
8
|
+
import { useRenderOptions } from '../../composables/use-render-options';
|
|
9
|
+
import type { Router } from 'vue-router';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* citeResolver contract — pins the {{cite:sourceId}} resolution cascade.
|
|
13
|
+
*
|
|
14
|
+
* The citeResolver must:
|
|
15
|
+
* 1. Look up the concept's own sources[] for a matching id
|
|
16
|
+
* 2. Delegate to ReferenceResolver.resolveCite() which walks the
|
|
17
|
+
* deployment's cascade
|
|
18
|
+
* 3. Render based on classification (internal → navigable link,
|
|
19
|
+
* self-contained → external link with origin.link, unresolved → span)
|
|
20
|
+
*
|
|
21
|
+
* Bug history: citeResolver was dead code (never wired). Every
|
|
22
|
+
* {{cite:foo}} mention fell through to a plain <span> with no resolution.
|
|
23
|
+
* This spec pins the wiring.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
describe('citeResolver — {{cite:sourceId}} resolution', () => {
|
|
27
|
+
const noopRouter = { push() {} } as unknown as Router;
|
|
28
|
+
let store: ReturnType<typeof useVocabularyStore>;
|
|
29
|
+
const manifest: Manifest = makeManifest({
|
|
30
|
+
id: 'test',
|
|
31
|
+
datasetUri: 'urn:test:dataset',
|
|
32
|
+
uriBase: 'https://example.org',
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
setupPinia();
|
|
37
|
+
resetFactory();
|
|
38
|
+
store = useVocabularyStore();
|
|
39
|
+
store.manifests.set(manifest.id, manifest);
|
|
40
|
+
|
|
41
|
+
const factory = getFactory();
|
|
42
|
+
factory.resolveRelatedRef = vi.fn((ref: { source: string | null; id: string | null } | null) =>
|
|
43
|
+
ref?.source === 'urn:test:dataset' && ref.id
|
|
44
|
+
? { registerId: manifest.id, conceptId: ref.id }
|
|
45
|
+
: null,
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('returns cite-ref span when no sources are provided', () => {
|
|
50
|
+
const { citeResolver } = useRenderOptions(() => 'test', () => undefined);
|
|
51
|
+
const html = citeResolver('foo', null);
|
|
52
|
+
expect(html).toContain('cite-ref');
|
|
53
|
+
expect(html).toContain('foo');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('returns cite-unresolved when source id not found in sources', () => {
|
|
57
|
+
const sources = [{ id: 'bar', origin: { ref: { source: 'Test', id: '42' } } }];
|
|
58
|
+
const { citeResolver } = useRenderOptions(() => 'test', () => sources);
|
|
59
|
+
const html = citeResolver('foo', null);
|
|
60
|
+
expect(html).toContain('cite-unresolved');
|
|
61
|
+
expect(html).toContain('foo');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('renders the label when provided', () => {
|
|
65
|
+
const sources = [{ id: 'foo', origin: { ref: { source: 'Test', id: '42' } } }];
|
|
66
|
+
const { citeResolver } = useRenderOptions(() => 'test', () => sources);
|
|
67
|
+
const html = citeResolver('foo', 'custom label');
|
|
68
|
+
// The resolver runs the cascade. Without a registered sourceRef, it
|
|
69
|
+
// falls through to unresolved or self-contained. Either way, the label
|
|
70
|
+
// or the source info should be in the output.
|
|
71
|
+
expect(html).toMatch(/custom label|Test|42/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('renders origin.link as external link for self-contained citation', () => {
|
|
75
|
+
const link = 'https://example.org/external/page';
|
|
76
|
+
const sources = [{
|
|
77
|
+
id: 'foo',
|
|
78
|
+
origin: { ref: { source: 'External', id: 'X1' }, link },
|
|
79
|
+
}];
|
|
80
|
+
const { citeResolver } = useRenderOptions(() => 'test', () => sources);
|
|
81
|
+
const html = citeResolver('foo', null);
|
|
82
|
+
// Without a registered sourceRef matching "External", the cascade
|
|
83
|
+
// falls through to self-contained or external. The link should appear.
|
|
84
|
+
expect(html).toContain('href');
|
|
85
|
+
expect(html).toContain(link);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('does NOT crash when source.origin is null', () => {
|
|
89
|
+
const sources = [{ id: 'foo', origin: null }];
|
|
90
|
+
const { citeResolver } = useRenderOptions(() => 'test', () => sources);
|
|
91
|
+
expect(() => citeResolver('foo', null)).not.toThrow();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('does NOT crash when sources is empty array', () => {
|
|
95
|
+
const { citeResolver } = useRenderOptions(() => 'test', () => []);
|
|
96
|
+
expect(() => citeResolver('foo', null)).not.toThrow();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('does NOT crash when registerId returns empty string', () => {
|
|
100
|
+
const sources = [{ id: 'foo', origin: { ref: { source: 'X', id: '1' } } }];
|
|
101
|
+
const { citeResolver } = useRenderOptions(() => '', () => sources);
|
|
102
|
+
expect(() => citeResolver('foo', null)).not.toThrow();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('RenderOptions — urnRefResolver removed', () => {
|
|
107
|
+
it('content-renderer no longer exports UrnRefResolver type', async () => {
|
|
108
|
+
// RenderOptions is a TypeScript interface (no runtime export).
|
|
109
|
+
// Verify the UrnRefResolver type is gone by checking the source.
|
|
110
|
+
const fs = await import('fs');
|
|
111
|
+
const path = await import('path');
|
|
112
|
+
const source = fs.readFileSync(
|
|
113
|
+
path.resolve(process.cwd(), 'src/utils/content-renderer.ts'),
|
|
114
|
+
'utf-8',
|
|
115
|
+
);
|
|
116
|
+
expect(source).not.toMatch(/UrnRefResolver/);
|
|
117
|
+
expect(source).not.toMatch(/urnRefResolver/);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -117,9 +117,14 @@ const conceptSources = computed(() => props.concept?.sources ?? []);
|
|
|
117
117
|
const conceptTags = computed(() => props.concept?.tags ?? []);
|
|
118
118
|
|
|
119
119
|
const factory = getFactory();
|
|
120
|
-
const { ensureBibLoaded, bibResolver, nonVerbalRefResolver } = useRenderOptions(
|
|
120
|
+
const { ready: bibReady, ensureBibLoaded, bibResolver, citeResolver, nonVerbalRefResolver } = useRenderOptions(
|
|
121
|
+
() => props.registerId,
|
|
122
|
+
() => props.concept?.sources,
|
|
123
|
+
);
|
|
121
124
|
|
|
122
|
-
const renderOpts = computed<RenderOptions>(() =>
|
|
125
|
+
const renderOpts = computed<RenderOptions>(() => {
|
|
126
|
+
bibReady.value;
|
|
127
|
+
return {
|
|
123
128
|
xrefResolver: (uri, term) => {
|
|
124
129
|
const resolution = factory.resolve(uri, props.registerId);
|
|
125
130
|
if (resolution.type === 'internal') {
|
|
@@ -139,8 +144,10 @@ const renderOpts = computed<RenderOptions>(() => ({
|
|
|
139
144
|
return `<a href="#" class="xref-link" data-register="${escapeAttr(props.registerId)}" data-concept="${escapeAttr(resolvedId)}">${escapeAttr(term)}</a>`;
|
|
140
145
|
},
|
|
141
146
|
bibResolver,
|
|
147
|
+
citeResolver,
|
|
142
148
|
nonVerbalRefResolver,
|
|
143
|
-
}
|
|
149
|
+
};
|
|
150
|
+
});
|
|
144
151
|
|
|
145
152
|
watch(() => props.registerId, () => { ensureBibLoaded(); }, { immediate: true });
|
|
146
153
|
|
|
@@ -152,7 +159,7 @@ const structuralEntityRefs = useConceptEntities(
|
|
|
152
159
|
useNonVerbalCrossRef();
|
|
153
160
|
|
|
154
161
|
function handleContentClick(e: MouseEvent) {
|
|
155
|
-
const target = (e.target as HTMLElement).closest('.xref-link') as HTMLElement | null;
|
|
162
|
+
const target = (e.target as HTMLElement).closest('.xref-link, .cite-link') as HTMLElement | null;
|
|
156
163
|
if (!target) return;
|
|
157
164
|
e.preventDefault();
|
|
158
165
|
const registerId = target.dataset.register;
|
|
@@ -1,11 +1,34 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Render-options composable — builds resolvers for the content renderer.
|
|
3
|
+
*
|
|
4
|
+
* The citeResolver is the load-bearing addition: it connects inline
|
|
5
|
+
* {{cite:sourceId}} mentions to the existing ReferenceResolver cascade
|
|
6
|
+
* (factory.resolver.resolveCite). This closes the data/deployment
|
|
7
|
+
* boundary: the data author writes {{cite:iev_702-02-07}}, the resolver
|
|
8
|
+
* walks the deployment's sourceRefs → routing → link cascade, and the
|
|
9
|
+
* renderer emits the correct HTML based on classification.
|
|
10
|
+
*/
|
|
11
|
+
import { ref, type Ref } from 'vue';
|
|
12
|
+
import type { RenderOptions, BibResolver, CiteResolver, NonVerbalRefResolver } from '../utils/content-renderer';
|
|
3
13
|
import type { NonVerbalKind } from '../adapters/non-verbal/types';
|
|
4
14
|
import { getFactory } from '../adapters/factory';
|
|
5
15
|
import { anchorId } from '../utils/non-verbal-anchor';
|
|
6
|
-
import { escapeAttr } from '../utils/escape';
|
|
16
|
+
import { escapeAttr, escapeHtml } from '../utils/escape';
|
|
7
17
|
|
|
8
|
-
|
|
18
|
+
/** Minimal shape of a ConceptSource that citeResolver needs. */
|
|
19
|
+
interface ConceptSourceLike {
|
|
20
|
+
id?: string | null;
|
|
21
|
+
origin?: {
|
|
22
|
+
ref?: { source?: string | null; id?: string | null; version?: string | null } | null;
|
|
23
|
+
locality?: { type?: string | null; reference_from?: string | null; referenceTo?: string | null; reference_to?: string | null; referenceFrom?: string | null } | null;
|
|
24
|
+
link?: string | null;
|
|
25
|
+
} | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function useRenderOptions(
|
|
29
|
+
registerId: () => string,
|
|
30
|
+
getSources?: () => readonly ConceptSourceLike[] | undefined,
|
|
31
|
+
) {
|
|
9
32
|
const ready = ref(false);
|
|
10
33
|
|
|
11
34
|
async function ensureBibLoaded() {
|
|
@@ -19,28 +42,80 @@ export function useRenderOptions(registerId: () => string) {
|
|
|
19
42
|
const id = registerId();
|
|
20
43
|
const entry = id ? getFactory().bibliography(id).findById(refId) : null;
|
|
21
44
|
if (!entry) {
|
|
22
|
-
return `<span class="bib-ref">${
|
|
45
|
+
return `<span class="bib-ref">${escapeHtml(title)}</span>`;
|
|
23
46
|
}
|
|
24
47
|
const display = title || entry.reference || refId;
|
|
25
48
|
if (entry.link) {
|
|
26
|
-
return `<a href="${escapeAttr(entry.link)}" target="_blank" rel="noopener" class="bib-link" title="${escapeAttr(entry.
|
|
49
|
+
return `<a href="${escapeAttr(entry.link)}" target="_blank" rel="noopener" class="bib-link" title="${escapeAttr(entry.reference ?? '')}">${escapeHtml(display)}</a>`;
|
|
50
|
+
}
|
|
51
|
+
return `<span class="bib-ref" title="${escapeAttr(entry.reference ?? '')}">${escapeHtml(display)}</span>`;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* citeResolver — the canonical path for {{cite:sourceId}} mentions.
|
|
56
|
+
*
|
|
57
|
+
* Looks up the concept's own sources[] for a matching id, then
|
|
58
|
+
* delegates to ReferenceResolver.resolveCite() which walks the
|
|
59
|
+
* deployment's cascade (sourceRefs → routing → link → unresolved).
|
|
60
|
+
*/
|
|
61
|
+
const citeResolver: CiteResolver = (key, label) => {
|
|
62
|
+
const rid = registerId();
|
|
63
|
+
const sources = getSources?.();
|
|
64
|
+
if (!sources || !rid) {
|
|
65
|
+
return `<span class="cite-ref">${escapeHtml(label ?? key)}</span>`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const source = sources.find(s => s.id === key);
|
|
69
|
+
if (!source?.origin) {
|
|
70
|
+
return `<span class="cite-unresolved">${escapeHtml(label ?? key)}</span>`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const factory = getFactory();
|
|
74
|
+
const resolution = factory.resolver.resolveCite(source.origin, rid);
|
|
75
|
+
const refId = source.origin.ref?.id ?? key;
|
|
76
|
+
const refSource = source.origin.ref?.source ?? '';
|
|
77
|
+
const display = label ?? refId;
|
|
78
|
+
|
|
79
|
+
switch (resolution.classification) {
|
|
80
|
+
case 'internal-citation': {
|
|
81
|
+
if (!resolution.resolved) break;
|
|
82
|
+
const { registerId: targetReg, conceptId } = resolution.resolved;
|
|
83
|
+
const isCross = targetReg !== rid;
|
|
84
|
+
const crossBadge = isCross ? '<span class="text-[9px] opacity-60">↗</span>' : '';
|
|
85
|
+
return `<a href="#" class="cite-link" data-register="${escapeAttr(targetReg)}" data-concept="${escapeAttr(conceptId)}">${escapeHtml(display)} ${crossBadge}</a>`;
|
|
86
|
+
}
|
|
87
|
+
case 'self-contained-citation': {
|
|
88
|
+
const link = source.origin.link;
|
|
89
|
+
if (link) {
|
|
90
|
+
return `<a href="${escapeAttr(link)}" target="_blank" rel="noopener" class="cite-link cite-external">${escapeHtml(refSource || display)} <span class="text-[9px] opacity-60">↗</span></a>`;
|
|
91
|
+
}
|
|
92
|
+
return `<span class="cite-ref">${escapeHtml(refSource || display)}</span>`;
|
|
93
|
+
}
|
|
94
|
+
case 'external-citation': {
|
|
95
|
+
const link = source.origin.link;
|
|
96
|
+
if (link) {
|
|
97
|
+
return `<a href="${escapeAttr(link)}" target="_blank" rel="noopener" class="cite-link cite-external">${escapeHtml(refSource || display)} <span class="text-[9px] opacity-60">↗</span></a>`;
|
|
98
|
+
}
|
|
99
|
+
return `<span class="cite-ref">${escapeHtml(refSource || display)}</span>`;
|
|
100
|
+
}
|
|
27
101
|
}
|
|
28
|
-
|
|
102
|
+
|
|
103
|
+
return `<span class="cite-unresolved">${escapeHtml(display)}</span>`;
|
|
29
104
|
};
|
|
30
105
|
|
|
31
106
|
const nonVerbalRefResolver: NonVerbalRefResolver = (kind: NonVerbalKind, entityId, display) => {
|
|
32
107
|
const id = registerId();
|
|
33
108
|
if (!id) {
|
|
34
109
|
const label = display ?? entityId;
|
|
35
|
-
return `<span class="nv-ref nv-ref--${kind}">${
|
|
110
|
+
return `<span class="nv-ref nv-ref--${kind}">${escapeHtml(label)}</span>`;
|
|
36
111
|
}
|
|
37
112
|
const href = `#${anchorId(kind, id, entityId)}`;
|
|
38
113
|
const label = display ?? defaultLabelForKind(kind, entityId);
|
|
39
114
|
const cls = `nv-ref nv-ref--${kind}`;
|
|
40
|
-
return `<a href="${href}" class="${cls}" data-nv-kind="${escapeAttr(kind)}" data-nv-dataset="${escapeAttr(id)}" data-nv-entity="${escapeAttr(entityId)}">${
|
|
115
|
+
return `<a href="${href}" class="${cls}" data-nv-kind="${escapeAttr(kind)}" data-nv-dataset="${escapeAttr(id)}" data-nv-entity="${escapeAttr(entityId)}">${escapeHtml(label)}</a>`;
|
|
41
116
|
};
|
|
42
117
|
|
|
43
|
-
return { ready, ensureBibLoaded, bibResolver, nonVerbalRefResolver };
|
|
118
|
+
return { ready, ensureBibLoaded, bibResolver, citeResolver, nonVerbalRefResolver };
|
|
44
119
|
}
|
|
45
120
|
|
|
46
121
|
function defaultLabelForKind(kind: NonVerbalKind, entityId: string): string {
|
|
@@ -50,3 +125,6 @@ function defaultLabelForKind(kind: NonVerbalKind, entityId: string): string {
|
|
|
50
125
|
case 'formula': return `Formula ${entityId}`;
|
|
51
126
|
}
|
|
52
127
|
}
|
|
128
|
+
|
|
129
|
+
/** Ensure `ready` is tracked as a Ref for consumers that read `.value`. */
|
|
130
|
+
export type RenderOptionsReady = Ref<boolean>;
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* - {{table:id}} / {{table:id, display}} → nonVerbalRefResolver (table)
|
|
11
11
|
* - {{formula:id}} / {{formula:id, display}} → nonVerbalRefResolver (formula)
|
|
12
12
|
* - {{cite:key[, render term]}} → citeResolver
|
|
13
|
-
* - {{urn:...[, render term]}} →
|
|
13
|
+
* - {{urn:...[, render term]}} → xrefResolver
|
|
14
14
|
* - {{concept_id[, render term]}} → conceptRefResolver
|
|
15
15
|
* - {{designation[, render term]}} → conceptRefResolver
|
|
16
16
|
*
|
|
@@ -28,7 +28,6 @@ export type XrefResolver = (uri: string, term: string) => string;
|
|
|
28
28
|
export type BibResolver = (refId: string, title: string) => string;
|
|
29
29
|
export type CiteResolver = (key: string, label: string | null) => string;
|
|
30
30
|
export type ConceptRefResolver = (conceptId: string, term: string) => string;
|
|
31
|
-
export type UrnRefResolver = (uri: string, term: string) => string;
|
|
32
31
|
export type NonVerbalRefResolver = (kind: NonVerbalKind, entityId: string, display?: string) => string;
|
|
33
32
|
|
|
34
33
|
export interface RenderOptions {
|
|
@@ -36,7 +35,6 @@ export interface RenderOptions {
|
|
|
36
35
|
bibResolver?: BibResolver;
|
|
37
36
|
conceptRefResolver?: ConceptRefResolver;
|
|
38
37
|
citeResolver?: CiteResolver;
|
|
39
|
-
urnRefResolver?: UrnRefResolver;
|
|
40
38
|
nonVerbalRefResolver?: NonVerbalRefResolver;
|
|
41
39
|
}
|
|
42
40
|
|
|
@@ -209,7 +207,6 @@ function resolveMentions(text: string, opts: RenderOptions): string {
|
|
|
209
207
|
case 'urn-ref': {
|
|
210
208
|
const uri = p.uri as string;
|
|
211
209
|
const label = (p.label as string) ?? uri;
|
|
212
|
-
if (opts.urnRefResolver) return opts.urnRefResolver(uri, label);
|
|
213
210
|
if (opts.xrefResolver) return opts.xrefResolver(uri, label);
|
|
214
211
|
return escapeHtml(label);
|
|
215
212
|
}
|