@brandon_m_behring/book-scaffold-astro 3.5.1 → 3.5.2

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/dist/index.d.ts CHANGED
@@ -79,4 +79,28 @@ declare function getFreshness(lastVerified: Date | undefined, volatility: Volati
79
79
  * affordance without a separate branch. */
80
80
  declare function freshnessLabel(f: Freshness | null): string;
81
81
 
82
- export { BookConfigOptions, BookScaffoldIntegrationOptions, type Freshness, type FreshnessStatus, type VolatilityLevel, bookScaffoldIntegration, defineBookConfig, defineMdxComponents, freshnessLabel, getFreshness, volatilityLevels };
82
+ /**
83
+ * src/lib/chapter-sort.ts — pure sort-key helper for chapter frontmatter.
84
+ *
85
+ * Lives in its own file (no `astro:content` imports) so tsup can include it
86
+ * in the toolkit's DTS bundle without trying to resolve Astro's virtual
87
+ * modules at build time. The Astro-context wrappers (sortKey, getAllChapters,
88
+ * getNeighbors) live in src/lib/chapters.ts and import from here.
89
+ */
90
+ /**
91
+ * Pure-function sort key from a chapter frontmatter object. Spans both
92
+ * tools and academic schemas:
93
+ *
94
+ * - Tools: numeric `part` (0-10), numeric `chapter` (0-99). Encodes as
95
+ * `part * 1000 + chapter` so chapters within a part stay grouped.
96
+ * - Academic: string-enum `part` (foundations|ssm-core|...), numeric `week`
97
+ * (1-99), no `chapter`. Maps `part` to a fixed ordinal then encodes as
98
+ * `partOrdinal * 1000 + week`.
99
+ *
100
+ * v3.5.2 (closes #24): previously the tools-only formula crashed on
101
+ * academic chapters (string `part`, no `chapter`) by producing NaN sort
102
+ * keys.
103
+ */
104
+ declare function chapterSortKey(data: Record<string, unknown>): number;
105
+
106
+ export { BookConfigOptions, BookScaffoldIntegrationOptions, type Freshness, type FreshnessStatus, type VolatilityLevel, bookScaffoldIntegration, chapterSortKey, defineBookConfig, defineMdxComponents, freshnessLabel, getFreshness, volatilityLevels };
package/dist/index.mjs CHANGED
@@ -697,6 +697,22 @@ function freshnessLabel(f) {
697
697
  return `Stale (${f.daysOld}d old; ${Math.abs(f.daysUntil)}d past threshold)`;
698
698
  }
699
699
  }
700
+
701
+ // src/lib/chapter-sort.ts
702
+ var ACADEMIC_PART_ORDINAL = {
703
+ foundations: 1,
704
+ "ssm-core": 2,
705
+ "beyond-ssm": 3,
706
+ integration: 4,
707
+ synthesis: 5
708
+ };
709
+ var UNKNOWN_PART_ORDINAL = 99;
710
+ function chapterSortKey(data) {
711
+ const partRaw = data.part;
712
+ const partOrdinal = typeof partRaw === "number" ? partRaw : typeof partRaw === "string" ? ACADEMIC_PART_ORDINAL[partRaw] ?? UNKNOWN_PART_ORDINAL : UNKNOWN_PART_ORDINAL;
713
+ const within = typeof data.chapter === "number" ? data.chapter : typeof data.week === "number" ? data.week : 0;
714
+ return partOrdinal * 1e3 + within;
715
+ }
700
716
  export {
701
717
  BOOK_PRESETS,
702
718
  BOOK_PROFILES,
@@ -706,6 +722,7 @@ export {
706
722
  bookScaffoldIntegration,
707
723
  changeKinds,
708
724
  changelogSchema,
725
+ chapterSortKey,
709
726
  chapterStatus,
710
727
  courseNotesChapterSchema,
711
728
  defineBookConfig,
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": "3.5.1",
4
+ "version": "3.5.2",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Brandon Behring",
@@ -2,10 +2,17 @@
2
2
  /**
3
3
  * /chapters — book index page.
4
4
  *
5
- * Groups non-draft chapters by Part in ascending part+chapter order.
6
- * Each card exposes `data-tools="<slug> <slug>..."` so future tool-filter
7
- * plumbing (Stage 3.2 commit 3) can hide cards via a single attribute
8
- * selector without touching the card rendering.
5
+ * Groups non-draft chapters by Part in ascending order. Each card exposes
6
+ * `data-tools="<slug> <slug>..."` so the ToolFilter island can hide cards via
7
+ * a single attribute selector without touching card rendering.
8
+ *
9
+ * v3.5.2 (closes #24): schema-aware. Previously hardcoded the tools-profile
10
+ * shape and crashed on academic profile (no `chapter` / `volatility` /
11
+ * `tools_compared` / `last_verified`). Now renders either shape:
12
+ * - tools: `Chapter N` + volatility badge + freshness + tools-compared tags
13
+ * - academic: `Week N` + status badge (no freshness or tools-compared)
14
+ * Field presence is the schema discriminator (cheaper than reading
15
+ * BOOK_PROFILE at the route layer).
9
16
  */
10
17
  import Base from '../layouts/Base.astro';
11
18
  import { getAllChapters, type Chapter } from '../src/lib/chapters';
@@ -13,94 +20,133 @@ import { getFreshness, freshnessLabel } from '../src/lib/freshness';
13
20
 
14
21
  const chapters = await getAllChapters();
15
22
 
16
- // Stable insertion-order grouping: iterate sorted chapters once.
17
- const byPart = new Map<number, Chapter[]>();
23
+ // Stable insertion-order grouping by `part`. Map key may be number (tools) or
24
+ // string (academic enum); Map preserves insertion order for both.
25
+ type PartKey = string | number;
26
+ const byPart = new Map<PartKey, Chapter[]>();
18
27
  for (const c of chapters) {
19
- const list = byPart.get(c.data.part);
28
+ const key = (c.data as { part: PartKey }).part;
29
+ const list = byPart.get(key);
20
30
  if (list) list.push(c);
21
- else byPart.set(c.data.part, [c]);
31
+ else byPart.set(key, [c]);
22
32
  }
23
33
 
24
34
  function formatDate(d: Date): string {
25
35
  return d.toISOString().slice(0, 10);
26
36
  }
27
37
 
28
- function partLabel(part: number, appendix: boolean): string {
29
- return appendix ? 'Appendices' : `Part ${part}`;
38
+ /** Render-ready label for a Part heading. Tools: "Part N" or "Appendices"
39
+ * (parts >= 6 are appendices). Academic: titlecase the enum string. */
40
+ function partLabel(part: PartKey): string {
41
+ if (typeof part === 'number') {
42
+ return part >= 6 ? 'Appendices' : `Part ${part}`;
43
+ }
44
+ return part
45
+ .split('-')
46
+ .map((w) => (w.length ? w[0].toUpperCase() + w.slice(1) : ''))
47
+ .join(' ');
48
+ }
49
+
50
+ function isAppendix(part: PartKey): boolean {
51
+ return typeof part === 'number' && part >= 6;
52
+ }
53
+
54
+ /** Per-card schema detection. Cheaper than reading BOOK_PROFILE — the shape
55
+ * of the chapter's data already tells us which profile produced it. */
56
+ function isToolsShape(data: Record<string, unknown>): boolean {
57
+ return 'volatility' in data && 'chapter' in data;
30
58
  }
31
59
  ---
32
60
  <Base
33
- title="Chapters — Agentic Coding"
34
- description="All chapters grouped by Part, with volatility, freshness, and tools-compared metadata at a glance."
61
+ title="Chapters"
62
+ description="All chapters grouped by Part."
35
63
  >
36
64
  <article class="prose chapters-index">
37
65
  <header class="chapters-index-header">
38
66
  <h1>Chapters</h1>
39
67
  <p class="chapters-index-lede">
40
- Every chapter, grouped by Part. Each card shows its volatility
41
- class, freshness status, and the tools it compares. Use a chapter
42
- card's metadata to calibrate how much trust to place in its
43
- specific claims — stable principles age slowly; feature surfaces
44
- age fast.
45
- </p>
46
- <p class="chapters-index-cross-ref">
47
- See also: <a href="/convergence/">convergence dashboard</a> —
48
- which patterns have landed in which tools, when.
68
+ Every chapter, grouped by Part. Use the card metadata to calibrate
69
+ how much trust to place in a chapter's specific claims.
49
70
  </p>
50
71
  </header>
51
72
 
52
73
  <p class="chapters-filter-hint" id="filter-hint" aria-live="polite"></p>
53
74
 
54
75
  {Array.from(byPart.entries()).map(([part, list]) => {
55
- const appendix = part >= 6;
76
+ const appendix = isAppendix(part);
56
77
  return (
57
78
  <section class="part-group">
58
79
  <h2 class="part-heading">
59
- <span class="part-label">{partLabel(part, appendix)}</span>
80
+ <span class="part-label">{partLabel(part)}</span>
60
81
  </h2>
61
82
  <ol class="chapter-list">
62
83
  {list.map((c) => {
63
- const freshness = getFreshness(c.data.last_verified, c.data.volatility);
64
- const freshnessText =
65
- freshness.status === 'fresh'
84
+ const data = c.data as Record<string, unknown>;
85
+ const tools = isToolsShape(data);
86
+ // data-tools attribute drives the ToolFilter island. Academic
87
+ // cards opt out by claiming "cross-tool" — always visible
88
+ // regardless of filter state.
89
+ const toolsAttr = tools
90
+ ? (data.tools_compared as string[]).join(' ')
91
+ : 'cross-tool';
92
+ const freshness = tools
93
+ ? getFreshness(data.last_verified as Date, data.volatility as Parameters<typeof getFreshness>[1])
94
+ : null;
95
+ const freshnessText = freshness
96
+ ? freshness.status === 'fresh'
66
97
  ? 'Fresh'
67
98
  : freshness.status === 'verify-soon'
68
99
  ? 'Verify soon'
69
- : 'Stale';
70
- const toolsAttr = c.data.tools_compared.join(' ');
100
+ : 'Stale'
101
+ : null;
71
102
  return (
72
- <li
73
- class="chapter-card"
74
- data-tools={toolsAttr}
75
- >
103
+ <li class="chapter-card" data-tools={toolsAttr}>
76
104
  <a href={`/${c.id}/`} class="chapter-card-link">
77
105
  <div class="chapter-card-meta">
78
106
  <span class="chapter-card-number">
79
- {appendix ? `Appendix ${String.fromCharCode(64 + c.data.chapter).toLowerCase()}` : `Chapter ${c.data.chapter}`}
80
- </span>
81
- <span
82
- class={`volatility-badge volatility-${c.data.volatility}`}
83
- title={`Volatility: ${c.data.volatility}`}
84
- >{c.data.volatility}</span>
85
- <span
86
- class="freshness-badge"
87
- data-status={freshness.status}
88
- aria-label={freshnessLabel(freshness)}
89
- title={freshnessLabel(freshness)}
90
- >{freshnessText}</span>
91
- <span class="chapter-card-verified">
92
- verified {formatDate(c.data.last_verified)}
107
+ {tools
108
+ ? appendix
109
+ ? `Appendix ${String.fromCharCode(64 + (data.chapter as number)).toLowerCase()}`
110
+ : `Chapter ${data.chapter}`
111
+ : `Week ${data.week}`}
93
112
  </span>
113
+ {tools && (
114
+ <span
115
+ class={`volatility-badge volatility-${data.volatility}`}
116
+ title={`Volatility: ${data.volatility}`}
117
+ >{data.volatility}</span>
118
+ )}
119
+ {!tools && data.status && (
120
+ <span
121
+ class={`status-badge status-${data.status}`}
122
+ title={`Status: ${data.status}`}
123
+ >{data.status}</span>
124
+ )}
125
+ {freshness && freshnessText && (
126
+ <span
127
+ class="freshness-badge"
128
+ data-status={freshness.status}
129
+ aria-label={freshnessLabel(freshness)}
130
+ title={freshnessLabel(freshness)}
131
+ >{freshnessText}</span>
132
+ )}
133
+ {tools && data.last_verified && (
134
+ <span class="chapter-card-verified">
135
+ verified {formatDate(data.last_verified as Date)}
136
+ </span>
137
+ )}
94
138
  </div>
95
- <h3 class="chapter-card-title">{c.data.title}</h3>
96
- {c.data.description && (
97
- <p class="chapter-card-description">{c.data.description}</p>
139
+ <h3 class="chapter-card-title">{data.title}</h3>
140
+ {data.description && (
141
+ <p class="chapter-card-description">{data.description as string}</p>
142
+ )}
143
+ {tools && (
144
+ <div class="chapter-card-tools">
145
+ {(data.tools_compared as string[]).map((t) => (
146
+ <span class="tool-badge">{t}</span>
147
+ ))}
148
+ </div>
98
149
  )}
99
- <div class="chapter-card-tools">
100
- {c.data.tools_compared.map((t) => (
101
- <span class="tool-badge">{t}</span>
102
- ))}
103
- </div>
104
150
  </a>
105
151
  </li>
106
152
  );
@@ -0,0 +1,55 @@
1
+ /**
2
+ * src/lib/chapter-sort.ts — pure sort-key helper for chapter frontmatter.
3
+ *
4
+ * Lives in its own file (no `astro:content` imports) so tsup can include it
5
+ * in the toolkit's DTS bundle without trying to resolve Astro's virtual
6
+ * modules at build time. The Astro-context wrappers (sortKey, getAllChapters,
7
+ * getNeighbors) live in src/lib/chapters.ts and import from here.
8
+ */
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;
25
+
26
+ /**
27
+ * Pure-function sort key from a chapter frontmatter object. Spans both
28
+ * tools and academic schemas:
29
+ *
30
+ * - Tools: numeric `part` (0-10), numeric `chapter` (0-99). Encodes as
31
+ * `part * 1000 + chapter` so chapters within a part stay grouped.
32
+ * - Academic: string-enum `part` (foundations|ssm-core|...), numeric `week`
33
+ * (1-99), no `chapter`. Maps `part` to a fixed ordinal then encodes as
34
+ * `partOrdinal * 1000 + week`.
35
+ *
36
+ * v3.5.2 (closes #24): previously the tools-only formula crashed on
37
+ * academic chapters (string `part`, no `chapter`) by producing NaN sort
38
+ * keys.
39
+ */
40
+ export function chapterSortKey(data: Record<string, unknown>): number {
41
+ const partRaw = data.part;
42
+ const partOrdinal =
43
+ typeof partRaw === 'number'
44
+ ? partRaw
45
+ : typeof partRaw === 'string'
46
+ ? (ACADEMIC_PART_ORDINAL[partRaw] ?? UNKNOWN_PART_ORDINAL)
47
+ : UNKNOWN_PART_ORDINAL;
48
+ const within =
49
+ typeof data.chapter === 'number'
50
+ ? data.chapter
51
+ : typeof data.week === 'number'
52
+ ? data.week
53
+ : 0;
54
+ return partOrdinal * 1000 + within;
55
+ }
@@ -1,19 +1,26 @@
1
1
  /**
2
2
  * src/lib/chapters.ts — ordering + nav helpers for the chapters collection.
3
3
  *
4
- * Centralizes the sort key (part × 100 + chapter) and draft filtering so
5
- * components and static-path generation use the same logic.
4
+ * Astro-context wrappers (need `astro:content`). The pure sort-key logic
5
+ * lives in src/lib/chapter-sort.ts so it can be included in the toolkit's
6
+ * DTS bundle without dragging Astro virtual modules into the build graph.
7
+ *
8
+ * v3.5.2 (closes #24): schema-aware sort. Previously assumed tools-profile
9
+ * shape (numeric `part` * 1000 + numeric `chapter`); academic chapters
10
+ * (string `part` enum + numeric `week`, no `chapter`) crashed.
6
11
  */
7
12
  import { getCollection, type CollectionEntry } from 'astro:content';
13
+ import { chapterSortKey } from './chapter-sort.js';
8
14
 
9
15
  export type Chapter = CollectionEntry<'chapters'>;
10
16
 
11
- /** Numeric sort key; chapters within a part come before a higher part. */
17
+ /** Sort key for an Astro Chapter collection entry. Thin wrapper over the
18
+ * pure `chapterSortKey` helper. */
12
19
  export function sortKey(c: Chapter): number {
13
- return c.data.part * 1000 + c.data.chapter;
20
+ return chapterSortKey(c.data as Record<string, unknown>);
14
21
  }
15
22
 
16
- /** All non-draft chapters, ordered by part+chapter ascending. */
23
+ /** All non-draft chapters, ordered by part+chapter (tools) or part+week (academic). */
17
24
  export async function getAllChapters(): Promise<Chapter[]> {
18
25
  const all = await getCollection('chapters', (entry) => !entry.data.draft);
19
26
  return all.sort((a, b) => sortKey(a) - sortKey(b));