@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.112",
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(() => props.registerId);
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
- import { ref } from 'vue';
2
- import type { RenderOptions, BibResolver, NonVerbalRefResolver } from '../utils/content-renderer';
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
- export function useRenderOptions(registerId: () => string) {
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">${escapeAttr(title)}</span>`;
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.title ?? '')}">${escapeAttr(display)}</a>`;
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
- return `<span class="bib-ref" title="${escapeAttr(entry.title ?? '')}">${escapeAttr(display)}</span>`;
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}">${escapeAttr(label)}</span>`;
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)}">${escapeAttr(label)}</a>`;
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]}} → urnRefResolver / xrefResolver
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
  }