@brandon_m_behring/book-scaffold-astro 4.13.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,32 +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
369
- };
370
- var UNKNOWN_PART_ORDINAL = 99;
371
- var ACADEMIC_PART_LABEL = {
362
+ // src/lib/academic-parts.ts
363
+ var ACADEMIC_PART_NAMES = {
372
364
  foundations: "Foundations",
373
365
  "ssm-core": "SSM Core",
374
- "beyond-ssm": "Beyond SSM",
366
+ "beyond-ssm": "Beyond SSMs",
375
367
  integration: "Integration",
376
368
  synthesis: "Synthesis"
377
369
  };
370
+ var ROMAN = ["I", "II", "III", "IV", "V", "VI", "VII", "VIII"];
378
371
  function titleCase(part) {
379
372
  return part.split("-").map((w) => w.length > 0 ? w.charAt(0).toUpperCase() + w.slice(1) : "").join(" ");
380
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
381
389
  var academicChaptersRenderer = {
382
390
  partKey(data) {
383
391
  return data.part ?? "";
384
392
  },
385
393
  formatPartLabel(part) {
386
394
  if (typeof part === "string" && part.length > 0) {
387
- return ACADEMIC_PART_LABEL[part] ?? titleCase(part);
395
+ return academicPartName(part);
388
396
  }
389
397
  return String(part);
390
398
  },
@@ -417,7 +425,7 @@ var academicChaptersRenderer = {
417
425
  },
418
426
  sortKey(data) {
419
427
  const partRaw = data.part;
420
- 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;
421
429
  const week = typeof data.week === "number" ? data.week : 0;
422
430
  return partOrdinal * 1e3 + week;
423
431
  }
@@ -1226,17 +1234,9 @@ async function defineBookConfig(opts) {
1226
1234
  }
1227
1235
 
1228
1236
  // src/lib/chapter-sort.ts
1229
- var ACADEMIC_PART_ORDINAL2 = {
1230
- foundations: 1,
1231
- "ssm-core": 2,
1232
- "beyond-ssm": 3,
1233
- integration: 4,
1234
- synthesis: 5
1235
- };
1236
- var UNKNOWN_PART_ORDINAL2 = 99;
1237
1237
  function chapterSortKey(data) {
1238
1238
  const partRaw = data.part;
1239
- 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;
1240
1240
  const within = typeof data.chapter === "number" ? data.chapter : typeof data.week === "number" ? data.week : 0;
1241
1241
  return partOrdinal * 1e3 + within;
1242
1242
  }
@@ -1281,13 +1281,18 @@ function defineTips(opts) {
1281
1281
  return { __tipsConfigVersion: 1, ...opts };
1282
1282
  }
1283
1283
  export {
1284
+ ACADEMIC_PART_NAMES,
1284
1285
  BOOK_PRESETS,
1285
1286
  BOOK_PROFILES,
1286
1287
  BRANDON_PORTFOLIO_DEFAULT,
1287
1288
  BUILTIN_STYLES,
1288
1289
  BookConfigError,
1290
+ UNKNOWN_PART_ORDINAL,
1289
1291
  academicChapterSchema,
1290
1292
  academicChaptersRenderer,
1293
+ academicPartHeading,
1294
+ academicPartName,
1295
+ academicPartOrdinal,
1291
1296
  academicParts,
1292
1297
  academicStyle,
1293
1298
  bookScaffoldIntegration,
package/dist/schemas.mjs CHANGED
@@ -243,32 +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
253
- };
254
- var UNKNOWN_PART_ORDINAL = 99;
255
- var ACADEMIC_PART_LABEL = {
246
+ // src/lib/academic-parts.ts
247
+ var ACADEMIC_PART_NAMES = {
256
248
  foundations: "Foundations",
257
249
  "ssm-core": "SSM Core",
258
- "beyond-ssm": "Beyond SSM",
250
+ "beyond-ssm": "Beyond SSMs",
259
251
  integration: "Integration",
260
252
  synthesis: "Synthesis"
261
253
  };
262
254
  function titleCase(part) {
263
255
  return part.split("-").map((w) => w.length > 0 ? w.charAt(0).toUpperCase() + w.slice(1) : "").join(" ");
264
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
265
267
  var academicChaptersRenderer = {
266
268
  partKey(data) {
267
269
  return data.part ?? "";
268
270
  },
269
271
  formatPartLabel(part) {
270
272
  if (typeof part === "string" && part.length > 0) {
271
- return ACADEMIC_PART_LABEL[part] ?? titleCase(part);
273
+ return academicPartName(part);
272
274
  }
273
275
  return String(part);
274
276
  },
@@ -301,7 +303,7 @@ var academicChaptersRenderer = {
301
303
  },
302
304
  sortKey(data) {
303
305
  const partRaw = data.part;
304
- 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;
305
307
  const week = typeof data.week === "number" ? data.week : 0;
306
308
  return partOrdinal * 1e3 + week;
307
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.13.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,44 +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
- /**
32
- * Display labels for the academic-profile `part` enum. An explicit map (not
33
- * naive title-casing) so acronyms render correctly: `ssm-core` → "SSM Core",
34
- * not "Ssm Core" (#91). Keys mirror ACADEMIC_PART_ORDINAL; unknown/custom
35
- * parts fall back to titleCase() in formatPartLabel.
36
- */
37
- const ACADEMIC_PART_LABEL: Record<string, string> = {
38
- foundations: 'Foundations',
39
- 'ssm-core': 'SSM Core',
40
- 'beyond-ssm': 'Beyond SSM',
41
- integration: 'Integration',
42
- synthesis: 'Synthesis',
43
- };
44
-
45
- /** Title-case an enum string: "ssm-core" → "Ssm Core". Fallback for parts
46
- * outside the known ACADEMIC_PART_LABEL map (e.g. consumer-custom parts). */
47
- function titleCase(part: string): string {
48
- return part
49
- .split('-')
50
- .map((w) => (w.length > 0 ? w.charAt(0).toUpperCase() + w.slice(1) : ''))
51
- .join(' ');
52
- }
15
+ import {
16
+ academicPartName,
17
+ academicPartOrdinal,
18
+ UNKNOWN_PART_ORDINAL,
19
+ } from '../../lib/academic-parts.js';
53
20
 
54
21
  export const academicChaptersRenderer: ChaptersRenderer = {
55
22
  partKey(data) {
@@ -58,7 +25,7 @@ export const academicChaptersRenderer: ChaptersRenderer = {
58
25
 
59
26
  formatPartLabel(part) {
60
27
  if (typeof part === 'string' && part.length > 0) {
61
- return ACADEMIC_PART_LABEL[part] ?? titleCase(part);
28
+ return academicPartName(part);
62
29
  }
63
30
  return String(part);
64
31
  },
@@ -107,7 +74,7 @@ export const academicChaptersRenderer: ChaptersRenderer = {
107
74
  const partRaw = data.part;
108
75
  const partOrdinal =
109
76
  typeof partRaw === 'string'
110
- ? (ACADEMIC_PART_ORDINAL[partRaw] ?? UNKNOWN_PART_ORDINAL)
77
+ ? academicPartOrdinal(partRaw)
111
78
  : typeof partRaw === 'number'
112
79
  ? partRaw
113
80
  : UNKNOWN_PART_ORDINAL;