1/**
2 * Build the data model used by the skill / anti-pattern / tutorial page
3 * generators.
4 *
5 * Single source of truth:
6 * - skill/SKILL.md → skill frontmatter + body
7 * - skill/reference/*.md → skill reference files
8 * - cli/engine/registry/antipatterns.mjs → ANTIPATTERNS registry
9 * - site/content/skills/{id}.md → optional editorial wrapper
10 * - site/content/tutorials/{slug}.md → full tutorial content
11 */
12
13import fs from 'node:fs';
14import path from 'node:path';
15import { pathToFileURL } from 'node:url';
16import { readSourceFiles, parseFrontmatter, replacePlaceholders } from './utils.js';
17import { ANTIPATTERNS } from '../../cli/engine/registry/antipatterns.mjs';
18import {
19 DETECTION_LAYERS,
20 VISUAL_EXAMPLES,
21 LLM_ONLY_RULES,
22 GALLERY_ITEMS,
23} from '../../site/data/anti-patterns-catalog.js';
24
25export {
26 LAYER_LABELS,
27 LAYER_DESCRIPTIONS,
28 GALLERY_ITEMS,
29} from '../../site/data/anti-patterns-catalog.js';
30
31/**
32 * Skills that should be excluded from the index and not get a detail page.
33 * These are deprecated shims or internal skills that users shouldn't browse.
34 */
35const EXCLUDED_SKILLS = new Set([
36 'frontend-design', // deprecated, renamed to impeccable
37 'teach-impeccable', // deprecated, folded into /impeccable teach
38 'arrange', // renamed to layout
39 'normalize', // merged into /polish
40]);
41
42/**
43 * Hand-curated category map for user-invocable skills.
44 * Mirrors public/js/data.js commandCategories. Validated below: the
45 * generator fails if any user-invocable skill is missing from this map.
46 */
47export const SKILL_CATEGORIES = {
48 // CREATE - build something new
49 impeccable: 'create',
50 craft: 'create',
51 shape: 'create',
52 // EVALUATE - review and assess
53 critique: 'evaluate',
54 audit: 'evaluate',
55 // REFINE - improve existing design
56 typeset: 'refine',
57 layout: 'refine',
58 colorize: 'refine',
59 animate: 'refine',
60 delight: 'refine',
61 bolder: 'refine',
62 quieter: 'refine',
63 overdrive: 'refine',
64 // SIMPLIFY - reduce and clarify
65 distill: 'simplify',
66 clarify: 'simplify',
67 adapt: 'simplify',
68 // HARDEN - production-ready
69 polish: 'harden',
70 optimize: 'harden',
71 harden: 'harden',
72 onboard: 'harden',
73 // SYSTEM - setup and tooling
74 teach: 'system',
75 document: 'system',
76 extract: 'system',
77 live: 'system',
78};
79
80export const CATEGORY_ORDER = ['create', 'evaluate', 'refine', 'simplify', 'harden', 'system'];
81
82export const CATEGORY_LABELS = {
83 create: 'Create',
84 evaluate: 'Evaluate',
85 refine: 'Refine',
86 simplify: 'Simplify',
87 harden: 'Harden',
88 system: 'System',
89};
90
91export const CATEGORY_DESCRIPTIONS = {
92 create: 'Build something new, from a blank page to a working feature.',
93 evaluate: 'Review what you have. Score it, critique it, find what to fix.',
94 refine: 'Improve one dimension at a time: type, layout, color, motion.',
95 simplify: 'Strip complexity. Remove what does not earn its place.',
96 harden: 'Make it production-ready. Edge cases, performance, polish.',
97 system: 'Setup and tooling. Design system work, extraction, organization.',
98};
99
100/**
101 * How commands relate to each other. Mirrors public/js/data.js so the server
102 * can render the docs overview without loading the client bundle.
103 *
104 * - leadsTo: commands that typically follow this one (used for evaluators)
105 * - pairs: the inverse counterpart (bolder <-> quieter)
106 * - combinesWith: commands that work well alongside this one
107 */
108export const COMMAND_RELATIONSHIPS = {
109 // Create
110 craft: { combinesWith: ['shape'] },
111 shape: { combinesWith: ['craft'] },
112 // Evaluate (these are the "diagnostics" that lead to fixes)
113 audit: { leadsTo: ['harden', 'optimize', 'adapt', 'clarify'] },
114 critique: { leadsTo: ['polish', 'distill', 'bolder', 'quieter', 'typeset', 'layout'] },
115 // Refine
116 typeset: { combinesWith: ['bolder', 'polish'] },
117 layout: { combinesWith: ['distill', 'adapt'] },
118 colorize: { combinesWith: ['bolder', 'delight'] },
119 animate: { combinesWith: ['delight'] },
120 delight: { combinesWith: ['bolder', 'animate'] },
121 bolder: { pairs: 'quieter' },
122 quieter: { pairs: 'bolder' },
123 overdrive: { combinesWith: ['animate', 'delight'] },
124 // Simplify
125 distill: { combinesWith: ['quieter', 'polish'] },
126 clarify: { combinesWith: ['polish', 'adapt'] },
127 adapt: { combinesWith: ['polish', 'clarify'] },
128 // Harden
129 polish: {},
130 optimize: {},
131 harden: { combinesWith: ['optimize'] },
132 onboard: { combinesWith: ['clarify', 'delight'] },
133 // System
134 teach: { combinesWith: ['document'] },
135 document: { combinesWith: ['teach', 'extract'] },
136 extract: { combinesWith: ['document'] },
137 live: {},
138};
139
140/**
141 * Read the detector rule registry.
142 */
143export function readAntipatternRules(rootDir) {
144 void rootDir;
145 return ANTIPATTERNS.slice();
146}
147
148/**
149 * Read an optional editorial wrapper file for a skill or tutorial.
150 * Returns { frontmatter, body } or null if the file doesn't exist.
151 */
152export function readEditorialWrapper(contentDir, kind, slug) {
153 const filePath = path.join(contentDir, kind, `${slug}.md`);
154 if (!fs.existsSync(filePath)) return null;
155 const content = fs.readFileSync(filePath, 'utf-8');
156 return parseFrontmatter(content);
157}
158
159/**
160 * Load the per-command before/after demo data from public/js/demos/commands.
161 * Returns a { [skillId]: { id, caption, before, after } } map.
162 * Skills without a demo file are simply missing from the map; the caller
163 * should treat a missing entry as "no demo".
164 */
165export async function loadCommandDemos(rootDir) {
166 const demosDir = path.join(rootDir, 'site/public/js/demos/commands');
167 if (!fs.existsSync(demosDir)) return {};
168
169 const demos = {};
170 const files = fs
171 .readdirSync(demosDir)
172 .filter((f) => f.endsWith('.js') && f !== 'index.js');
173
174 for (const file of files) {
175 const full = path.join(demosDir, file);
176 try {
177 const mod = await import(pathToFileURL(full).href);
178 const demo = mod.default;
179 if (demo && demo.id) {
180 demos[demo.id] = demo;
181 }
182 } catch (err) {
183 // Demo files occasionally import other demo modules or use features
184 // that don't survive dynamic import. Log and move on rather than
185 // failing the whole generator.
186 console.warn(`[sub-pages] Could not load demo ${file}: ${err.message}`);
187 }
188 }
189 return demos;
190}
191
192/**
193 * Build the full sub-page data model.
194 *
195 * @param {string} rootDir - repo root
196 * @returns {{
197 * skills: Array,
198 * skillsByCategory: Record<string, Array>,
199 * knownSkillIds: Set<string>,
200 * rules: Array,
201 * tutorials: Array,
202 * }}
203 */
204export async function buildSubPageData(rootDir) {
205 const { skills: rawSkills } = readSourceFiles(rootDir);
206 const contentDir = path.join(rootDir, 'site/content');
207 const commandDemos = await loadCommandDemos(rootDir);
208
209 // After the v3.0 consolidation there's only one source skill (impeccable).
210 // Its reference/ directory holds one file per command (audit.md, polish.md, ...).
211 // We synthesize a virtual skill entry for each sub-command so the sub-page
212 // generators can keep rendering per-command pages, index cards, etc.
213 const impeccableSkill = rawSkills.find((s) => s.name === 'impeccable');
214 const metadataPath = path.join(rootDir, 'skill/scripts/command-metadata.json');
215 let commandMetadata = {};
216 if (fs.existsSync(metadataPath)) {
217 commandMetadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
218 }
219
220 // Reference files and skill bodies use {{command_prefix}} placeholders that
221 // are normally replaced by the provider transformer at build time. For web
222 // rendering, resolve them here using the claude-code provider as the canonical
223 // form ("/" prefix). The list of all command names includes the root skill
224 // plus all sub-commands from metadata so cross-references render correctly.
225 const allCommandNames = ['impeccable', ...Object.keys(commandMetadata)];
226 const resolvePlaceholders = (content) =>
227 replacePlaceholders(content, 'claude-code', [], allCommandNames);
228
229 const skills = [];
230
231 // 1. The root impeccable skill itself.
232 if (impeccableSkill && !EXCLUDED_SKILLS.has(impeccableSkill.name)) {
233 const editorial = readEditorialWrapper(contentDir, 'skills', 'impeccable');
234 const demo = commandDemos['impeccable'] || null;
235 skills.push({
236 id: 'impeccable',
237 name: 'impeccable',
238 description: impeccableSkill.description,
239 argumentHint: impeccableSkill.argumentHint,
240 category: SKILL_CATEGORIES['impeccable'],
241 body: resolvePlaceholders(impeccableSkill.body),
242 references: (impeccableSkill.references || []).map((r) => ({
243 ...r,
244 content: resolvePlaceholders(r.content),
245 })),
246 editorial,
247 demo,
248 isSubCommand: false,
249 });
250 }
251
252 // 2. One virtual entry per sub-command, body sourced from its reference file.
253 if (impeccableSkill) {
254 for (const [cmdId, meta] of Object.entries(commandMetadata)) {
255 if (EXCLUDED_SKILLS.has(cmdId)) continue;
256 const refFile = impeccableSkill.references?.find((r) => r.name === cmdId);
257 if (!refFile) continue; // no reference file = no page
258
259 const editorial = readEditorialWrapper(contentDir, 'skills', cmdId);
260 const demo = commandDemos[cmdId] || null;
261 skills.push({
262 id: cmdId,
263 name: cmdId,
264 description: meta.description,
265 argumentHint: meta.argumentHint,
266 category: SKILL_CATEGORIES[cmdId],
267 body: resolvePlaceholders(refFile.content),
268 references: [], // sub-commands don't have their own references
269 editorial,
270 demo,
271 isSubCommand: true,
272 });
273 }
274 }
275
276 skills.sort((a, b) => a.name.localeCompare(b.name));
277
278 // Validate the category map covers every skill entry.
279 const missing = skills.filter((s) => !s.category).map((s) => s.id);
280 if (missing.length > 0) {
281 throw new Error(
282 `SKILL_CATEGORIES in scripts/lib/sub-pages-data.js is missing entries for: ${missing.join(', ')}`,
283 );
284 }
285
286 const knownSkillIds = new Set(skills.map((s) => s.id));
287
288 const skillsByCategory = {};
289 for (const cat of CATEGORY_ORDER) skillsByCategory[cat] = [];
290 for (const skill of skills) skillsByCategory[skill.category].push(skill);
291
292 // Anti-pattern rules, enriched with catalog metadata and merged with
293 // LLM-only rules from the skill's DON'T list.
294 const detectedRules = readAntipatternRules(rootDir).map((r) => ({
295 ...r,
296 layer: DETECTION_LAYERS[r.id] || 'cli',
297 visual: VISUAL_EXAMPLES[r.id] || null,
298 }));
299 const llmRules = LLM_ONLY_RULES.map((r) => ({
300 ...r,
301 layer: 'llm',
302 visual: VISUAL_EXAMPLES[r.id] || null,
303 }));
304 const rules = [...detectedRules, ...llmRules];
305
306 // Tutorials: each required file in site/content/tutorials/.
307 const tutorialsDir = path.join(contentDir, 'tutorials');
308 const tutorials = [];
309 if (fs.existsSync(tutorialsDir)) {
310 const files = fs.readdirSync(tutorialsDir).filter((f) => f.endsWith('.md'));
311 for (const file of files) {
312 const slug = path.basename(file, '.md');
313 const raw = fs.readFileSync(path.join(tutorialsDir, file), 'utf-8');
314 const { frontmatter, body } = parseFrontmatter(raw);
315 tutorials.push({
316 slug,
317 title: frontmatter.title || slug,
318 description: frontmatter.description || '',
319 tagline: frontmatter.tagline || '',
320 order: frontmatter.order ? Number(frontmatter.order) : 99,
321 body,
322 });
323 }
324 tutorials.sort((a, b) => a.order - b.order);
325 }
326
327 return {
328 skills,
329 skillsByCategory,
330 knownSkillIds,
331 rules,
332 tutorials,
333 };
334}