sub-pages-data.js

  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}