@brandon_m_behring/book-scaffold-astro 3.0.0-alpha.0 → 3.0.0-alpha.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.mjs CHANGED
@@ -152,12 +152,12 @@ var ALWAYS_ON_STYLES = [
152
152
  ];
153
153
  var TOOLS_ONLY_STYLES = ["convergence.css", "tool-filter.css"];
154
154
  var DEFAULT_ROUTES_ALL = [
155
- { pattern: "/chapters", file: "chapters.astro" },
156
155
  { pattern: "/references", file: "references.astro" },
157
156
  { pattern: "/print", file: "print.astro" },
158
157
  { pattern: "/search", file: "search.astro" }
159
158
  ];
160
159
  var DEFAULT_ROUTES_TOOLS = [
160
+ { pattern: "/chapters", file: "chapters.astro" },
161
161
  { pattern: "/convergence", file: "convergence.astro" }
162
162
  ];
163
163
  function resolvePage(file) {
package/dist/schemas.d.ts CHANGED
@@ -6,12 +6,7 @@ import 'astro';
6
6
  * consumer extends via object spread and Zod `.extend()` (see PACKAGE_DESIGN.md §5).
7
7
  */
8
8
  declare function defineBookSchemas(opts?: BookSchemasOptions): {
9
- collections: {
10
- chapters: unknown;
11
- sources: unknown;
12
- changelog: unknown;
13
- patterns: unknown;
14
- };
9
+ collections: Record<string, unknown>;
15
10
  };
16
11
 
17
12
  export { defineBookSchemas };
package/dist/schemas.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  // src/schemas-entry.ts
2
+ import { existsSync } from "fs";
2
3
  import { defineCollection } from "astro:content";
3
4
  import { glob, file } from "astro/loaders";
4
5
 
@@ -139,21 +140,28 @@ function defineBookSchemas(opts = {}) {
139
140
  }),
140
141
  schema: profile === "academic" ? academicChapterSchema : toolsChapterSchema
141
142
  });
142
- const sources = defineCollection({
143
- loader: file("sources/manifest.yaml"),
144
- schema: sourcesSchema
145
- });
146
- const changelog = defineCollection({
147
- loader: glob({ pattern: "*.yaml", base: "./changelog/tools" }),
148
- schema: changelogSchema
149
- });
150
- const patterns = defineCollection({
151
- loader: file("changelog/patterns.yaml"),
152
- schema: patternsSchema
153
- });
154
- return {
155
- collections: { chapters, sources, changelog, patterns }
143
+ const collections = {
144
+ chapters
156
145
  };
146
+ if (existsSync("./sources/manifest.yaml")) {
147
+ collections.sources = defineCollection({
148
+ loader: file("sources/manifest.yaml"),
149
+ schema: sourcesSchema
150
+ });
151
+ }
152
+ if (existsSync("./changelog/tools")) {
153
+ collections.changelog = defineCollection({
154
+ loader: glob({ pattern: "*.yaml", base: "./changelog/tools" }),
155
+ schema: changelogSchema
156
+ });
157
+ }
158
+ if (existsSync("./changelog/patterns.yaml")) {
159
+ collections.patterns = defineCollection({
160
+ loader: file("changelog/patterns.yaml"),
161
+ schema: patternsSchema
162
+ });
163
+ }
164
+ return { collections };
157
165
  }
158
166
  export {
159
167
  defineBookSchemas
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.0.0-alpha.0",
4
+ "version": "3.0.0-alpha.2",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Brandon Behring",
@@ -95,6 +95,7 @@
95
95
  },
96
96
  "files": [
97
97
  "dist",
98
+ "src/lib",
98
99
  "components",
99
100
  "styles",
100
101
  "layouts",
@@ -0,0 +1,37 @@
1
+ /**
2
+ * src/lib/chapters.ts — ordering + nav helpers for the chapters collection.
3
+ *
4
+ * Centralizes the sort key (part × 100 + chapter) and draft filtering so
5
+ * components and static-path generation use the same logic.
6
+ */
7
+ import { getCollection, type CollectionEntry } from 'astro:content';
8
+
9
+ export type Chapter = CollectionEntry<'chapters'>;
10
+
11
+ /** Numeric sort key; chapters within a part come before a higher part. */
12
+ export function sortKey(c: Chapter): number {
13
+ return c.data.part * 1000 + c.data.chapter;
14
+ }
15
+
16
+ /** All non-draft chapters, ordered by part+chapter ascending. */
17
+ export async function getAllChapters(): Promise<Chapter[]> {
18
+ const all = await getCollection('chapters', (entry) => !entry.data.draft);
19
+ return all.sort((a, b) => sortKey(a) - sortKey(b));
20
+ }
21
+
22
+ /**
23
+ * Given a chapter id, return its ordered neighbors.
24
+ * Either may be null at the edges of the book.
25
+ */
26
+ export async function getNeighbors(id: string): Promise<{
27
+ prev: Chapter | null;
28
+ next: Chapter | null;
29
+ }> {
30
+ const all = await getAllChapters();
31
+ const idx = all.findIndex((c) => c.id === id);
32
+ if (idx === -1) return { prev: null, next: null };
33
+ return {
34
+ prev: idx > 0 ? all[idx - 1] : null,
35
+ next: idx < all.length - 1 ? all[idx + 1] : null,
36
+ };
37
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * src/lib/freshness.ts — volatility-aware staleness computation.
3
+ *
4
+ * Each chapter carries a `last_verified` date and a `volatility` class.
5
+ * This module maps those to a freshness status the reader can trust.
6
+ *
7
+ * Thresholds chosen to align with Ch 15's source-tier audit cadences:
8
+ * stable-principle → 365 days (principles drift annually at most)
9
+ * architectural-pattern → 180 days (between annual and quarterly; "on
10
+ * major release" isn't derivable from
11
+ * frontmatter)
12
+ * feature-surface → 90 days (quarterly)
13
+ *
14
+ * Status bands (fraction of threshold):
15
+ * fresh (<75%) — green dot, unobtrusive
16
+ * verify-soon (75-100%) — yellow, mild warning
17
+ * stale (>100%) — amber/red, "verify before trusting"
18
+ *
19
+ * Example assertions (verified by visual inspection during Stage 3.1):
20
+ * getFreshness(today, 'feature-surface').status === 'fresh'
21
+ * getFreshness(today-70d, 'feature-surface').status === 'verify-soon'
22
+ * getFreshness(today-100d, 'feature-surface').status === 'stale'
23
+ * getFreshness(today-200d, 'stable-principle').status === 'fresh'
24
+ * getFreshness(today-300d, 'stable-principle').status === 'verify-soon'
25
+ */
26
+ import type { volatilityLevels } from '../schemas.js';
27
+
28
+ export type VolatilityLevel = (typeof volatilityLevels)[number];
29
+
30
+ export type FreshnessStatus = 'fresh' | 'verify-soon' | 'stale';
31
+
32
+ export interface Freshness {
33
+ status: FreshnessStatus;
34
+ daysOld: number;
35
+ thresholdDays: number;
36
+ /** Days until stale; negative when already stale. */
37
+ daysUntil: number;
38
+ }
39
+
40
+ const THRESHOLDS: Record<VolatilityLevel, number> = {
41
+ 'stable-principle': 365,
42
+ 'architectural-pattern': 180,
43
+ 'feature-surface': 90,
44
+ };
45
+
46
+ const MS_PER_DAY = 1000 * 60 * 60 * 24;
47
+
48
+ /**
49
+ * Compute freshness for a chapter given its last_verified date + volatility.
50
+ *
51
+ * Pure function; caller supplies `now` only in tests. Production callers omit.
52
+ */
53
+ export function getFreshness(
54
+ lastVerified: Date,
55
+ volatility: VolatilityLevel,
56
+ now: Date = new Date(),
57
+ ): Freshness {
58
+ const thresholdDays = THRESHOLDS[volatility];
59
+ const daysOld = Math.floor((now.getTime() - lastVerified.getTime()) / MS_PER_DAY);
60
+ const daysUntil = thresholdDays - daysOld;
61
+
62
+ let status: FreshnessStatus;
63
+ if (daysOld < thresholdDays * 0.75) {
64
+ status = 'fresh';
65
+ } else if (daysOld < thresholdDays) {
66
+ status = 'verify-soon';
67
+ } else {
68
+ status = 'stale';
69
+ }
70
+
71
+ return { status, daysOld, thresholdDays, daysUntil };
72
+ }
73
+
74
+ /** Human-readable label for each status; used for ARIA + tooltips. */
75
+ export function freshnessLabel(f: Freshness): string {
76
+ switch (f.status) {
77
+ case 'fresh':
78
+ return `Fresh (${f.daysOld}d old; verify within ${f.daysUntil}d)`;
79
+ case 'verify-soon':
80
+ return `Verify soon (${f.daysOld}d old; ${f.daysUntil}d until stale)`;
81
+ case 'stale':
82
+ return `Stale (${f.daysOld}d old; ${Math.abs(f.daysUntil)}d past threshold)`;
83
+ }
84
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * src/lib/katex-macros.ts — KaTeX macro definitions ported from the LaTeX guide.
3
+ *
4
+ * Source files in the LaTeX tree:
5
+ * - guides/shared/ssm-notation.tex (20 SSM-specific macros)
6
+ * - guides/shared/ssm-guide-preamble.sty:387-413 (16 general math macros)
7
+ *
8
+ * Wired into astro.config.mjs via:
9
+ * rehypeKatex({ strict: 'error', macros: ssmMacros, trust: true })
10
+ *
11
+ * KaTeX-specific adaptations:
12
+ * - \bm is not part of KaTeX; aliased to \boldsymbol (visually identical
13
+ * in stix-two / Computer Modern math fonts).
14
+ * - \DeclareMathOperator and \DeclareMathOperator* are translated to
15
+ * \operatorname / \operatorname* respectively.
16
+ * - \psmallmatrix is an environment in amsmath, not a macro. KaTeX
17
+ * macros operate at command level; substitution happens in the
18
+ * converter script (latex-to-mdx.mjs in Phase 2.7) — search for
19
+ * \begin{psmallmatrix} and replace with \begin{pmatrix}.
20
+ *
21
+ * Equation numbering is handled by a separate remark plugin (deferred to
22
+ * a follow-up session per plan Phase 2.1 Task #9).
23
+ */
24
+
25
+ export const ssmMacros: Record<string, string> = {
26
+ // -----------------------------------------------------------------
27
+ // Compatibility alias: \bm{x} -> \boldsymbol{x}
28
+ // KaTeX does not ship \bm. The LaTeX source uses \bm extensively in
29
+ // ssm-notation.tex for vectors and matrices.
30
+ // -----------------------------------------------------------------
31
+ '\\bm': '\\boldsymbol{#1}',
32
+
33
+ // -----------------------------------------------------------------
34
+ // SSM state space variables (ssm-notation.tex:5-13)
35
+ // -----------------------------------------------------------------
36
+ '\\statevec': '\\boldsymbol{h}', // state vector h_t
37
+ '\\statemat': '\\boldsymbol{A}', // dynamics matrix A
38
+ '\\inputmat': '\\boldsymbol{B}', // input matrix B
39
+ '\\outputmat': '\\boldsymbol{C}', // output matrix C
40
+ '\\feedmat': '\\boldsymbol{D}', // feedthrough matrix D
41
+ '\\stepsize': '\\Delta', // discretization step
42
+ '\\discA': '\\bar{\\boldsymbol{A}}', // discretized A
43
+ '\\discB': '\\bar{\\boldsymbol{B}}', // discretized B
44
+
45
+ // -----------------------------------------------------------------
46
+ // Dimensions (ssm-notation.tex:16-18)
47
+ // -----------------------------------------------------------------
48
+ '\\seqlen': 'L', // sequence length
49
+ '\\statedim': 'N', // state dimension
50
+ '\\inputdim': 'D', // input / model dimension
51
+
52
+ // -----------------------------------------------------------------
53
+ // Scan operator (ssm-notation.tex:23-24)
54
+ // -----------------------------------------------------------------
55
+ '\\scanop': '\\oplus', // associative binary operator for diagonal SSMs
56
+ '\\elemwise': '\\odot', // element-wise product
57
+
58
+ // -----------------------------------------------------------------
59
+ // Dynamical systems (ssm-notation.tex:27-30)
60
+ // -----------------------------------------------------------------
61
+ '\\monodromy': '\\boldsymbol{Z}', // monodromy matrix Z(T)
62
+ '\\floquet': '\\mu', // Floquet multiplier
63
+ '\\lyapexp': '\\lambda', // Lyapunov exponent
64
+ '\\jacobian': '\\boldsymbol{J}', // Jacobian matrix
65
+
66
+ // -----------------------------------------------------------------
67
+ // Calculus shortcuts (ssm-notation.tex:33-35)
68
+ // -----------------------------------------------------------------
69
+ '\\ddt': '\\frac{d}{dt}',
70
+ '\\pderiv': '\\frac{\\partial #1}{\\partial #2}', // 2 args
71
+ '\\spectralradius': '\\rho',
72
+
73
+ // -----------------------------------------------------------------
74
+ // Common sets (preamble:390-393)
75
+ // -----------------------------------------------------------------
76
+ '\\R': '\\mathbb{R}',
77
+ '\\C': '\\mathbb{C}',
78
+ '\\N': '\\mathbb{N}',
79
+ '\\Z': '\\mathbb{Z}',
80
+
81
+ // -----------------------------------------------------------------
82
+ // Probability / statistics (preamble:396-397)
83
+ // -----------------------------------------------------------------
84
+ '\\E': '\\mathbb{E}',
85
+ '\\Prob': '\\mathbb{P}',
86
+
87
+ // -----------------------------------------------------------------
88
+ // Norms and inner products (preamble:400-402)
89
+ // -----------------------------------------------------------------
90
+ '\\norm': '\\lVert #1 \\rVert',
91
+ '\\ip': '\\langle #1, #2 \\rangle',
92
+ '\\abs': '\\lvert #1 \\rvert',
93
+
94
+ // -----------------------------------------------------------------
95
+ // Operators (preamble:405-410)
96
+ // \DeclareMathOperator* -> \operatorname* (with limits below in display)
97
+ // \DeclareMathOperator -> \operatorname
98
+ // -----------------------------------------------------------------
99
+ '\\argmax': '\\operatorname*{arg\\,max}',
100
+ '\\argmin': '\\operatorname*{arg\\,min}',
101
+ '\\diag': '\\operatorname{diag}',
102
+ '\\tr': '\\operatorname{tr}',
103
+ '\\spec': '\\operatorname{spec}',
104
+ '\\rank': '\\operatorname{rank}',
105
+
106
+ // -----------------------------------------------------------------
107
+ // Complexity (preamble:413)
108
+ // -----------------------------------------------------------------
109
+ '\\bigO': '\\mathcal{O}(#1)',
110
+ };
@@ -0,0 +1,127 @@
1
+ /**
2
+ * src/lib/patterns.ts — helpers for the convergence dashboard.
3
+ *
4
+ * Joins the patterns collection (registry of agentic-coding patterns)
5
+ * with the changelog collection (per-tool version timelines) to answer
6
+ * the two questions the dashboard surfaces:
7
+ *
8
+ * 1. For a given pattern, which tools adopted it and when?
9
+ * 2. What is the set of tracked patterns, in a stable display order?
10
+ *
11
+ * Neither file format stores the join directly; these helpers compute
12
+ * it lazily at build time so new tool/version rows require no code
13
+ * changes.
14
+ */
15
+ import { getCollection, type CollectionEntry } from 'astro:content';
16
+ import {
17
+ toolSlugs,
18
+ patternCategories,
19
+ changeKinds,
20
+ } from '../content.config';
21
+
22
+ export type PatternEntry = CollectionEntry<'patterns'>;
23
+ export type ToolSlug = (typeof toolSlugs)[number];
24
+ export type PatternCategory = (typeof patternCategories)[number];
25
+ export type ChangeKind = (typeof changeKinds)[number];
26
+
27
+ export interface TimelineEntry {
28
+ tool: ToolSlug;
29
+ version: string;
30
+ date: Date;
31
+ kind: ChangeKind;
32
+ note: string;
33
+ sourceKey?: string;
34
+ }
35
+
36
+ /**
37
+ * Per-pattern timeline: every (tool, version, change) triple where
38
+ * change.pattern === slug, sorted by date ascending. A pattern that no
39
+ * tool has adopted yet returns an empty array.
40
+ */
41
+ export async function getPatternTimeline(slug: string): Promise<TimelineEntry[]> {
42
+ const tools = await getCollection('changelog');
43
+ const out: TimelineEntry[] = [];
44
+ for (const entry of tools) {
45
+ const tool = entry.data.tool;
46
+ for (const v of entry.data.versions) {
47
+ for (const change of v.changes) {
48
+ if (change.pattern !== slug) continue;
49
+ out.push({
50
+ tool,
51
+ version: v.version,
52
+ date: v.date,
53
+ kind: change.kind,
54
+ note: change.note,
55
+ sourceKey: change.source_key,
56
+ });
57
+ }
58
+ }
59
+ }
60
+ out.sort((a, b) => a.date.getTime() - b.date.getTime());
61
+ return out;
62
+ }
63
+
64
+ /**
65
+ * All patterns in stable display order: converged patterns first (by
66
+ * convergence_date ascending — oldest convergence first), then
67
+ * unconverged patterns (by slug). Within each bucket, preserves
68
+ * manifest order.
69
+ */
70
+ export async function getAllPatterns(): Promise<PatternEntry[]> {
71
+ const all = await getCollection('patterns');
72
+ return all.sort((a, b) => {
73
+ const aDate = a.data.convergence_date?.getTime();
74
+ const bDate = b.data.convergence_date?.getTime();
75
+ if (aDate != null && bDate != null) return aDate - bDate;
76
+ if (aDate != null) return -1;
77
+ if (bDate != null) return 1;
78
+ return a.id.localeCompare(b.id);
79
+ });
80
+ }
81
+
82
+ /**
83
+ * Patterns grouped by category; categories that have no patterns are
84
+ * still emitted with an empty array so the dashboard can render
85
+ * honest "no patterns yet" placeholders without checking undefined.
86
+ */
87
+ export async function getPatternsByCategory(): Promise<
88
+ Record<PatternCategory, PatternEntry[]>
89
+ > {
90
+ const all = await getAllPatterns();
91
+ const grouped = Object.fromEntries(
92
+ patternCategories.map((c) => [c, [] as PatternEntry[]]),
93
+ ) as Record<PatternCategory, PatternEntry[]>;
94
+ for (const p of all) {
95
+ const cat = p.data.category ?? 'other';
96
+ grouped[cat].push(p);
97
+ }
98
+ return grouped;
99
+ }
100
+
101
+ /** Human-readable labels per category. */
102
+ export const CATEGORY_LABELS: Record<PatternCategory, string> = {
103
+ safety: 'Safety',
104
+ scale: 'Scale',
105
+ context: 'Context',
106
+ interaction: 'Interaction',
107
+ extension: 'Extension',
108
+ other: 'Other',
109
+ };
110
+
111
+ /**
112
+ * Which tools have adopted a given pattern (deduplicated). Helpful
113
+ * for computing "adopted by N of 3 tools" summary counts.
114
+ */
115
+ export function toolsInTimeline(timeline: TimelineEntry[]): ToolSlug[] {
116
+ const seen = new Set<ToolSlug>();
117
+ for (const e of timeline) seen.add(e.tool);
118
+ return Array.from(seen);
119
+ }
120
+
121
+ /** Fixed display order for tool rows on the timeline. 'cross-tool' is
122
+ * excluded because a pattern is something tools adopt individually. */
123
+ export const TIMELINE_TOOL_ORDER: ToolSlug[] = [
124
+ 'claude-code',
125
+ 'gemini-cli',
126
+ 'codex-cli',
127
+ ];
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Repository URL constants used by CodeRef and CodeBlock components.
3
+ *
4
+ * Centralized here so renaming the repo or moving branches is a one-file
5
+ * change rather than a hunt across components.
6
+ *
7
+ * If the repo is later mirrored to a different host, update GITHUB_BASE
8
+ * accordingly; the components do not assume GitHub-specific anchor syntax
9
+ * beyond #L<N>-L<M>, which all major hosts (GitHub, GitLab, Codeberg)
10
+ * support.
11
+ */
12
+ export const GITHUB_REPO = 'brandon-behring/post_transformers';
13
+ export const GITHUB_BRANCH = 'main';
14
+ export const GITHUB_BASE = `https://github.com/${GITHUB_REPO}/blob/${GITHUB_BRANCH}`;
15
+
16
+ /**
17
+ * Build a GitHub line-anchor URL.
18
+ * buildGithubUrl('experiments/jax/week04/s4.py', 42) -> .../s4.py#L42
19
+ * buildGithubUrl('experiments/jax/week04/s4.py', 42, 58) -> .../s4.py#L42-L58
20
+ * buildGithubUrl('experiments/jax/week04/s4.py') -> .../s4.py
21
+ */
22
+ export function buildGithubUrl(path: string, line?: number, lineEnd?: number): string {
23
+ const cleanPath = path.replace(/^\/+/, '');
24
+ let url = `${GITHUB_BASE}/${cleanPath}`;
25
+ if (line !== undefined) {
26
+ url += `#L${line}`;
27
+ if (lineEnd !== undefined && lineEnd !== line) {
28
+ url += `-L${lineEnd}`;
29
+ }
30
+ }
31
+ return url;
32
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * src/lib/sources.ts — helpers for the sources collection.
3
+ *
4
+ * `sources/manifest.yaml` drives every citation in the book. This
5
+ * module centralizes tier grouping + ordering so the SourceArchive
6
+ * component and any future dashboards share a single source of truth.
7
+ */
8
+ import { getCollection, type CollectionEntry } from 'astro:content';
9
+ import { sourceTiers } from '../content.config';
10
+
11
+ export type SourceEntry = CollectionEntry<'sources'>;
12
+ export type SourceTier = (typeof sourceTiers)[number];
13
+
14
+ /**
15
+ * Grouped source entries keyed by tier, in descending authority
16
+ * (T1-official → T4-conjecture). Empty tiers map to an empty array
17
+ * so callers can render "no sources yet" placeholders without
18
+ * checking for undefined.
19
+ *
20
+ * Within each tier, entries sort by publish_date descending (newest
21
+ * first); entries without a publish_date sort after those with one.
22
+ */
23
+ export async function getSourcesByTier(): Promise<Record<SourceTier, SourceEntry[]>> {
24
+ const all = await getCollection('sources');
25
+ const grouped: Record<SourceTier, SourceEntry[]> = {
26
+ 'T1-official': [],
27
+ 'T2-release-notes': [],
28
+ 'T3-practitioner': [],
29
+ 'T4-conjecture': [],
30
+ };
31
+ for (const entry of all) {
32
+ grouped[entry.data.tier].push(entry);
33
+ }
34
+ for (const tier of sourceTiers) {
35
+ grouped[tier].sort((a, b) => {
36
+ const ad = a.data.publish_date?.getTime() ?? -Infinity;
37
+ const bd = b.data.publish_date?.getTime() ?? -Infinity;
38
+ return bd - ad;
39
+ });
40
+ }
41
+ return grouped;
42
+ }
43
+
44
+ /** Human-readable description for each tier (used as empty-state label). */
45
+ export const TIER_LABELS: Record<SourceTier, string> = {
46
+ 'T1-official': 'T1 · Official',
47
+ 'T2-release-notes': 'T2 · Release notes',
48
+ 'T3-practitioner': 'T3 · Practitioner',
49
+ 'T4-conjecture': 'T4 · Conjecture',
50
+ };
51
+
52
+ /** One-line explanation per tier (shown under the heading). */
53
+ export const TIER_DESCRIPTIONS: Record<SourceTier, string> = {
54
+ 'T1-official':
55
+ 'Vendor-official documentation or release notes. Highest trust for factual claims about the vendor\u2019s own tool.',
56
+ 'T2-release-notes':
57
+ 'Release blog posts, changelogs, conference talks. Trustworthy for intent and availability claims.',
58
+ 'T3-practitioner':
59
+ 'Respected community writing with a durable argument the author has defended over time.',
60
+ 'T4-conjecture':
61
+ 'Blog posts, tweets, or unverified claims. Pointers to investigate, not authorities.',
62
+ };