1/**
2 * Build the data model used by the skill / anti-pattern / tutorial page
3 * generators.
4 *
5 * Single source of truth:
6 * - source/skills/{id}/SKILL.md → skill frontmatter + body
7 * - source/skills/{id}/reference/*.md → skill reference files
8 * - src/detect-antipatterns.mjs → ANTIPATTERNS array (parsed)
9 * - content/site/skills/{id}.md → optional editorial wrapper
10 * - content/site/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 } from './utils.js';
17import {
18 DETECTION_LAYERS,
19 VISUAL_EXAMPLES,
20 LLM_ONLY_RULES,
21 GALLERY_ITEMS,
22} from '../../content/site/anti-patterns-catalog.js';
23
24export {
25 LAYER_LABELS,
26 LAYER_DESCRIPTIONS,
27 GALLERY_ITEMS,
28} from '../../content/site/anti-patterns-catalog.js';
29
30/**
31 * Skills that should be excluded from the index and not get a detail page.
32 * These are deprecated shims or internal skills that users shouldn't browse.
33 */
34const EXCLUDED_SKILLS = new Set([
35 'frontend-design', // deprecated, renamed to impeccable
36 'teach-impeccable', // deprecated, folded into /impeccable teach
37 'arrange', // renamed to layout
38 'normalize', // merged into /polish
39 'onboard', // merged into /harden
40 'extract', // merged into /impeccable extract
41]);
42
43/**
44 * Hand-curated category map for user-invocable skills.
45 * Mirrors public/js/data.js commandCategories. Validated below: the
46 * generator fails if any user-invocable skill is missing from this map.
47 */
48const SKILL_CATEGORIES = {
49 // CREATE - build something new
50 impeccable: '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};
73
74export const CATEGORY_ORDER = ['create', 'evaluate', 'refine', 'simplify', 'harden'];
75
76export const CATEGORY_LABELS = {
77 create: 'Create',
78 evaluate: 'Evaluate',
79 refine: 'Refine',
80 simplify: 'Simplify',
81 harden: 'Harden',
82 system: 'System',
83};
84
85export const CATEGORY_DESCRIPTIONS = {
86 create: 'Build something new, from a blank page to a working feature.',
87 evaluate: 'Review what you have. Score it, critique it, find what to fix.',
88 refine: 'Improve one dimension at a time: type, layout, color, motion.',
89 simplify: 'Strip complexity. Remove what does not earn its place.',
90 harden: 'Make it production-ready. Edge cases, performance, polish.',
91 system: 'Setup and tooling. Design system work, extraction, organization.',
92};
93
94/**
95 * Parse the ANTIPATTERNS array out of src/detect-antipatterns.mjs.
96 * Mirrors the trick in scripts/build.js validateAntipatternRules() so we
97 * don't have to run the browser-only module.
98 */
99export function readAntipatternRules(rootDir) {
100 const detectPath = path.join(rootDir, 'src/detect-antipatterns.mjs');
101 const src = fs.readFileSync(detectPath, 'utf-8');
102 const match = src.match(/const ANTIPATTERNS = \[([\s\S]*?)\n\];/);
103 if (!match) {
104 throw new Error(`Could not extract ANTIPATTERNS from ${detectPath}`);
105 }
106 // eslint-disable-next-line no-new-func
107 return new Function(`return [${match[1]}]`)();
108}
109
110/**
111 * Read an optional editorial wrapper file for a skill or tutorial.
112 * Returns { frontmatter, body } or null if the file doesn't exist.
113 */
114export function readEditorialWrapper(contentDir, kind, slug) {
115 const filePath = path.join(contentDir, kind, `${slug}.md`);
116 if (!fs.existsSync(filePath)) return null;
117 const content = fs.readFileSync(filePath, 'utf-8');
118 return parseFrontmatter(content);
119}
120
121/**
122 * Load the per-command before/after demo data from public/js/demos/commands.
123 * Returns a { [skillId]: { id, caption, before, after } } map.
124 * Skills without a demo file are simply missing from the map; the caller
125 * should treat a missing entry as "no demo".
126 */
127export async function loadCommandDemos(rootDir) {
128 const demosDir = path.join(rootDir, 'public/js/demos/commands');
129 if (!fs.existsSync(demosDir)) return {};
130
131 const demos = {};
132 const files = fs
133 .readdirSync(demosDir)
134 .filter((f) => f.endsWith('.js') && f !== 'index.js');
135
136 for (const file of files) {
137 const full = path.join(demosDir, file);
138 try {
139 const mod = await import(pathToFileURL(full).href);
140 const demo = mod.default;
141 if (demo && demo.id) {
142 demos[demo.id] = demo;
143 }
144 } catch (err) {
145 // Demo files occasionally import other demo modules or use features
146 // that don't survive dynamic import. Log and move on rather than
147 // failing the whole generator.
148 console.warn(`[sub-pages] Could not load demo ${file}: ${err.message}`);
149 }
150 }
151 return demos;
152}
153
154/**
155 * Build the full sub-page data model.
156 *
157 * @param {string} rootDir - repo root
158 * @returns {{
159 * skills: Array,
160 * skillsByCategory: Record<string, Array>,
161 * knownSkillIds: Set<string>,
162 * rules: Array,
163 * tutorials: Array,
164 * }}
165 */
166export async function buildSubPageData(rootDir) {
167 const { skills: rawSkills } = readSourceFiles(rootDir);
168 const contentDir = path.join(rootDir, 'content/site');
169 const commandDemos = await loadCommandDemos(rootDir);
170
171 // Filter to user-invocable, non-deprecated skills.
172 const skills = rawSkills
173 .filter((s) => s.userInvocable && !EXCLUDED_SKILLS.has(s.name))
174 .map((s) => {
175 const category = SKILL_CATEGORIES[s.name];
176 const editorial = readEditorialWrapper(contentDir, 'skills', s.name);
177 const demo = commandDemos[s.name] || null;
178 return {
179 id: s.name,
180 name: s.name,
181 description: s.description,
182 argumentHint: s.argumentHint,
183 category,
184 body: s.body,
185 references: s.references,
186 editorial, // may be null
187 demo, // may be null (e.g. /shape has no demo)
188 };
189 })
190 .sort((a, b) => a.name.localeCompare(b.name));
191
192 // Validate the category map covers every user-invocable skill.
193 const missing = skills.filter((s) => !s.category).map((s) => s.id);
194 if (missing.length > 0) {
195 throw new Error(
196 `SKILL_CATEGORIES in scripts/lib/sub-pages-data.js is missing entries for: ${missing.join(', ')}`,
197 );
198 }
199
200 const knownSkillIds = new Set(skills.map((s) => s.id));
201
202 const skillsByCategory = {};
203 for (const cat of CATEGORY_ORDER) skillsByCategory[cat] = [];
204 for (const skill of skills) skillsByCategory[skill.category].push(skill);
205
206 // Anti-pattern rules, enriched with catalog metadata and merged with
207 // LLM-only rules from the skill's DON'T list.
208 const detectedRules = readAntipatternRules(rootDir).map((r) => ({
209 ...r,
210 layer: DETECTION_LAYERS[r.id] || 'cli',
211 visual: VISUAL_EXAMPLES[r.id] || null,
212 }));
213 const llmRules = LLM_ONLY_RULES.map((r) => ({
214 ...r,
215 layer: 'llm',
216 visual: VISUAL_EXAMPLES[r.id] || null,
217 }));
218 const rules = [...detectedRules, ...llmRules];
219
220 // Tutorials: each required file in content/site/tutorials/.
221 const tutorialsDir = path.join(contentDir, 'tutorials');
222 const tutorials = [];
223 if (fs.existsSync(tutorialsDir)) {
224 const files = fs.readdirSync(tutorialsDir).filter((f) => f.endsWith('.md'));
225 for (const file of files) {
226 const slug = path.basename(file, '.md');
227 const raw = fs.readFileSync(path.join(tutorialsDir, file), 'utf-8');
228 const { frontmatter, body } = parseFrontmatter(raw);
229 tutorials.push({
230 slug,
231 title: frontmatter.title || slug,
232 description: frontmatter.description || '',
233 tagline: frontmatter.tagline || '',
234 order: frontmatter.order ? Number(frontmatter.order) : 99,
235 body,
236 });
237 }
238 tutorials.sort((a, b) => a.order - b.order);
239 }
240
241 return {
242 skills,
243 skillsByCategory,
244 knownSkillIds,
245 rules,
246 tutorials,
247 };
248}