@glossarist/concept-browser 0.7.112 → 0.7.114

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/README.md CHANGED
@@ -293,6 +293,48 @@ The page appears in the sidebar navigation automatically.
293
293
 
294
294
  See [`site-config.example.yml`](site-config.example.yml) for all options including UI languages, fonts, features, and dataset groups.
295
295
 
296
+ ## Data/Deployment Boundary
297
+
298
+ Concept-browser enforces a strict separation between **data** (authored by dataset authors) and **deployment** (configured by deployers):
299
+
300
+ - **Dataset authors** write concepts, sources, bibliography, and inline mentions. They don't know where their dataset will be deployed or what other datasets will be co-deployed.
301
+ - **Deployers** write `site-config.yml` to register datasets, declare URI patterns, and optionally add routing entries for external datasets. They never edit dataset content.
302
+ - **Concept-browser** resolves every cross-reference at runtime via a fixed cascade, making multiple datasets behave as one coherent whole.
303
+
304
+ ### The resolution cascade
305
+
306
+ Every `{{cite:...}}` and `{{urn:...}}` mention walks this cascade at render time:
307
+
308
+ 1. **`uriPatterns`** — is there a co-deployed dataset that matches? → internal link (case 1)
309
+ 2. **`routing[]`** — is there a routing entry for the URI? → external link (case 2)
310
+ 3. **`citation.link`** — does the source have a canonical link? → flat bib record (case 3)
311
+ 4. **Unresolved** — plain text
312
+
313
+ The same YAML renders differently in different deployments. The data never changes.
314
+
315
+ ## Inline Content Syntax
316
+
317
+ All inline references in concept text use the unified `{{kind:target}}` notation:
318
+
319
+ | Kind | Example | What it does |
320
+ |---|---|---|
321
+ | `cite` | `{{cite:sourceId}}` | Cite a `ConceptSource` from this concept's `sources[]` — walks the full resolution cascade |
322
+ | `urn` | `{{urn:iso:std:iso:704}}` | Reference a concept via URN routing |
323
+ | `fig` | `{{fig:diagram_3}}` | Reference a figure entity in the same dataset |
324
+ | `table` | `{{table:units}}` | Reference a table entity |
325
+ | `formula` | `{{formula:ohm_law}}` | Reference a formula entity |
326
+ | `bib` | `{{bib:ref_1}}` | Reference a bibliography entry (case-3-only, no underlying concept) |
327
+ | `link` | `{{link:https://example.com}}` | External URL (canonical, deployment-independent) |
328
+ | `image` | `{{image:src, alt}}` | Inline image embed |
329
+ | *(none)* | `{{measurement unit}}` | Designation match in same dataset |
330
+ | *(none)* | `{{112-01-10}}` | Numeric ID match in same dataset |
331
+
332
+ Each kind accepts an optional label: `{{kind:target, label}}`.
333
+
334
+ **Deprecated:** `<<ref,title>>` (AsciiDoc xref syntax) emits a deprecation warning. Migrate to `{{kind:target}}`.
335
+
336
+ See `/learn/inline-content` on any deployed site for a full interactive reference.
337
+
296
338
  ## CLI Commands
297
339
 
298
340
  ```
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.114",
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
+ });
@@ -0,0 +1,139 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { renderContent, cleanContent, type RenderOptions } from '../../utils/content-renderer';
3
+
4
+ /**
5
+ * Unified mention syntax contract — {{kind:target[, label]}}.
6
+ *
7
+ * Pins the dispatch for every kind and the deprecation behavior for
8
+ * the legacy <<ref,title>> AsciiDoc xref syntax.
9
+ */
10
+
11
+ describe('content-renderer — unified mention kinds', () => {
12
+
13
+ describe('{{link:URL}} — external link', () => {
14
+ it('renders bare URL as external anchor', () => {
15
+ const html = renderContent('{{link:https://example.com}}');
16
+ expect(html).toContain('<a href="https://example.com"');
17
+ expect(html).toContain('target="_blank"');
18
+ expect(html).toContain('rel="noopener"');
19
+ expect(html).toContain('https://example.com');
20
+ });
21
+
22
+ it('renders URL with label', () => {
23
+ const html = renderContent('{{link:https://example.com, click here}}');
24
+ expect(html).toContain('click here');
25
+ expect(html).toContain('href="https://example.com"');
26
+ });
27
+
28
+ it('delegates to linkResolver when provided', () => {
29
+ const opts: RenderOptions = { linkResolver: (url, label) => `<a href="${url}" class="custom">${label}</a>` };
30
+ const html = renderContent('{{link:https://example.com, test}}', opts);
31
+ expect(html).toContain('class="custom"');
32
+ expect(html).toContain('test');
33
+ });
34
+ });
35
+
36
+ describe('{{image:src}} — inline image embed', () => {
37
+ it('renders bare src as img tag', () => {
38
+ const html = renderContent('{{image:photo.png}}');
39
+ expect(html).toContain('<img');
40
+ expect(html).toContain('src="photo.png"');
41
+ });
42
+
43
+ it('renders src with alt text', () => {
44
+ const html = renderContent('{{image:photo.png, A photo of the setup}}');
45
+ expect(html).toContain('src="photo.png"');
46
+ expect(html).toContain('alt="A photo of the setup"');
47
+ });
48
+
49
+ it('delegates to imageResolver when provided', () => {
50
+ const opts: RenderOptions = { imageResolver: (src, alt) => `<img src="/base/${src}" alt="${alt}" class="custom-img" />` };
51
+ const html = renderContent('{{image:photo.png, test}}', opts);
52
+ expect(html).toContain('src="/base/photo.png"');
53
+ expect(html).toContain('class="custom-img"');
54
+ });
55
+ });
56
+
57
+ describe('{{bib:id}} — bibliography entry', () => {
58
+ it('renders id as bib-ref span when no resolver', () => {
59
+ const html = renderContent('{{bib:ref_1}}');
60
+ expect(html).toContain('bib-ref');
61
+ expect(html).toContain('ref_1');
62
+ });
63
+
64
+ it('renders label when provided', () => {
65
+ const html = renderContent('{{bib:ref_1, ISO 704}}');
66
+ expect(html).toContain('ISO 704');
67
+ });
68
+
69
+ it('delegates to bibResolver when provided', () => {
70
+ const opts: RenderOptions = { bibResolver: (id, title) => `<a href="/bib/${id}" class="bib-link">${title}</a>` };
71
+ const html = renderContent('{{bib:ref_1, ISO 704}}', opts);
72
+ expect(html).toContain('href="/bib/ref_1"');
73
+ expect(html).toContain('ISO 704');
74
+ });
75
+ });
76
+
77
+ describe('cleanContent — new kinds stripped correctly', () => {
78
+ it('strips {{link:URL}} to the URL', () => {
79
+ expect(cleanContent('{{link:https://example.com}}')).toBe('https://example.com');
80
+ });
81
+
82
+ it('strips {{link:URL, label}} to the label', () => {
83
+ expect(cleanContent('{{link:https://example.com, click here}}')).toBe('click here');
84
+ });
85
+
86
+ it('strips {{image:src}} to empty string', () => {
87
+ expect(cleanContent('{{image:photo.png}}')).toBe('');
88
+ });
89
+
90
+ it('strips {{image:src, alt}} to the alt text', () => {
91
+ expect(cleanContent('{{image:photo.png, A photo}}')).toBe('A photo');
92
+ });
93
+
94
+ it('strips {{bib:id}} to the id', () => {
95
+ expect(cleanContent('{{bib:ref_1}}')).toBe('ref_1');
96
+ });
97
+
98
+ it('strips {{bib:id, label}} to the label', () => {
99
+ expect(cleanContent('{{bib:ref_1, ISO 704}}')).toBe('ISO 704');
100
+ });
101
+ });
102
+ });
103
+
104
+ describe('content-renderer — legacy <<ref,title>> deprecation', () => {
105
+ afterEach(() => {
106
+ vi.restoreAllMocks();
107
+ });
108
+
109
+ it('emits a deprecation warning via console.warn', () => {
110
+ const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
111
+ renderContent('See <<845-01-01, 845-01-01>> for details.');
112
+ expect(spy).toHaveBeenCalledTimes(1);
113
+ expect(spy.mock.calls[0][0]).toContain('deprecated');
114
+ expect(spy.mock.calls[0][0]).toContain('<<845-01-01,845-01-01>>');
115
+ });
116
+
117
+ it('renders the title as plain text in a legacy-xref span', () => {
118
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
119
+ const html = renderContent('See <<845-01-01, 845-01-01>> for details.');
120
+ expect(html).toContain('legacy-xref');
121
+ expect(html).toContain('845-01-01');
122
+ // Must NOT contain bib-ref or bib-link (the old wrong behavior)
123
+ expect(html).not.toContain('bib-ref');
124
+ expect(html).not.toContain('bib-link');
125
+ });
126
+
127
+ it('does NOT call bibResolver (cascade is not bypassed)', () => {
128
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
129
+ const bibResolver = vi.fn(() => '<a class="bib-link">mock</a>');
130
+ const opts: RenderOptions = { bibResolver };
131
+ const html = renderContent('<<845-01-01, 845-01-01>>', opts);
132
+ expect(bibResolver).not.toHaveBeenCalled();
133
+ expect(html).not.toContain('bib-link');
134
+ });
135
+
136
+ it('cleanContent extracts the title from <<ref,title>>', () => {
137
+ expect(cleanContent('See <<845-01-01, 845-01-01>>')).toContain('845-01-01');
138
+ });
139
+ });
@@ -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>;
@@ -17,6 +17,7 @@ export async function getStaticPaths() {
17
17
  { params: { path: 'learn/relationships' } },
18
18
  { params: { path: 'learn/designations' } },
19
19
  { params: { path: 'learn/statuses' } },
20
+ { params: { path: 'learn/inline-content' } },
20
21
  ];
21
22
  for (const ds of datasets) {
22
23
  paths.push({ params: { path: `dataset/${ds.data.id}/about` } });
@@ -118,6 +118,11 @@ export const routes: RouteRecordRaw[] = [
118
118
  name: 'learn-statuses',
119
119
  component: () => import('../views/learn/LearnStatusesView.vue'),
120
120
  },
121
+ {
122
+ path: '/learn/inline-content',
123
+ name: 'learn-inline-content',
124
+ component: () => import('../views/learn/LearnInlineContentView.vue'),
125
+ },
121
126
  {
122
127
  path: '/about',
123
128
  name: 'about-global',
@@ -5,14 +5,22 @@
5
5
  * math placeholders, tables, lists, and text formatting. This is the single
6
6
  * source of truth for content rendering in the browser.
7
7
  *
8
- * Mention kinds routed here (all dispatched by glossarist's `parseMention`):
9
- * - {{fig:id}} / {{fig:id, display}} → nonVerbalRefResolver (figure)
10
- * - {{table:id}} / {{table:id, display}} → nonVerbalRefResolver (table)
11
- * - {{formula:id}} / {{formula:id, display}} → nonVerbalRefResolver (formula)
12
- * - {{cite:key[, render term]}} → citeResolver
13
- * - {{urn:...[, render term]}} → urnRefResolver / xrefResolver
14
- * - {{concept_id[, render term]}} → conceptRefResolver
15
- * - {{designation[, render term]}} → conceptRefResolver
8
+ * Unified mention syntax: {{kind:target[, label]}}
9
+ * Every inline reference is a typed mention with a kind prefix:
10
+ *
11
+ * - {{cite:key[, label]}} → citeResolver (concept source citation)
12
+ * - {{urn:...[, label]}} → xrefResolver (URN routing)
13
+ * - {{fig:id[, label]}} → nonVerbalRefResolver (figure)
14
+ * - {{table:id[, label]}} → nonVerbalRefResolver (table)
15
+ * - {{formula:id[, label]}} → nonVerbalRefResolver (formula)
16
+ * - {{bib:id[, label]}} → bibResolver (bibliography entry, case-3-only)
17
+ * - {{link:URL[, label]}} → linkResolver (external URL)
18
+ * - {{image:src[, alt]}} → imageResolver (inline image embed)
19
+ * - {{designation[, label]}} → conceptRefResolver (designation match)
20
+ * - {{numeric_id[, label]}} → conceptRefResolver (numeric ID match)
21
+ *
22
+ * Legacy `<<ref,title>>` (AsciiDoc xref) is deprecated; emits a console
23
+ * warning and renders as plain text. Migrate to {{kind:target}} syntax.
16
24
  *
17
25
  * Math-specific helpers (replaceBracketed, mathPlaceholder) are internal.
18
26
  * The v-math directive upgrades the placeholders to Plurimath at runtime.
@@ -28,16 +36,18 @@ export type XrefResolver = (uri: string, term: string) => string;
28
36
  export type BibResolver = (refId: string, title: string) => string;
29
37
  export type CiteResolver = (key: string, label: string | null) => string;
30
38
  export type ConceptRefResolver = (conceptId: string, term: string) => string;
31
- export type UrnRefResolver = (uri: string, term: string) => string;
32
39
  export type NonVerbalRefResolver = (kind: NonVerbalKind, entityId: string, display?: string) => string;
40
+ export type LinkResolver = (url: string, label: string) => string;
41
+ export type ImageResolver = (src: string, alt: string) => string;
33
42
 
34
43
  export interface RenderOptions {
35
44
  xrefResolver?: XrefResolver;
36
45
  bibResolver?: BibResolver;
37
46
  conceptRefResolver?: ConceptRefResolver;
38
47
  citeResolver?: CiteResolver;
39
- urnRefResolver?: UrnRefResolver;
40
48
  nonVerbalRefResolver?: NonVerbalRefResolver;
49
+ linkResolver?: LinkResolver;
50
+ imageResolver?: ImageResolver;
41
51
  }
42
52
 
43
53
  // ── Math placeholders ────────────────────────────────────────────────────
@@ -146,12 +156,30 @@ function convertLists(text: string): string {
146
156
 
147
157
  // ── Inline reference resolution ──────────────────────────────────────────
148
158
 
149
- function resolveBibRefs(text: string, opts: RenderOptions): string {
159
+ /**
160
+ * Deprecated AsciiDoc xref syntax: <<refId, title>>
161
+ *
162
+ * This was historically overloaded for:
163
+ * 1. Non-concept entity xrefs (should use {{fig/table/formula:id, label}})
164
+ * 2. Bibliography lookups (should use {{bib:id}} or {{cite:id}})
165
+ * 3. Concept citations (should use {{cite:id}})
166
+ *
167
+ * All three uses are wrong. This function emits a deprecation warning and
168
+ * renders the title as plain text. Datasets should migrate to the unified
169
+ * {{kind:target}} syntax.
170
+ */
171
+ function resolveLegacyXref(text: string): string {
150
172
  return text.replace(/<<([^,>]+),([^>]+)>>/g, (_, refId, title) => {
151
- if (opts.bibResolver) {
152
- return opts.bibResolver(refId.trim(), title.trim());
173
+ const rid = refId.trim();
174
+ const lbl = title.trim();
175
+ if (typeof console !== 'undefined') {
176
+ console.warn(
177
+ `[glossarist] <<${rid},${lbl}>> is deprecated. ` +
178
+ `Use {{fig/table/formula:${rid}, ${lbl}}} for non-concept entities, ` +
179
+ `{{cite:${rid}}} for concept citations, or {{bib:${rid}}} for bibliography.`,
180
+ );
153
181
  }
154
- return `<span class="bib-ref">${escapeHtml(title.trim())}</span>`;
182
+ return `<span class="legacy-xref" title="Deprecated: use {{kind:target}} syntax">${escapeHtml(lbl)}</span>`;
155
183
  });
156
184
  }
157
185
 
@@ -182,6 +210,45 @@ function resolveUrnRefs(text: string, opts: RenderOptions): string {
182
210
 
183
211
  function resolveMentions(text: string, opts: RenderOptions): string {
184
212
  return text.replace(/\{\{([^{}]+?)\}\}/g, (_orig, body) => {
213
+
214
+ // ── New kinds: link, image, bib (pre-parsed before parseMention) ──
215
+ // These follow the same {{kind:target[, label]}} convention as
216
+ // fig/table/formula but are handled here because glossarist's
217
+ // parseMention doesn't recognize them yet. When glossarist-js adds
218
+ // support (per PROMPT-NOW.md), these pre-parse cases can be removed
219
+ // and the switch below will handle them natively.
220
+
221
+ const linkMatch = body.match(/^link:(.+)$/i);
222
+ if (linkMatch) {
223
+ const rest = linkMatch[1];
224
+ const commaIdx = rest.indexOf(',');
225
+ const url = (commaIdx > 0 ? rest.slice(0, commaIdx).trim() : rest.trim());
226
+ const label = commaIdx > 0 ? rest.slice(commaIdx + 1).trim() : url;
227
+ if (opts.linkResolver) return opts.linkResolver(url, label);
228
+ return `<a href="${escapeAttr(url)}" target="_blank" rel="noopener" class="ext-link">${escapeHtml(label)}</a>`;
229
+ }
230
+
231
+ const imageMatch = body.match(/^image:(.+)$/i);
232
+ if (imageMatch) {
233
+ const rest = imageMatch[1];
234
+ const commaIdx = rest.indexOf(',');
235
+ const src = (commaIdx > 0 ? rest.slice(0, commaIdx).trim() : rest.trim());
236
+ const alt = commaIdx > 0 ? rest.slice(commaIdx + 1).trim() : '';
237
+ if (opts.imageResolver) return opts.imageResolver(src, alt);
238
+ return `<img src="${escapeAttr(src)}" alt="${escapeAttr(alt)}" class="inline-image" />`;
239
+ }
240
+
241
+ const bibMatch = body.match(/^bib:(.+)$/i);
242
+ if (bibMatch) {
243
+ const rest = bibMatch[1];
244
+ const commaIdx = rest.indexOf(',');
245
+ const id = (commaIdx > 0 ? rest.slice(0, commaIdx).trim() : rest.trim());
246
+ const label = commaIdx > 0 ? rest.slice(commaIdx + 1).trim() : id;
247
+ if (opts.bibResolver) return opts.bibResolver(id, label);
248
+ return `<span class="bib-ref">${escapeHtml(label)}</span>`;
249
+ }
250
+
251
+ // ── Existing kinds: handled by glossarist's parseMention ──
185
252
  const parsed = parseMention(body);
186
253
  const p = parsed as unknown as Record<string, unknown>;
187
254
 
@@ -209,7 +276,6 @@ function resolveMentions(text: string, opts: RenderOptions): string {
209
276
  case 'urn-ref': {
210
277
  const uri = p.uri as string;
211
278
  const label = (p.label as string) ?? uri;
212
- if (opts.urnRefResolver) return opts.urnRefResolver(uri, label);
213
279
  if (opts.xrefResolver) return opts.xrefResolver(uri, label);
214
280
  return escapeHtml(label);
215
281
  }
@@ -256,10 +322,10 @@ function resolveMentions(text: string, opts: RenderOptions): string {
256
322
  * 2. AsciiDoc tables
257
323
  * 3. Bullet and numbered lists
258
324
  * 4. Text formatting (bold, italic, subscript)
259
- * 5. Bibliography cross-references (<<ref,title>>)
260
- * 6. Single-brace URN inline references ({urn:...})
261
- * 7. Mention dispatcher — non-verbal (fig/table/formula), then parseMention
262
- * (cite-ref, urn-ref, numeric, designation)
325
+ * 5. Legacy AsciiDoc xrefs (<<ref,title>> → deprecated, renders as plain text)
326
+ * 6. URN inline references ({urn:...})
327
+ * 7. Mention dispatcher — link/image/bib (pre-parsed), then parseMention
328
+ * (fig/table/formula, cite-ref, urn-ref, numeric, designation)
263
329
  */
264
330
  export function renderContent(text: string, xrefResolverOrOpts?: XrefResolver | RenderOptions): string {
265
331
  if (!text) return '';
@@ -283,7 +349,7 @@ export function renderContent(text: string, xrefResolverOrOpts?: XrefResolver |
283
349
  result = result.replace(/~([^~]+)~/g, '<sub>$1</sub>');
284
350
 
285
351
  // Stage 4: Reference resolution
286
- result = resolveBibRefs(result, opts);
352
+ result = resolveLegacyXref(result);
287
353
  result = resolveUrnRefs(result, opts);
288
354
 
289
355
  // Stage 5: Mention dispatcher (non-verbal first, then parseMention SSOT)
@@ -299,18 +365,28 @@ export function renderContent(text: string, xrefResolverOrOpts?: XrefResolver |
299
365
  export function cleanContent(text: string): string {
300
366
  if (!text) return '';
301
367
  let result = text
368
+ // Legacy xrefs first (before HTML tag stripping eats <<)
369
+ .replace(/<<([^,>]+),([^>]+)>>/g, '$2')
302
370
  .replace(/<[^>]+>/g, '')
303
371
  .replace(/\*\*([^*]+)\*\*/g, '$1')
304
372
  .replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '$1')
305
373
  .replace(/~([^~]+)~/g, '_$1')
306
374
  .replace(/\n[ \t]*\* /g, '; ')
307
- .replace(/<<([^,>]+),([^>]+)>>/g, '$2')
308
- // Non-verbal mentions: {{fig:id, display}} → display; {{fig:id}} → id
375
+ // Entity mentions: {{fig:id, display}} → display; {{fig:id}} → id
309
376
  .replace(/\{\{(?:fig|figure|table|tbl|formula|eq):([^,}]+),\s*([^}]+)\}\}/g, '$2')
310
377
  .replace(/\{\{(?:fig|figure|table|tbl|formula|eq):([^}]+)\}\}/g, '$1')
378
+ // Link mentions: {{link:URL, label}} → label; {{link:URL}} → URL
379
+ .replace(/\{\{link:([^,}]+),\s*([^}]+)\}\}/gi, '$2')
380
+ .replace(/\{\{link:([^}]+)\}\}/gi, '$1')
381
+ // Image mentions: {{image:src, alt}} → alt; {{image:src}} → empty
382
+ .replace(/\{\{image:[^,}]+,\s*([^}]+)\}\}/gi, '$1')
383
+ .replace(/\{\{image:([^}]+)\}\}/gi, '')
384
+ // Bib mentions: {{bib:id, label}} → label; {{bib:id}} → id
385
+ .replace(/\{\{bib:([^,}]+),\s*([^}]+)\}\}/gi, '$2')
386
+ .replace(/\{\{bib:([^}]+)\}\}/gi, '$1')
311
387
  // URN refs — show render term (second part for two-arg, third part for three-arg)
312
- .replace(/\{\{urn:[^,}]+,([^,}]+),([^}]+)\}\}/g, '$1') // three-arg: {{urn:...,term,display}} → term
313
- .replace(/\{\{urn:[^,}]+(?:,([^}]+))?\}\}/g, (_, label) => label ? label.trim() : '') // two-arg or bare
388
+ .replace(/\{\{urn:[^,}]+,([^,}]+),([^}]+)\}\}/g, '$1')
389
+ .replace(/\{\{urn:[^,}]+(?:,([^}]+))?\}\}/g, (_, label) => label ? label.trim() : '')
314
390
  .replace(/\{urn:[^,}]+,([^,}]+)(?:,[^}]+)?\}/g, '$1')
315
391
  // Cite refs — show render term (or empty if bare)
316
392
  .replace(/\{\{cite:[^,}]+(?:,([^}]+))?\}\}/g, (_, label) => label ? label.trim() : '')
@@ -0,0 +1,128 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * LearnInlineContentView — unified inline content syntax reference.
4
+ *
5
+ * Documents every {{kind:target}} mention kind with examples,
6
+ * explains the data/deployment boundary for citations, and shows
7
+ * the resolution cascade.
8
+ */
9
+ import { computed } from 'vue';
10
+ import { useI18n } from '../../i18n';
11
+ import LearnCard from '../../components/learn/LearnCard.vue';
12
+
13
+ const { t } = useI18n();
14
+
15
+ const introText = computed(() =>
16
+ t('learn.inlineContent.intro') ||
17
+ 'Every inline reference in concept text uses the unified mustache-kind:target notation. The first word (before the colon) tells you what kind of reference it is.',
18
+ );
19
+
20
+ const mentionKinds = [
21
+ { kind: 'cite', syntax: '{{cite:sourceId}}', example: '{{cite:iso7301}}', desc: 'Cite a ConceptSource from this concept\'s sources[] list. Walks the full resolution cascade (co-deployed → routed → bib).' },
22
+ { kind: 'cite + label', syntax: '{{cite:sourceId, label}}', example: '{{cite:iso7301, rice}}', desc: 'Same as cite, with an explicit display label.' },
23
+ { kind: 'urn', syntax: '{{urn:URN}}', example: '{{urn:iso:std:iso:704}}', desc: 'Reference a concept via URN routing. The deployment decides which dataset (if any) handles this URN.' },
24
+ { kind: 'fig', syntax: '{{fig:id}}', example: '{{fig:diagram_3}}', desc: 'Reference a figure entity in the same dataset.' },
25
+ { kind: 'table', syntax: '{{table:id}}', example: '{{table:unit_list}}', desc: 'Reference a table entity.' },
26
+ { kind: 'formula', syntax: '{{formula:id}}', example: '{{formula:ohm_law}}', desc: 'Reference a formula entity.' },
27
+ { kind: 'bib', syntax: '{{bib:id}}', example: '{{bib:ref_1}}', desc: 'Bibliography entry (case-3-only — no underlying concept). Renders as a flat reference.' },
28
+ { kind: 'link', syntax: '{{link:URL}}', example: '{{link:https://example.com}}', desc: 'External URL. The URL is canonical; the deployment has no say.' },
29
+ { kind: 'link + label', syntax: '{{link:URL, label}}', example: '{{link:https://example.com, click here}}', desc: 'External URL with display label.' },
30
+ { kind: 'image', syntax: '{{image:src}}', example: '{{image:diagram.png}}', desc: 'Inline image embed. Local images get basePath-prefixed.' },
31
+ { kind: 'image + alt', syntax: '{{image:src, alt}}', example: '{{image:diagram.png, The diagram}}', desc: 'Image with accessibility alt text.' },
32
+ { kind: 'designation', syntax: '{{designation}}', example: '{{measurement unit}}', desc: 'Reference a concept by matching its designation text (same dataset).' },
33
+ { kind: 'numeric', syntax: '{{numeric_id}}', example: '{{112-01-10}}', desc: 'Reference a concept by numeric ID (same dataset).' },
34
+ ];
35
+
36
+ const cascade = [
37
+ { step: 1, name: 'uriPatterns', desc: 'Is there a co-deployed dataset that matches this URI?', result: 'Internal link (case 1)' },
38
+ { step: 2, name: 'routing[]', desc: 'Is there a routing entry for this URI in site-config.yml?', result: 'External link (case 2)' },
39
+ { step: 3, name: 'citation.link', desc: 'Does the source have a canonical link?', result: 'Flat bib record (case 3)' },
40
+ { step: 4, name: 'unresolved', desc: 'No match anywhere', result: 'Plain text' },
41
+ ];
42
+ </script>
43
+
44
+ <template>
45
+ <div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
46
+ <h1 class="font-title text-3xl text-ink-800 dark:text-ink-50 mb-2">
47
+ {{ t('learn.inlineContent.title') || 'Inline Content Syntax' }}
48
+ </h1>
49
+ <p class="text-ink-500 dark:text-ink-400">
50
+ {{ introText }}
51
+ </p>
52
+
53
+ <LearnCard title="Mention Kinds">
54
+ <template #body>
55
+ <div class="overflow-x-auto">
56
+ <table class="w-full text-sm">
57
+ <thead>
58
+ <tr class="border-b border-ink-100 dark:border-ink-700 text-left">
59
+ <th class="py-2 pr-4">Kind</th>
60
+ <th class="py-2 pr-4">Syntax</th>
61
+ <th class="py-2 pr-4">Example</th>
62
+ <th class="py-2">Description</th>
63
+ </tr>
64
+ </thead>
65
+ <tbody>
66
+ <tr v-for="row in mentionKinds" :key="row.syntax" class="border-b border-ink-50 dark:border-ink-800">
67
+ <td class="py-2 pr-4 font-mono text-xs text-ink-600 dark:text-ink-300">{{ row.kind }}</td>
68
+ <td class="py-2 pr-4 font-mono text-xs text-ink-700 dark:text-ink-200">{{ row.syntax }}</td>
69
+ <td class="py-2 pr-4 font-mono text-xs text-emerald-700 dark:text-emerald-300">{{ row.example }}</td>
70
+ <td class="py-2 text-xs text-ink-500 dark:text-ink-400">{{ row.desc }}</td>
71
+ </tr>
72
+ </tbody>
73
+ </table>
74
+ </div>
75
+ </template>
76
+ </LearnCard>
77
+
78
+ <LearnCard title="Data / Deployment Boundary">
79
+ <template #body>
80
+ <div class="space-y-3 text-sm text-ink-600 dark:text-ink-300">
81
+ <p>
82
+ <strong>Dataset authors</strong> write citations in a deployment-agnostic way.
83
+ They don't know where their dataset will be deployed or what other datasets will be co-deployed.
84
+ </p>
85
+ <p>
86
+ <strong>Deployers</strong> write <code class="text-xs bg-ink-50 dark:bg-ink-800 px-1 rounded">site-config.yml</code>
87
+ to register datasets and declare URI patterns. They never edit dataset content.
88
+ </p>
89
+ <p>
90
+ <strong>Concept-browser</strong> resolves every citation at runtime. The same YAML works in any deployment.
91
+ </p>
92
+ </div>
93
+ </template>
94
+ </LearnCard>
95
+
96
+ <LearnCard title="Resolution Cascade">
97
+ <template #body>
98
+ <div class="space-y-2">
99
+ <div v-for="step in cascade" :key="step.step" class="flex items-start gap-3 py-2">
100
+ <span class="flex-shrink-0 w-6 h-6 rounded-full bg-ink-100 dark:bg-ink-700 text-ink-600 dark:text-ink-300 text-xs font-mono flex items-center justify-center">{{ step.step }}</span>
101
+ <div class="flex-1">
102
+ <div class="font-mono text-xs text-ink-700 dark:text-ink-200">{{ step.name }}</div>
103
+ <div class="text-xs text-ink-500 dark:text-ink-400">{{ step.desc }}</div>
104
+ <div class="text-xs text-emerald-700 dark:text-emerald-300 mt-0.5">{{ step.result }}</div>
105
+ </div>
106
+ </div>
107
+ </div>
108
+ </template>
109
+ </LearnCard>
110
+
111
+ <LearnCard title="Deprecated: <<ref,title>>">
112
+ <template #body>
113
+ <div class="text-sm text-ink-600 dark:text-ink-300 space-y-2">
114
+ <p>
115
+ The AsciiDoc xref syntax <code class="text-xs bg-amber-50 dark:bg-amber-900/20 px-1 rounded">&lt;&lt;ref,title&gt;&gt;</code>
116
+ is deprecated. It emits a console warning and renders as plain text.
117
+ </p>
118
+ <p>Migrate to the unified syntax:</p>
119
+ <ul class="list-disc list-inside text-xs space-y-1">
120
+ <li><code>&lt;&lt;fig_3,Figure 3&gt;&gt;</code> → <code class="text-emerald-700 dark:text-emerald-300" v-text="'{{fig:fig_3, Figure 3}}'"></code></li>
121
+ <li><code>&lt;&lt;845-01-01, 845-01-01&gt;&gt;</code> → <code class="text-emerald-700 dark:text-emerald-300" v-text="'{{cite:iev_845-01-01}}'"></code></li>
122
+ <li><code>&lt;&lt;ref_1, ISO 704&gt;&gt;</code> → <code class="text-emerald-700 dark:text-emerald-300" v-text="'{{bib:ref_1, ISO 704}}'"></code></li>
123
+ </ul>
124
+ </div>
125
+ </template>
126
+ </LearnCard>
127
+ </div>
128
+ </template>