@glossarist/concept-browser 0.7.111 → 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.111",
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
+ });
@@ -0,0 +1,202 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { existsSync, readdirSync, readFileSync } from 'fs';
3
+ import { resolve } from 'path';
4
+ import {
5
+ buildFontVariables,
6
+ renderFontVariablesInline,
7
+ FONT_SLOTS,
8
+ type BrandingFontsLike,
9
+ } from '../../utils/font-variables';
10
+
11
+ /**
12
+ * Single-source-of-truth contract for branding-driven CSS font variables.
13
+ *
14
+ * Bug history (2026-08-01):
15
+ * - 0.7.106–0.7.110: brand fonts were silently clobbered for
16
+ * `--font-serif`/`--font-sans`/`--font-mono` because the tailwind.css
17
+ * `@theme` block emitted `:root, :host { ... }` declarations into the
18
+ * CSS bundle, which loaded AFTER Default.astro's inline `<style>` and
19
+ * overrode the brand values per CSS cascade (equal specificity, later
20
+ * source order wins).
21
+ * - 0.7.111 contributor patch fixed `:root` block but missed `@theme`.
22
+ *
23
+ * Post-refactor: `buildFontVariables()` is the only emitter. Both
24
+ * `Default.astro` (build-time inline) and `use-site-config.ts` (runtime
25
+ * overrides) call it. The output is the canonical slot-named shape —
26
+ * no `--font-serif`/`--font-sans` category-named variables that bake in
27
+ * serif↔heading / sans↔body assumptions.
28
+ */
29
+
30
+ describe('buildFontVariables — shape contract', () => {
31
+ it('returns exactly the four slot-named variables', () => {
32
+ const vars = buildFontVariables(null);
33
+ expect(Object.keys(vars).sort()).toEqual(
34
+ ['--font-body', '--font-heading', '--font-mono', '--font-title'],
35
+ );
36
+ });
37
+
38
+ it('FONT_SLOTS constant is the canonical slot list', () => {
39
+ expect(FONT_SLOTS).toEqual(['title', 'heading', 'body', 'mono']);
40
+ });
41
+
42
+ it('never emits --font-serif or --font-sans (category-named variables are forbidden)', () => {
43
+ const vars = buildFontVariables({
44
+ title: { family: 'Raleway', category: 'sans-serif' },
45
+ });
46
+ expect(vars).not.toHaveProperty('--font-serif');
47
+ expect(vars).not.toHaveProperty('--font-sans');
48
+ });
49
+
50
+ it('never emits --font-header as a separate variable (legacy alias handled by CSS, not JS)', () => {
51
+ const vars = buildFontVariables({ header: { family: 'Raleway' } });
52
+ expect(vars).not.toHaveProperty('--font-header');
53
+ });
54
+ });
55
+
56
+ describe('buildFontVariables — defaults preserve Glossarist visual identity', () => {
57
+ it('falls back to DM Serif Display + DM Sans + JetBrains Mono when no branding', () => {
58
+ const vars = buildFontVariables(null);
59
+ expect(vars['--font-title']).toContain('DM Serif Display');
60
+ expect(vars['--font-heading']).toContain('DM Serif Display');
61
+ expect(vars['--font-body']).toContain('DM Sans');
62
+ expect(vars['--font-mono']).toContain('JetBrains Mono');
63
+ });
64
+
65
+ it('keeps working when individual slots are missing', () => {
66
+ const vars = buildFontVariables({ title: { family: 'Raleway' } });
67
+ expect(vars['--font-title']).toContain('Raleway');
68
+ expect(vars['--font-heading']).toContain('DM Serif Display');
69
+ expect(vars['--font-body']).toContain('DM Sans');
70
+ });
71
+ });
72
+
73
+ describe('buildFontVariables — brand overrides', () => {
74
+ const raleway: BrandingFontsLike = {
75
+ title: { family: 'Raleway', category: 'sans-serif' },
76
+ heading: { family: 'Raleway', category: 'sans-serif' },
77
+ body: { family: 'Raleway', category: 'sans-serif' },
78
+ mono: { family: 'Fira Code', category: 'monospace' },
79
+ };
80
+
81
+ it('produces Raleway stacks for every text slot when consumer sets all four', () => {
82
+ const vars = buildFontVariables(raleway);
83
+ expect(vars['--font-title']).toBe("'Raleway', system-ui, sans-serif");
84
+ expect(vars['--font-heading']).toBe("'Raleway', system-ui, sans-serif");
85
+ expect(vars['--font-body']).toBe("'Raleway', system-ui, sans-serif");
86
+ expect(vars['--font-mono']).toBe("'Fira Code', ui-monospace, \"JetBrains Mono\", Menlo, Monaco, monospace");
87
+ });
88
+
89
+ it('honors `header` legacy alias as input for the heading slot', () => {
90
+ const vars = buildFontVariables({ header: { family: 'Inter', category: 'sans-serif' } });
91
+ expect(vars['--font-heading']).toBe("'Inter', system-ui, sans-serif");
92
+ });
93
+
94
+ it('`heading` slot wins over legacy `header` alias when both are set', () => {
95
+ const vars = buildFontVariables({
96
+ heading: { family: 'Heading', category: 'sans-serif' },
97
+ header: { family: 'Header', category: 'sans-serif' },
98
+ });
99
+ expect(vars['--font-heading']).toContain('Heading');
100
+ expect(vars['--font-heading']).not.toContain('Header');
101
+ });
102
+ });
103
+
104
+ describe('renderFontVariablesInline — serialization', () => {
105
+ it('produces a `:root { ... }` block with every slot variable', () => {
106
+ const css = renderFontVariablesInline({ title: { family: 'Raleway', category: 'sans-serif' } });
107
+ expect(css.startsWith(':root{')).toBe(true);
108
+ expect(css.endsWith('}')).toBe(true);
109
+ expect(css).toContain('--font-title:');
110
+ expect(css).toContain('--font-heading:');
111
+ expect(css).toContain('--font-body:');
112
+ expect(css).toContain('--font-mono:');
113
+ expect(css).toContain("'Raleway'");
114
+ });
115
+
116
+ it('separates declarations with semicolons (no trailing junk)', () => {
117
+ const css = renderFontVariablesInline(null);
118
+ // Each declaration is `--font-X: value`, separated by `;`.
119
+ const declCount = css.match(/--font-[a-z]+:/g)!.length;
120
+ const sepCount = css.match(/;/g)!.length;
121
+ expect(declCount).toBe(4);
122
+ expect(sepCount).toBe(3); // 4 declarations = 3 separators (no trailing)
123
+ expect(css).not.toMatch(/;;/);
124
+ });
125
+
126
+ it('emits only slot-named variables — never --font-serif / --font-sans', () => {
127
+ const css = renderFontVariablesInline(null);
128
+ expect(css).not.toMatch(/--font-serif/);
129
+ expect(css).not.toMatch(/--font-sans/);
130
+ expect(css).not.toMatch(/--font-header/);
131
+ });
132
+ });
133
+
134
+ /**
135
+ * THE LOAD-BEARING REGRESSION TEST.
136
+ *
137
+ * The bundled CSS must NOT contain `:root, :host { --font-... }` declarations
138
+ * for any slot variable. If it does, the bundle loads after the inline
139
+ * `<style>` and clobbers the brand values per CSS cascade.
140
+ *
141
+ * This test scans the built Default.*.css artifact (produced by
142
+ * `npm run build:astro`) and asserts no font variable appears in a
143
+ * `:root` selector. Skipped when dist/ doesn't exist (fresh clone,
144
+ * before first build).
145
+ */
146
+ function findBundleDefaultCss(): string | null {
147
+ const astroDir = resolve(process.cwd(), 'dist/_astro');
148
+ if (!existsSync(astroDir)) return null;
149
+ for (const f of readdirSync(astroDir)) {
150
+ if (f.startsWith('Default.') && f.endsWith('.css')) {
151
+ return readFileSync(resolve(astroDir, f), 'utf-8');
152
+ }
153
+ }
154
+ return null;
155
+ }
156
+
157
+ describe('bundle drift regression — Default.*.css must not clobber brand font vars', () => {
158
+ it('bundle has no `:root,:host{...--font-X: <literal>}` declarations', () => {
159
+ // The bundle's `:root, :host { ... }` block (from @theme) is allowed
160
+ // to define font variables as `var()` references to the slot variables
161
+ // — that's an alias, not a literal. A literal value (e.g.,
162
+ // `--font-serif: "DM Serif Display"`) would clobber the inline <style>.
163
+ const css = findBundleDefaultCss();
164
+ if (css == null) {
165
+ console.warn('Skipping bundle drift test — dist/_astro/Default.*.css not found.');
166
+ return;
167
+ }
168
+ // Find any `--font-X: <value>` declaration in a `:root,:host{...}` block.
169
+ // Reject if the value is a literal (not a var() reference).
170
+ // NOTE: --font-weight-* is a Tailwind font-WEIGHT scale, not a
171
+ // font-family variable — excluded from this drift check.
172
+ const rootBlocks = css.match(/:root[^{]*\{([^}]*)\}/g) ?? [];
173
+ const offenders: string[] = [];
174
+ for (const block of rootBlocks) {
175
+ const fontDecls = block.matchAll(/--font-(title|heading|header|body|mono|serif|sans)\s*:\s*([^;}]+)/g);
176
+ for (const m of fontDecls) {
177
+ const name = m[1];
178
+ const value = m[2].trim();
179
+ if (value.startsWith('var(')) continue; // alias — OK
180
+ if (value === 'initial') continue; // explicit disable — OK
181
+ offenders.push(`--font-${name}: ${value}`);
182
+ }
183
+ }
184
+ expect(
185
+ offenders,
186
+ 'bundle must not emit literal font-family values at :root (they clobber inline style)',
187
+ ).toEqual([]);
188
+ });
189
+
190
+ it('bundle aliases map to slot variables (no category-named sources)', () => {
191
+ const css = findBundleDefaultCss();
192
+ if (css == null) {
193
+ console.warn('Skipping bundle drift test — dist/_astro/Default.*.css not found.');
194
+ return;
195
+ }
196
+ // Verify the alias wiring is in place: --font-serif → --font-heading,
197
+ // --font-sans → --font-body. These ensure Tailwind's default utility
198
+ // names still work in templates that haven't migrated to slot names.
199
+ expect(css).toMatch(/--font-serif:\s*var\(--font-heading\)/);
200
+ expect(css).toMatch(/--font-sans:\s*var\(--font-body\)/);
201
+ });
202
+ });
@@ -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>;
@@ -3,7 +3,7 @@ import type { PageConfig, SiteColors } from './types';
3
3
  import type { DatasetGroup } from './types';
4
4
  import { synthesizePages } from './page-types';
5
5
  import { locale } from '../i18n';
6
- import { fontStack, DEFAULT_FONTS, DEFAULT_CATEGORY } from '../utils/font-stack';
6
+ import { buildFontVariables, type BrandingFontsLike } from '../utils/font-variables';
7
7
 
8
8
  export interface RuntimeSiteConfig {
9
9
  id: string;
@@ -99,25 +99,20 @@ function applyBranding(config: RuntimeSiteConfig) {
99
99
  root.style.setProperty('--brand-dark', b.darkColor);
100
100
  }
101
101
 
102
- if (b.fonts?.title) {
103
- loadFont(b.fonts.title);
104
- root.style.setProperty('--font-title', fontStack(b.fonts.title, DEFAULT_CATEGORY.title) ?? DEFAULT_FONTS.title);
105
- }
106
- // heading is the new slot name; header is the backward-compat alias.
107
- const headingFont = b.fonts?.heading ?? b.fonts?.header;
108
- if (headingFont) {
109
- loadFont(headingFont);
110
- const stack = fontStack(headingFont, DEFAULT_CATEGORY.heading) ?? DEFAULT_FONTS.heading;
111
- root.style.setProperty('--font-heading', stack);
112
- root.style.setProperty('--font-header', stack);
113
- }
114
- if (b.fonts?.body) {
115
- loadFont(b.fonts.body);
116
- root.style.setProperty('--font-body', fontStack(b.fonts.body, DEFAULT_CATEGORY.body) ?? DEFAULT_FONTS.body);
117
- }
118
- if (b.fonts?.mono) {
119
- loadFont(b.fonts.mono);
120
- root.style.setProperty('--font-mono', fontStack(b.fonts.mono, DEFAULT_CATEGORY.mono) ?? DEFAULT_FONTS.mono);
102
+ if (b.fonts && (b.fonts.title || b.fonts.heading || b.fonts.header || b.fonts.body || b.fonts.mono)) {
103
+ // Load declared Google Fonts first so the brand stack resolves
104
+ // without flashing the fallback.
105
+ if (b.fonts.title) loadFont(b.fonts.title);
106
+ const headingFont = b.fonts.heading ?? b.fonts.header;
107
+ if (headingFont) loadFont(headingFont);
108
+ if (b.fonts.body) loadFont(b.fonts.body);
109
+ if (b.fonts.mono) loadFont(b.fonts.mono);
110
+
111
+ // Single SSOT — same shape as Default.astro's inline <style>.
112
+ const vars = buildFontVariables(b.fonts as BrandingFontsLike);
113
+ for (const [k, v] of Object.entries(vars)) {
114
+ root.style.setProperty(k, v);
115
+ }
121
116
  }
122
117
  }
123
118
 
@@ -2,12 +2,8 @@
2
2
  import '@/styles/tailwind.css';
3
3
  import AppIsland from '../islands/AppIsland.vue';
4
4
  import { getSiteConfig } from '../lib/site-config';
5
- import {
6
- fontStack,
7
- DEFAULT_FONTS,
8
- DEFAULT_CATEGORY,
9
- type FontConfigLike,
10
- } from '../utils/font-stack';
5
+ import { type FontConfigLike } from '../utils/font-stack';
6
+ import { renderFontVariablesInline } from '../utils/font-variables';
11
7
  import { resolveFaviconBase } from '../utils/favicon-base';
12
8
  import { buildFaviconLinks, renderFaviconLinkTag } from '../utils/favicon-links';
13
9
 
@@ -44,13 +40,11 @@ const fontsUrl = fontParts.length > 0
44
40
  ? `https://fonts.googleapis.com/css2?${fontParts.join('&')}&display=swap`
45
41
  : 'https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=DM+Serif+Display:ital@0;1&family=JetBrains+Mono:wght@400;500&display=swap';
46
42
 
47
- // Build CSS font-family stacks via the shared SSOT. Consumers can pick
48
- // any category for any slot — e.g. sans-serif title + serif heading —
49
- // and the fallback chain follows the declared category.
50
- const fontTitle = fontStack(brandingFonts?.title, DEFAULT_CATEGORY.title) ?? DEFAULT_FONTS.title;
51
- const fontHeading = fontStack(headingFont, DEFAULT_CATEGORY.heading) ?? DEFAULT_FONTS.heading;
52
- const fontBody = fontStack(brandingFonts?.body, DEFAULT_CATEGORY.body) ?? DEFAULT_FONTS.body;
53
- const fontMono = fontStack(brandingFonts?.mono, DEFAULT_CATEGORY.mono) ?? DEFAULT_FONTS.mono;
43
+ // Single source of truth for the inline font-variable block.
44
+ // renderFontVariablesInline() is shared with use-site-config.ts so the
45
+ // build-time SSG and runtime override paths can never drift.
46
+ // See src/utils/font-variables.ts for the contract + regression tests.
47
+ const fontVarsCss = renderFontVariablesInline(brandingFonts);
54
48
 
55
49
  // Favicon configuration (issues #173, #177, and the data-driven refactor).
56
50
  //
@@ -137,7 +131,7 @@ const faviconLinks = buildFaviconLinks({
137
131
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
138
132
  <link rel="preload" as="style" href={fontsUrl} onload="this.rel='stylesheet'">
139
133
  <noscript><link href={fontsUrl} rel="stylesheet"></noscript>
140
- <Fragment set:html={`<style>:root{--font-title:${fontTitle};--font-heading:${fontHeading};--font-header:${fontHeading};--font-body:${fontBody};--font-mono:${fontMono}}</style>`} />
134
+ <Fragment set:html={`<style>${fontVarsCss}</style>`} />
141
135
  <script is:inline>
142
136
  (function() {
143
137
  const c = document.cookie.split(';').map(s => s.trim()).find(s => s.startsWith('theme='));
@@ -38,9 +38,19 @@
38
38
  --color-group-family: #6366f1;
39
39
  --color-group-collection: #0891b2;
40
40
 
41
- --font-serif: "DM Serif Display", Georgia, serif;
42
- --font-sans: "DM Sans", system-ui, sans-serif;
43
- --font-mono: "JetBrains Mono", monospace;
41
+ /* Tailwind font utilities. Defined as `var()` references to the slot
42
+ variables so the utilities exist (templates still reference
43
+ `.font-serif`, `.font-sans`, `.font-mono`) but the bundle's
44
+ `:root, :host { ... }` block emits ALIASES, not literal values —
45
+ meaning it cannot clobber whatever the inline `<style>` sets.
46
+
47
+ The inline style (Default.astro) emits ONLY slot-named variables
48
+ (--font-title, --font-heading, --font-body, --font-mono) via
49
+ buildFontVariables() in src/utils/font-variables.ts. The aliases
50
+ here resolve to those slot variables through the cascade. */
51
+ --font-serif: var(--font-heading);
52
+ --font-sans: var(--font-body);
53
+ --font-mono: var(--font-mono);
44
54
  }
45
55
 
46
56
  :root {
@@ -60,10 +70,6 @@
60
70
  --brand-primary: #2563eb;
61
71
  --brand-primary-rgb: 37, 99, 235;
62
72
  --brand-dark: #1e293b;
63
- --font-title: "DM Serif Display", Georgia, serif;
64
- --font-heading: "DM Serif Display", Georgia, serif;
65
- --font-header: "DM Serif Display", Georgia, serif;
66
- --font-body: "DM Sans", system-ui, sans-serif;
67
73
  --gold-accent: #B8935A;
68
74
  --gold-accent-deep: #8C6A3A;
69
75
  }
@@ -75,11 +81,23 @@
75
81
  -webkit-font-smoothing: antialiased;
76
82
  -moz-osx-font-smoothing: grayscale;
77
83
  }
78
- .font-title {
79
- font-family: var(--font-title);
80
- }
81
- h1, h2, h3, .font-serif {
82
- font-family: var(--font-header);
84
+ /* Slot-named utilities — the canonical API. Each maps to a CSS variable
85
+ emitted by buildFontVariables() in src/utils/font-variables.ts. */
86
+ .font-title { font-family: var(--font-title); }
87
+ .font-heading { font-family: var(--font-heading); }
88
+ .font-body { font-family: var(--font-body); }
89
+ /* Backward-compat aliases. Existing templates use `.font-serif` and
90
+ `.font-sans`; map them to slot variables so brand fonts propagate
91
+ without forcing every consumer to rewrite markup. These do NOT
92
+ bake in serif↔heading / sans↔body — the slot variable holds
93
+ whatever category the consumer declared. */
94
+ .font-serif { font-family: var(--font-heading); }
95
+ .font-sans { font-family: var(--font-body); }
96
+ /* Legacy alias for `.font-header` (renamed to `.font-heading`). */
97
+ .font-header { font-family: var(--font-heading); }
98
+ /* Default heading typography — applies unless overridden by a slot class. */
99
+ h1, h2, h3, h4, h5, h6 {
100
+ font-family: var(--font-heading);
83
101
  }
84
102
  code, pre, .font-mono {
85
103
  font-family: var(--font-mono);
@@ -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
  }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Single source of truth for branding-driven CSS font variables.
3
+ *
4
+ * WHY THIS EXISTS
5
+ *
6
+ * Pre-refactor (0.7.106–0.7.111), font variables came from FOUR places:
7
+ * 1. `src/utils/font-stack.ts` (DEFAULT_FONTS)
8
+ * 2. `src/styles/tailwind.css` `:root { ... }` block
9
+ * 3. `src/styles/tailwind.css` `@theme { ... }` block (generated
10
+ * `:root, :host { ... }` in the CSS bundle)
11
+ * 4. `tailwind.config.js` `fontFamily.*` utility fallback strings
12
+ *
13
+ * The @theme block was the load-bearing bug. Its `:root, :host { ... }`
14
+ * declaration in the CSS bundle loaded AFTER `Default.astro`'s inline
15
+ * `<style>`, so by CSS cascade (equal specificity, later source order
16
+ * wins) the brand values were silently clobbered for `--font-serif`,
17
+ * `--font-sans`, `--font-mono`.
18
+ *
19
+ * Post-refactor: this module is the ONLY place that decides which CSS
20
+ * font variables exist and what their values are. Both
21
+ * `Default.astro` (build-time SSG inline `<style>`) and
22
+ * `use-site-config.ts` (runtime `root.style.setProperty` overrides)
23
+ * call `buildFontVariables()` and emit the same shape.
24
+ *
25
+ * DESIGN
26
+ *
27
+ * Slot-named variables only. No category-named variables
28
+ * (`--font-serif`, `--font-sans`) — those bake in "serif↔heading,
29
+ * sans↔body" assumptions that break when a consumer picks a different
30
+ * category for a slot. The Tailwind utilities `.font-serif` and
31
+ * `.font-sans` are kept as backward-compat aliases defined in
32
+ * `@layer base`, mapped to slot variables.
33
+ *
34
+ * Output keys:
35
+ * --font-title — most prominent display text
36
+ * --font-heading — h2–h6 section headings
37
+ * --font-body — paragraph text
38
+ * --font-mono — code, pre, kbd
39
+ */
40
+
41
+ import { fontStack, DEFAULT_FONTS, DEFAULT_CATEGORY, type FontConfigLike } from './font-stack';
42
+
43
+ /** Canonical slot names. Any CSS variable not in this list is a bug. */
44
+ export const FONT_SLOTS = ['title', 'heading', 'body', 'mono'] as const;
45
+ export type FontSlot = (typeof FONT_SLOTS)[number];
46
+
47
+ /** Branding fonts shape — accepts both `header` (legacy alias) and `heading`. */
48
+ export interface BrandingFontsLike {
49
+ title?: FontConfigLike;
50
+ heading?: FontConfigLike;
51
+ /** @deprecated Legacy alias for `heading`. */
52
+ header?: FontConfigLike;
53
+ body?: FontConfigLike;
54
+ mono?: FontConfigLike;
55
+ }
56
+
57
+ /**
58
+ * Build the canonical `:root` CSS font-variable map from a branding
59
+ * config. Every variable the system relies on is present in the output
60
+ * — no need for fallback definitions elsewhere.
61
+ *
62
+ * Values are pre-formed CSS font-family stacks (e.g.
63
+ * `'Raleway', system-ui, sans-serif`). The caller is responsible for
64
+ * serializing into a CSS `:root { ... }` block.
65
+ *
66
+ * `header` (legacy alias) is read but not emitted as a separate
67
+ * variable. Its value flows into `--font-heading`. Consumers that
68
+ * still reference `--font-header` in their CSS get the heading value
69
+ * via the alias declaration in `@layer base` of tailwind.css.
70
+ */
71
+ export function buildFontVariables(
72
+ fonts: BrandingFontsLike | null | undefined,
73
+ ): Record<`--font-${FontSlot}`, string> {
74
+ const f = fonts ?? {};
75
+ const heading = f.heading ?? f.header;
76
+ return {
77
+ '--font-title': fontStack(f.title, DEFAULT_CATEGORY.title) ?? DEFAULT_FONTS.title,
78
+ '--font-heading': fontStack(heading, DEFAULT_CATEGORY.heading) ?? DEFAULT_FONTS.heading,
79
+ '--font-body': fontStack(f.body, DEFAULT_CATEGORY.body) ?? DEFAULT_FONTS.body,
80
+ '--font-mono': fontStack(f.mono, DEFAULT_CATEGORY.mono) ?? DEFAULT_FONTS.mono,
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Serialize the variable map to an inline `:root { ... }` CSS string,
86
+ * safe to drop into a `<style>` tag.
87
+ *
88
+ * Escapes nothing (font stacks come from a controlled source) but
89
+ * joins with proper CSS syntax. Use this from Default.astro.
90
+ */
91
+ export function renderFontVariablesInline(
92
+ fonts: BrandingFontsLike | null | undefined,
93
+ ): string {
94
+ const vars = buildFontVariables(fonts);
95
+ const body = FONT_SLOTS.map(slot => `--font-${slot}:${vars[`--font-${slot}`]}`).join(';');
96
+ return `:root{${body}}`;
97
+ }
@@ -12,13 +12,11 @@ export default {
12
12
  ],
13
13
  theme: {
14
14
  extend: {
15
- fontFamily: {
16
- title: ['var(--font-title, "DM Serif Display")', 'Georgia', 'serif'],
17
- heading: ['var(--font-heading, "DM Serif Display")', 'Georgia', 'serif'],
18
- serif: ['var(--font-header, "DM Serif Display")', 'Georgia', 'serif'],
19
- sans: ['var(--font-body, "DM Sans")', 'system-ui', 'sans-serif'],
20
- mono: ['var(--font-mono, "JetBrains Mono")', 'ui-monospace', 'monospace'],
21
- },
15
+ // Font utilities are defined in src/styles/tailwind.css @layer base.
16
+ // They reference slot CSS variables (--font-title, --font-heading,
17
+ // --font-body, --font-mono) emitted by buildFontVariables() in
18
+ // src/utils/font-variables.ts. Configuring them here would create
19
+ // a second source of truth that can drift.
22
20
  colors: {
23
21
  ink: {
24
22
  DEFAULT: '#1a1b2e',
@@ -39,11 +37,6 @@ export default {
39
37
  raised: '#ffffff',
40
38
  },
41
39
  },
42
- typography: {
43
- serif: {
44
- 'font-family': 'var(--font-header, "DM Serif Display"), Georgia, serif',
45
- },
46
- },
47
40
  },
48
41
  },
49
42
  plugins: [],