@brandon_m_behring/book-scaffold-astro 4.12.0 → 4.14.0

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.
@@ -21,6 +21,7 @@
21
21
  */
22
22
  import type { CollectionEntry } from 'astro:content';
23
23
  import { getFreshness, freshnessLabel } from '../src/lib/freshness';
24
+ import { academicPartHeading } from '../src/lib/academic-parts';
24
25
  import StatusBadge from './StatusBadge.astro';
25
26
  import CodeRef from './CodeRef.astro';
26
27
 
@@ -60,23 +61,15 @@ const freshnessText = freshness
60
61
  : 'Stale'
61
62
  : null;
62
63
 
63
- // Academic-profile part labels (Roman-numeral · descriptive name).
64
- // v2.0 post_transformers used this exact mapping; verbatim restore here so
65
- // the header content density matches at narrow viewports. Keys mirror
66
- // `academicParts` enum from src/schemas.ts.
67
- const ACADEMIC_PART_LABELS: Record<string, string> = {
68
- foundations: 'Part I · Foundations',
69
- 'ssm-core': 'Part II · SSM Core',
70
- 'beyond-ssm': 'Part III · Beyond SSMs',
71
- integration: 'Part IV · Integration',
72
- synthesis: 'Part V · Synthesis',
73
- };
74
-
75
- // Display strings, profile-tagged for clarity in markup.
64
+ // Display strings, profile-tagged for clarity in markup. Academic part
65
+ // labels ("Part {roman} · {name}") come from the shared academic-parts
66
+ // module — the single source of truth across the /chapters index, Sidebar,
67
+ // and this header (#95). Unknown/custom academic parts fall back to the
68
+ // title-cased name (no ordinal), consistent with the other two surfaces.
76
69
  const partLabel = (() => {
77
70
  const p = d.part;
78
- if (hasAcademicMeta && typeof p === 'string' && p in ACADEMIC_PART_LABELS) {
79
- return ACADEMIC_PART_LABELS[p];
71
+ if (hasAcademicMeta && typeof p === 'string' && p.length > 0) {
72
+ return academicPartHeading(p);
80
73
  }
81
74
  if (typeof p === 'number') return `Part ${p}`;
82
75
  if (typeof p === 'string' && p.length > 0) return `Part: ${p}`;
@@ -18,26 +18,17 @@
18
18
  * them in as Astro.props from Base.astro if you want per-page overrides.
19
19
  */
20
20
  import { getCollection } from 'astro:content';
21
+ import { academicParts } from '../src/schemas';
22
+ import { academicPartHeading } from '../src/lib/academic-parts';
21
23
 
22
24
  const profile = import.meta.env.BOOK_PROFILE ?? 'minimal';
23
25
  const siteTitle = 'Book';
24
26
  const siteSubtitle = 'A scaffold-astro book';
25
27
 
26
- // Academic profile: part is a string enum.
27
- const ACADEMIC_PART_ORDER = [
28
- 'foundations',
29
- 'ssm-core',
30
- 'beyond-ssm',
31
- 'integration',
32
- 'synthesis',
33
- ] as const;
34
- const ACADEMIC_PART_LABEL: Record<string, string> = {
35
- foundations: 'Part I · Foundations',
36
- 'ssm-core': 'Part II · SSM Core',
37
- 'beyond-ssm': 'Part III · Beyond SSMs',
38
- integration: 'Part IV · Integration',
39
- synthesis: 'Part V · Synthesis',
40
- };
28
+ // Academic profile: part is a string enum. `academicParts` (schemas.ts) is
29
+ // the canonical order, shared with the renderer and ChapterHeader (#95); the
30
+ // "Part {roman} · {name}" labels come from the same academic-parts module.
31
+ const ACADEMIC_PART_ORDER = academicParts;
41
32
 
42
33
  const rawChapters = await getCollection('chapters', (entry) => !entry.data.draft);
43
34
 
@@ -101,7 +92,7 @@ function isCurrent(id: string): boolean {
101
92
  }
102
93
 
103
94
  function partLabel(key: string): string {
104
- if (profile === 'academic') return ACADEMIC_PART_LABEL[key] ?? key;
95
+ if (profile === 'academic') return academicPartHeading(key);
105
96
  return `Part ${key}`;
106
97
  }
107
98
  ---
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { AstroUserConfig, AstroIntegration } from 'astro';
2
- import { c as BookConfigOptions, f as BookScaffoldIntegrationOptions, Y as volatilityLevels, h as ChaptersRenderer, o as Style } from './types-CULHImU4.js';
3
- export { A as AcademicChapter, B as BOOK_PRESETS, a as BOOK_PROFILES, b as BookConfigError, d as BookPreset, e as BookProfile, g as BookSchemasOptions, C as ChapterFor, i as CourseNotesChapter, F as FreshnessAffordance, j as FrontmatterRouteConfig, M as MinimalChapter, P as PartKey, k as PartialRouteToggles, l as ProfileDefinition, m as Provenance, R as ResearchPortfolioChapter, n as RouteToggles, S as StatusBadge, p as StyleInput, T as ToolsChapter, V as VolatilityBadge, q as academicChapterSchema, r as academicParts, s as changeKinds, t as changelogSchema, u as chapterStatus, v as citationBackstops, w as composeStyles, x as courseNotesChapterSchema, y as defineProfile, z as defineStyle, D as minimalChapterSchema, E as normalizeFrontmatterConfig, G as patternCategories, H as patternsSchema, I as provenanceObject, J as provenanceSchema, K as researchPortfolioChapterSchema, L as resolvePreset, N as resolveProfile, O as sourceTiers, Q as sourceTiersResearch, U as sourcesSchema, W as toolSlugs, X as toolsChapterSchema } from './types-CULHImU4.js';
2
+ import { c as BookConfigOptions, f as BookScaffoldIntegrationOptions, Y as volatilityLevels, h as ChaptersRenderer, r as academicParts, o as Style } from './types-CULHImU4.js';
3
+ export { A as AcademicChapter, B as BOOK_PRESETS, a as BOOK_PROFILES, b as BookConfigError, d as BookPreset, e as BookProfile, g as BookSchemasOptions, C as ChapterFor, i as CourseNotesChapter, F as FreshnessAffordance, j as FrontmatterRouteConfig, M as MinimalChapter, P as PartKey, k as PartialRouteToggles, l as ProfileDefinition, m as Provenance, R as ResearchPortfolioChapter, n as RouteToggles, S as StatusBadge, p as StyleInput, T as ToolsChapter, V as VolatilityBadge, q as academicChapterSchema, s as changeKinds, t as changelogSchema, u as chapterStatus, v as citationBackstops, w as composeStyles, x as courseNotesChapterSchema, y as defineProfile, z as defineStyle, D as minimalChapterSchema, E as normalizeFrontmatterConfig, G as patternCategories, H as patternsSchema, I as provenanceObject, J as provenanceSchema, K as researchPortfolioChapterSchema, L as resolvePreset, N as resolveProfile, O as sourceTiers, Q as sourceTiersResearch, U as sourcesSchema, W as toolSlugs, X as toolsChapterSchema } from './types-CULHImU4.js';
4
4
  import 'astro/zod';
5
5
 
6
6
  /**
@@ -95,14 +95,6 @@ declare function getFreshness(lastVerified: Date | undefined, volatility: Volati
95
95
  * affordance without a separate branch. */
96
96
  declare function freshnessLabel(f: Freshness | null): string;
97
97
 
98
- /**
99
- * src/lib/chapter-sort.ts — pure sort-key helper for chapter frontmatter.
100
- *
101
- * Lives in its own file (no `astro:content` imports) so tsup can include it
102
- * in the toolkit's DTS bundle without trying to resolve Astro's virtual
103
- * modules at build time. The Astro-context wrappers (sortKey, getAllChapters,
104
- * getNeighbors) live in src/lib/chapters.ts and import from here.
105
- */
106
98
  /**
107
99
  * Pure-function sort key from a chapter frontmatter object. Spans both
108
100
  * tools and academic schemas:
@@ -159,6 +151,59 @@ declare const academicChaptersRenderer: ChaptersRenderer;
159
151
 
160
152
  declare const fallbackChaptersRenderer: ChaptersRenderer;
161
153
 
154
+ /**
155
+ * src/lib/academic-parts.ts — single source of truth for academic-profile
156
+ * part labels (#95).
157
+ *
158
+ * The academic `part` enum (src/schemas.ts: academicParts) is rendered on
159
+ * three surfaces — the `/chapters` index, the Sidebar nav, and the chapter
160
+ * header. Before v4.14.0 each surface carried its own copy of the
161
+ * enum→label map, and they had silently diverged: `beyond-ssm` rendered
162
+ * "Beyond SSM" on the index but "Beyond SSMs" in the sidebar/header. This
163
+ * module is the one place that owns:
164
+ *
165
+ * - the base display name per part ("SSM Core", "Beyond SSMs", …), and
166
+ * - the unknown/custom-part fallback (titleCase, consistently).
167
+ *
168
+ * The renderer uses the bare name; the Sidebar/ChapterHeader compose
169
+ * "Part {roman} · {name}". Both the Roman heading prefix AND the on-page
170
+ * sort order derive from the part's position in `academicParts` (the
171
+ * canonical order) via `academicPartOrdinal()` below — so labels and
172
+ * ordering share one source and cannot drift apart (#95 + #99).
173
+ */
174
+
175
+ type AcademicPart = (typeof academicParts)[number];
176
+ /**
177
+ * Base display names for the academic `part` enum. An explicit map (not
178
+ * naive title-casing) so acronyms render correctly: `ssm-core` → "SSM Core",
179
+ * not "Ssm Core" (#91). Keys mirror `academicParts` in src/schemas.ts.
180
+ * Canonical spelling for `beyond-ssm` is the plural "Beyond SSMs" (#95).
181
+ */
182
+ declare const ACADEMIC_PART_NAMES: Record<AcademicPart, string>;
183
+ /**
184
+ * Bare part name for the `/chapters` index group heading. Known parts use
185
+ * the explicit map; unknown/custom parts fall back to titleCase.
186
+ */
187
+ declare function academicPartName(part: string): string;
188
+ /** Ordinal returned for any `part` outside `academicParts` — it sorts after
189
+ * all known parts and renders with no Roman prefix. */
190
+ declare const UNKNOWN_PART_ORDINAL = 99;
191
+ /**
192
+ * 1-based ordinal of an academic `part`, from its position in `academicParts`
193
+ * (the canonical order); `UNKNOWN_PART_ORDINAL` for anything outside the enum.
194
+ * The single source for BOTH the on-page sort key (academicChaptersRenderer
195
+ * and chapterSortKey) and the Roman heading prefix below — so a reorder of
196
+ * `academicParts` moves sort order and labels together, never apart (#99).
197
+ */
198
+ declare function academicPartOrdinal(part: string): number;
199
+ /**
200
+ * "Part {roman} · {name}" heading for the Sidebar and ChapterHeader. Known
201
+ * parts get the Roman-ordinal prefix; unknown/custom parts (no ordinal)
202
+ * fall back to the bare title-cased name — one consistent rule across both
203
+ * surfaces, replacing the old divergent fallbacks (raw key vs "Part: key").
204
+ */
205
+ declare function academicPartHeading(part: string): string;
206
+
162
207
  /**
163
208
  * src/styles/built-in.ts — toolkit-shipped Styles, one per BookPreset (v4.0.0).
164
209
  *
@@ -260,4 +305,4 @@ type TipsConfigInput = Omit<TipsConfig, typeof TipsConfigBrand | '__tipsConfigVe
260
305
  */
261
306
  declare function defineTips(opts: TipsConfigInput): TipsConfig;
262
307
 
263
- export { BRANDON_PORTFOLIO_DEFAULT, BUILTIN_STYLES, BookConfigOptions, BookScaffoldIntegrationOptions, ChaptersRenderer, type Freshness, type FreshnessStatus, Style, type TipsConfig, type TipsConfigInput, type VolatilityLevel, academicChaptersRenderer, academicStyle, bookScaffoldIntegration, chapterSortKey, courseNotesStyle, defineBookConfig, defineMdxComponents, defineTips, fallbackChaptersRenderer, freshnessLabel, getFreshness, minimalStyle, researchPortfolioStyle, toolsChaptersRenderer, toolsStyle, volatilityLevels };
308
+ export { ACADEMIC_PART_NAMES, BRANDON_PORTFOLIO_DEFAULT, BUILTIN_STYLES, BookConfigOptions, BookScaffoldIntegrationOptions, ChaptersRenderer, type Freshness, type FreshnessStatus, Style, type TipsConfig, type TipsConfigInput, UNKNOWN_PART_ORDINAL, type VolatilityLevel, academicChaptersRenderer, academicPartHeading, academicPartName, academicPartOrdinal, academicParts, academicStyle, bookScaffoldIntegration, chapterSortKey, courseNotesStyle, defineBookConfig, defineMdxComponents, defineTips, fallbackChaptersRenderer, freshnessLabel, getFreshness, minimalStyle, researchPortfolioStyle, toolsChaptersRenderer, toolsStyle, volatilityLevels };
package/dist/index.mjs CHANGED
@@ -359,25 +359,40 @@ var patternsSchema = z.object({
359
359
  convergence_date: z.date().nullable().optional()
360
360
  });
361
361
 
362
- // src/profiles/renderers/academic-chapters.ts
363
- var ACADEMIC_PART_ORDINAL = {
364
- foundations: 1,
365
- "ssm-core": 2,
366
- "beyond-ssm": 3,
367
- integration: 4,
368
- synthesis: 5
362
+ // src/lib/academic-parts.ts
363
+ var ACADEMIC_PART_NAMES = {
364
+ foundations: "Foundations",
365
+ "ssm-core": "SSM Core",
366
+ "beyond-ssm": "Beyond SSMs",
367
+ integration: "Integration",
368
+ synthesis: "Synthesis"
369
369
  };
370
- var UNKNOWN_PART_ORDINAL = 99;
370
+ var ROMAN = ["I", "II", "III", "IV", "V", "VI", "VII", "VIII"];
371
371
  function titleCase(part) {
372
372
  return part.split("-").map((w) => w.length > 0 ? w.charAt(0).toUpperCase() + w.slice(1) : "").join(" ");
373
373
  }
374
+ function academicPartName(part) {
375
+ return ACADEMIC_PART_NAMES[part] ?? titleCase(part);
376
+ }
377
+ var UNKNOWN_PART_ORDINAL = 99;
378
+ function academicPartOrdinal(part) {
379
+ const i = academicParts.indexOf(part);
380
+ return i >= 0 ? i + 1 : UNKNOWN_PART_ORDINAL;
381
+ }
382
+ function academicPartHeading(part) {
383
+ const ord = academicPartOrdinal(part);
384
+ const name = academicPartName(part);
385
+ return ord === UNKNOWN_PART_ORDINAL ? name : `Part ${ROMAN[ord - 1]} \xB7 ${name}`;
386
+ }
387
+
388
+ // src/profiles/renderers/academic-chapters.ts
374
389
  var academicChaptersRenderer = {
375
390
  partKey(data) {
376
391
  return data.part ?? "";
377
392
  },
378
393
  formatPartLabel(part) {
379
394
  if (typeof part === "string" && part.length > 0) {
380
- return titleCase(part);
395
+ return academicPartName(part);
381
396
  }
382
397
  return String(part);
383
398
  },
@@ -410,7 +425,7 @@ var academicChaptersRenderer = {
410
425
  },
411
426
  sortKey(data) {
412
427
  const partRaw = data.part;
413
- const partOrdinal = typeof partRaw === "string" ? ACADEMIC_PART_ORDINAL[partRaw] ?? UNKNOWN_PART_ORDINAL : typeof partRaw === "number" ? partRaw : UNKNOWN_PART_ORDINAL;
428
+ const partOrdinal = typeof partRaw === "string" ? academicPartOrdinal(partRaw) : typeof partRaw === "number" ? partRaw : UNKNOWN_PART_ORDINAL;
414
429
  const week = typeof data.week === "number" ? data.week : 0;
415
430
  return partOrdinal * 1e3 + week;
416
431
  }
@@ -1219,17 +1234,9 @@ async function defineBookConfig(opts) {
1219
1234
  }
1220
1235
 
1221
1236
  // src/lib/chapter-sort.ts
1222
- var ACADEMIC_PART_ORDINAL2 = {
1223
- foundations: 1,
1224
- "ssm-core": 2,
1225
- "beyond-ssm": 3,
1226
- integration: 4,
1227
- synthesis: 5
1228
- };
1229
- var UNKNOWN_PART_ORDINAL2 = 99;
1230
1237
  function chapterSortKey(data) {
1231
1238
  const partRaw = data.part;
1232
- const partOrdinal = typeof partRaw === "number" ? partRaw : typeof partRaw === "string" ? ACADEMIC_PART_ORDINAL2[partRaw] ?? UNKNOWN_PART_ORDINAL2 : UNKNOWN_PART_ORDINAL2;
1239
+ const partOrdinal = typeof partRaw === "number" ? partRaw : typeof partRaw === "string" ? academicPartOrdinal(partRaw) : UNKNOWN_PART_ORDINAL;
1233
1240
  const within = typeof data.chapter === "number" ? data.chapter : typeof data.week === "number" ? data.week : 0;
1234
1241
  return partOrdinal * 1e3 + within;
1235
1242
  }
@@ -1274,13 +1281,18 @@ function defineTips(opts) {
1274
1281
  return { __tipsConfigVersion: 1, ...opts };
1275
1282
  }
1276
1283
  export {
1284
+ ACADEMIC_PART_NAMES,
1277
1285
  BOOK_PRESETS,
1278
1286
  BOOK_PROFILES,
1279
1287
  BRANDON_PORTFOLIO_DEFAULT,
1280
1288
  BUILTIN_STYLES,
1281
1289
  BookConfigError,
1290
+ UNKNOWN_PART_ORDINAL,
1282
1291
  academicChapterSchema,
1283
1292
  academicChaptersRenderer,
1293
+ academicPartHeading,
1294
+ academicPartName,
1295
+ academicPartOrdinal,
1284
1296
  academicParts,
1285
1297
  academicStyle,
1286
1298
  bookScaffoldIntegration,
package/dist/schemas.mjs CHANGED
@@ -243,25 +243,34 @@ var patternsSchema = z.object({
243
243
  convergence_date: z.date().nullable().optional()
244
244
  });
245
245
 
246
- // src/profiles/renderers/academic-chapters.ts
247
- var ACADEMIC_PART_ORDINAL = {
248
- foundations: 1,
249
- "ssm-core": 2,
250
- "beyond-ssm": 3,
251
- integration: 4,
252
- synthesis: 5
246
+ // src/lib/academic-parts.ts
247
+ var ACADEMIC_PART_NAMES = {
248
+ foundations: "Foundations",
249
+ "ssm-core": "SSM Core",
250
+ "beyond-ssm": "Beyond SSMs",
251
+ integration: "Integration",
252
+ synthesis: "Synthesis"
253
253
  };
254
- var UNKNOWN_PART_ORDINAL = 99;
255
254
  function titleCase(part) {
256
255
  return part.split("-").map((w) => w.length > 0 ? w.charAt(0).toUpperCase() + w.slice(1) : "").join(" ");
257
256
  }
257
+ function academicPartName(part) {
258
+ return ACADEMIC_PART_NAMES[part] ?? titleCase(part);
259
+ }
260
+ var UNKNOWN_PART_ORDINAL = 99;
261
+ function academicPartOrdinal(part) {
262
+ const i = academicParts.indexOf(part);
263
+ return i >= 0 ? i + 1 : UNKNOWN_PART_ORDINAL;
264
+ }
265
+
266
+ // src/profiles/renderers/academic-chapters.ts
258
267
  var academicChaptersRenderer = {
259
268
  partKey(data) {
260
269
  return data.part ?? "";
261
270
  },
262
271
  formatPartLabel(part) {
263
272
  if (typeof part === "string" && part.length > 0) {
264
- return titleCase(part);
273
+ return academicPartName(part);
265
274
  }
266
275
  return String(part);
267
276
  },
@@ -294,7 +303,7 @@ var academicChaptersRenderer = {
294
303
  },
295
304
  sortKey(data) {
296
305
  const partRaw = data.part;
297
- const partOrdinal = typeof partRaw === "string" ? ACADEMIC_PART_ORDINAL[partRaw] ?? UNKNOWN_PART_ORDINAL : typeof partRaw === "number" ? partRaw : UNKNOWN_PART_ORDINAL;
306
+ const partOrdinal = typeof partRaw === "string" ? academicPartOrdinal(partRaw) : typeof partRaw === "number" ? partRaw : UNKNOWN_PART_ORDINAL;
298
307
  const week = typeof data.week === "number" ? data.week : 0;
299
308
  return partOrdinal * 1e3 + week;
300
309
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@brandon_m_behring/book-scaffold-astro",
3
3
  "description": "Astro 6 + MDX toolkit for long-form technical books. Profile-aware (academic / tools / minimal); ships Tufte typography, KaTeX, BibTeX citations, Pagefind, Cloudflare Workers deploy. See PACKAGE_DESIGN.md for the API contract.",
4
- "version": "4.12.0",
4
+ "version": "4.14.0",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Brandon Behring",
@@ -14,15 +14,39 @@ import Base from '../layouts/Base.astro';
14
14
  import PatternTimeline from '../components/PatternTimeline.astro';
15
15
  import {
16
16
  getPatternsByCategory,
17
+ emptyPatternsByCategory,
17
18
  CATEGORY_LABELS,
18
19
  } from '../src/lib/patterns';
19
20
  import { patternCategories } from '@brandon_m_behring/book-scaffold-astro';
20
21
 
21
- const grouped = await getPatternsByCategory();
22
+ // A tools-profile book may use the inline <Convergence> component yet define
23
+ // no `changelog/patterns.yaml` — then the `patterns` collection is never
24
+ // registered and getCollection('patterns') errors with "The collection
25
+ // 'patterns' does not exist or is empty" (#86). Gate on the manifest's
26
+ // presence and never touch the collection when it is absent — the same
27
+ // presence-gate references.astro uses for the optional `sources` collection.
28
+ // When absent, render the dashboard's honest empty-state from a pure
29
+ // all-empty map (no content-collection access).
30
+ const patternsManifest = import.meta.glob('/changelog/patterns.yaml', {
31
+ query: '?raw',
32
+ import: 'default',
33
+ eager: true,
34
+ });
35
+ const hasPatterns = '/changelog/patterns.yaml' in patternsManifest;
36
+ const grouped = hasPatterns
37
+ ? await getPatternsByCategory()
38
+ : emptyPatternsByCategory();
22
39
  const totalPatterns = Object.values(grouped).reduce(
23
40
  (n, arr) => n + arr.length,
24
41
  0,
25
42
  );
43
+ // Distinguish a missing/misnamed manifest from a legitimately empty registry:
44
+ // a tools book that uses <Convergence> but never created changelog/patterns.yaml
45
+ // would otherwise see a blank dashboard indistinguishable from "no patterns yet".
46
+ // Surface an actionable hint instead — matching tips.astro / references.astro.
47
+ const noManifestHint = hasPatterns
48
+ ? null
49
+ : 'No changelog/patterns.yaml found — create it to populate this dashboard, or set routes: { convergence: false } to drop this page.';
26
50
  ---
27
51
  <Base
28
52
  title="Convergence — Agentic Coding"
@@ -43,6 +67,9 @@ const totalPatterns = Object.values(grouped).reduce(
43
67
  converge or as historical patterns get backfilled; expect the
44
68
  count to drift upward, not the existing entries.
45
69
  </p>
70
+ {noManifestHint && (
71
+ <p class="convergence-no-manifest" role="note">{noManifestHint}</p>
72
+ )}
46
73
  </header>
47
74
 
48
75
  {patternCategories.map((cat) => {
@@ -0,0 +1,87 @@
1
+ /**
2
+ * src/lib/academic-parts.ts — single source of truth for academic-profile
3
+ * part labels (#95).
4
+ *
5
+ * The academic `part` enum (src/schemas.ts: academicParts) is rendered on
6
+ * three surfaces — the `/chapters` index, the Sidebar nav, and the chapter
7
+ * header. Before v4.14.0 each surface carried its own copy of the
8
+ * enum→label map, and they had silently diverged: `beyond-ssm` rendered
9
+ * "Beyond SSM" on the index but "Beyond SSMs" in the sidebar/header. This
10
+ * module is the one place that owns:
11
+ *
12
+ * - the base display name per part ("SSM Core", "Beyond SSMs", …), and
13
+ * - the unknown/custom-part fallback (titleCase, consistently).
14
+ *
15
+ * The renderer uses the bare name; the Sidebar/ChapterHeader compose
16
+ * "Part {roman} · {name}". Both the Roman heading prefix AND the on-page
17
+ * sort order derive from the part's position in `academicParts` (the
18
+ * canonical order) via `academicPartOrdinal()` below — so labels and
19
+ * ordering share one source and cannot drift apart (#95 + #99).
20
+ */
21
+ import { academicParts } from '../schemas.js';
22
+
23
+ type AcademicPart = (typeof academicParts)[number];
24
+
25
+ /**
26
+ * Base display names for the academic `part` enum. An explicit map (not
27
+ * naive title-casing) so acronyms render correctly: `ssm-core` → "SSM Core",
28
+ * not "Ssm Core" (#91). Keys mirror `academicParts` in src/schemas.ts.
29
+ * Canonical spelling for `beyond-ssm` is the plural "Beyond SSMs" (#95).
30
+ */
31
+ export const ACADEMIC_PART_NAMES: Record<AcademicPart, string> = {
32
+ foundations: 'Foundations',
33
+ 'ssm-core': 'SSM Core',
34
+ 'beyond-ssm': 'Beyond SSMs',
35
+ integration: 'Integration',
36
+ synthesis: 'Synthesis',
37
+ };
38
+
39
+ /** Roman ordinals for the "Part {roman}" heading prefix, indexed by the
40
+ * part's position in `academicParts`. Sized past the 5 known parts so a
41
+ * future enum addition still resolves a numeral. */
42
+ const ROMAN = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII'];
43
+
44
+ /** Title-case an enum string: "consumer-custom" → "Consumer Custom".
45
+ * Fallback for parts outside the known ACADEMIC_PART_NAMES map. */
46
+ function titleCase(part: string): string {
47
+ return part
48
+ .split('-')
49
+ .map((w) => (w.length > 0 ? w.charAt(0).toUpperCase() + w.slice(1) : ''))
50
+ .join(' ');
51
+ }
52
+
53
+ /**
54
+ * Bare part name for the `/chapters` index group heading. Known parts use
55
+ * the explicit map; unknown/custom parts fall back to titleCase.
56
+ */
57
+ export function academicPartName(part: string): string {
58
+ return ACADEMIC_PART_NAMES[part as AcademicPart] ?? titleCase(part);
59
+ }
60
+
61
+ /** Ordinal returned for any `part` outside `academicParts` — it sorts after
62
+ * all known parts and renders with no Roman prefix. */
63
+ export const UNKNOWN_PART_ORDINAL = 99;
64
+
65
+ /**
66
+ * 1-based ordinal of an academic `part`, from its position in `academicParts`
67
+ * (the canonical order); `UNKNOWN_PART_ORDINAL` for anything outside the enum.
68
+ * The single source for BOTH the on-page sort key (academicChaptersRenderer
69
+ * and chapterSortKey) and the Roman heading prefix below — so a reorder of
70
+ * `academicParts` moves sort order and labels together, never apart (#99).
71
+ */
72
+ export function academicPartOrdinal(part: string): number {
73
+ const i = academicParts.indexOf(part as AcademicPart);
74
+ return i >= 0 ? i + 1 : UNKNOWN_PART_ORDINAL;
75
+ }
76
+
77
+ /**
78
+ * "Part {roman} · {name}" heading for the Sidebar and ChapterHeader. Known
79
+ * parts get the Roman-ordinal prefix; unknown/custom parts (no ordinal)
80
+ * fall back to the bare title-cased name — one consistent rule across both
81
+ * surfaces, replacing the old divergent fallbacks (raw key vs "Part: key").
82
+ */
83
+ export function academicPartHeading(part: string): string {
84
+ const ord = academicPartOrdinal(part);
85
+ const name = academicPartName(part);
86
+ return ord === UNKNOWN_PART_ORDINAL ? name : `Part ${ROMAN[ord - 1]} · ${name}`;
87
+ }
@@ -5,23 +5,13 @@
5
5
  * in the toolkit's DTS bundle without trying to resolve Astro's virtual
6
6
  * modules at build time. The Astro-context wrappers (sortKey, getAllChapters,
7
7
  * getNeighbors) live in src/lib/chapters.ts and import from here.
8
+ *
9
+ * The academic `part`→ordinal mapping is shared with the label surfaces via
10
+ * `academicPartOrdinal` (src/lib/academic-parts.ts, also astro:content-free),
11
+ * so on-page sort order and the Roman heading prefix derive from one canonical
12
+ * order (`academicParts`) and cannot drift apart (#99).
8
13
  */
9
-
10
- /**
11
- * Ordinal positions for the academic-profile `part` enum (src/schemas.ts:
12
- * academicParts). Order here is load-bearing: it determines the on-page
13
- * grouping order of academic books. Anything outside the enum (e.g., a
14
- * consumer-extended part name) sorts to the end.
15
- */
16
- const ACADEMIC_PART_ORDINAL: Record<string, number> = {
17
- foundations: 1,
18
- 'ssm-core': 2,
19
- 'beyond-ssm': 3,
20
- integration: 4,
21
- synthesis: 5,
22
- };
23
-
24
- const UNKNOWN_PART_ORDINAL = 99;
14
+ import { academicPartOrdinal, UNKNOWN_PART_ORDINAL } from './academic-parts.js';
25
15
 
26
16
  /**
27
17
  * Pure-function sort key from a chapter frontmatter object. Spans both
@@ -43,7 +33,7 @@ export function chapterSortKey(data: Record<string, unknown>): number {
43
33
  typeof partRaw === 'number'
44
34
  ? partRaw
45
35
  : typeof partRaw === 'string'
46
- ? (ACADEMIC_PART_ORDINAL[partRaw] ?? UNKNOWN_PART_ORDINAL)
36
+ ? academicPartOrdinal(partRaw)
47
37
  : UNKNOWN_PART_ORDINAL;
48
38
  const within =
49
39
  typeof data.chapter === 'number'
@@ -79,6 +79,20 @@ export async function getAllPatterns(): Promise<PatternEntry[]> {
79
79
  });
80
80
  }
81
81
 
82
+ /**
83
+ * An all-empty category map (every category → []), in the exact shape
84
+ * getPatternsByCategory returns. Pure — no content-collection access — so it
85
+ * is safe to call when a book defines no `patterns` collection at all: the
86
+ * convergence dashboard renders its honest "no patterns yet" placeholders
87
+ * from it without ever calling getCollection('patterns') (#86, cf. how
88
+ * references.astro gates the absent `sources` collection).
89
+ */
90
+ export function emptyPatternsByCategory(): Record<PatternCategory, PatternEntry[]> {
91
+ return Object.fromEntries(
92
+ patternCategories.map((c) => [c, [] as PatternEntry[]]),
93
+ ) as Record<PatternCategory, PatternEntry[]>;
94
+ }
95
+
82
96
  /**
83
97
  * Patterns grouped by category; categories that have no patterns are
84
98
  * still emitted with an empty array so the dashboard can render
@@ -88,9 +102,7 @@ export async function getPatternsByCategory(): Promise<
88
102
  Record<PatternCategory, PatternEntry[]>
89
103
  > {
90
104
  const all = await getAllPatterns();
91
- const grouped = Object.fromEntries(
92
- patternCategories.map((c) => [c, [] as PatternEntry[]]),
93
- ) as Record<PatternCategory, PatternEntry[]>;
105
+ const grouped = emptyPatternsByCategory();
94
106
  for (const p of all) {
95
107
  const cat = p.data.category ?? 'other';
96
108
  grouped[cat].push(p);
@@ -12,29 +12,11 @@ import type {
12
12
  PartKey,
13
13
  StatusBadge,
14
14
  } from '../../lib/chapters-renderer.js';
15
-
16
- /**
17
- * Ordinal positions for the academic-profile `part` enum (src/schemas.ts:
18
- * academicParts). Order is load-bearing — drives both grouping order and
19
- * sort key. Must match academicParts in schemas.ts.
20
- */
21
- const ACADEMIC_PART_ORDINAL: Record<string, number> = {
22
- foundations: 1,
23
- 'ssm-core': 2,
24
- 'beyond-ssm': 3,
25
- integration: 4,
26
- synthesis: 5,
27
- };
28
-
29
- const UNKNOWN_PART_ORDINAL = 99;
30
-
31
- /** Title-case an enum string: "ssm-core" → "Ssm Core". */
32
- function titleCase(part: string): string {
33
- return part
34
- .split('-')
35
- .map((w) => (w.length > 0 ? w.charAt(0).toUpperCase() + w.slice(1) : ''))
36
- .join(' ');
37
- }
15
+ import {
16
+ academicPartName,
17
+ academicPartOrdinal,
18
+ UNKNOWN_PART_ORDINAL,
19
+ } from '../../lib/academic-parts.js';
38
20
 
39
21
  export const academicChaptersRenderer: ChaptersRenderer = {
40
22
  partKey(data) {
@@ -43,7 +25,7 @@ export const academicChaptersRenderer: ChaptersRenderer = {
43
25
 
44
26
  formatPartLabel(part) {
45
27
  if (typeof part === 'string' && part.length > 0) {
46
- return titleCase(part);
28
+ return academicPartName(part);
47
29
  }
48
30
  return String(part);
49
31
  },
@@ -92,7 +74,7 @@ export const academicChaptersRenderer: ChaptersRenderer = {
92
74
  const partRaw = data.part;
93
75
  const partOrdinal =
94
76
  typeof partRaw === 'string'
95
- ? (ACADEMIC_PART_ORDINAL[partRaw] ?? UNKNOWN_PART_ORDINAL)
77
+ ? academicPartOrdinal(partRaw)
96
78
  : typeof partRaw === 'number'
97
79
  ? partRaw
98
80
  : UNKNOWN_PART_ORDINAL;
package/styles/tokens.css CHANGED
@@ -34,7 +34,9 @@
34
34
  --color-bg: var(--paper);
35
35
  --color-bg-subtle: var(--code-bg);
36
36
  --color-text: var(--dark-text);
37
- --color-text-muted: color-mix(in srgb, var(--dark-text) 55%, var(--paper));
37
+ /* 65% clears WCAG AA on --paper (~5.4:1); 55% computed to ≈#807F7E ≈3.9:1, failing AA (#91).
38
+ color-mix re-resolves in the dark scope (cream over deep bg → higher contrast). */
39
+ --color-text-muted: color-mix(in srgb, var(--dark-text) 65%, var(--paper));
38
40
  --color-border: var(--code-frame);
39
41
  --color-link: var(--warm-blue);
40
42
  --color-heading: var(--warm-blue);