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 * - 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}