mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
Three additions to the anti-patterns catalog page, all sourced from a
new content/site/anti-patterns-catalog.js file so the user's parallel
edits to src/detect-antipatterns.mjs don't conflict with display metadata.
1. Detection layer badge per rule. Three layers:
cli - static analysis or jsdom. Runs from `npx impeccable detect`
on files, no browser required. 23 of 25 current rules.
browser - needs real browser layout (getBoundingClientRect).
Runs via the browser extension or Puppeteer, not the
plain CLI. Only 2 rules: cramped-padding and line-length,
as documented in tests/detect-antipatterns-browser.test.mjs.
llm - no deterministic detector. Flagged by /critique's LLM
review pass. 13 rules live only in the skill's DON'T list.
Each card renders a mono pill with the layer label, color-coded per
layer (neutral mist for CLI, blue tint for browser, amber tint for LLM).
The How-to-read legend grows a dl explaining what each layer means.
2. Inline visual example per detected rule. All 25 detection rules get
a ~140px tall preview area at the top of the card showing the bad
pattern as live HTML (cream background, self-contained inline styles).
Visuals for side-tab, gradient-text, dark-glow, nested-cards, and the
rest let you see what the detector is actually flagging. LLM-only
rules ship without visuals for now; their card bodies take the full
card height.
3. LLM-only rules merged into the sections. Parsed out from
source/skills/impeccable/SKILL.md DON'T lines that the detector
doesn't cover: Syne, monospace-as-technical, dark-mode-default,
everything-in-cards, identical-card-grids, hero-metric-layout,
glassmorphism, sparkline-decoration, generic-drop-shadows,
modal-reflex, every-button-primary, redundant-headers,
mobile-amputation. Each renders like a detection rule card but
shows the 'LLM only' layer badge and has no rule id chip. They
slot into the same section groups as detected rules (Interaction
and Responsive sections added to the section order so these get
real headings).
- scripts/lib/sub-pages-data.js: imports the catalog, enriches
detected rules with { layer, visual }, appends LLM_ONLY_RULES with
layer: 'llm'. Re-exports LAYER_LABELS and LAYER_DESCRIPTIONS for
the generator.
- scripts/build-sub-pages.js: renderRuleCard adds the visual block
and the layer badge; LLM rules drop the rule id chip since their id
is just an internal slug. groupRulesBySection now extends the
primary order with whatever extra sections rules reference.
- public/css/sub-pages.css: .rule-card now has a .rule-card-visual
preview area on top with border-bottom, body section below. New
.rule-card-layer pill styling per layer. Layer legend dl using a
2-column grid for badge -> description.
Dev server serves 38 total cards (25 detected + 13 LLM) across 8
sections: Visual Details, Typography, Color & Contrast, Layout & Space,
Motion, Interaction, Responsive, General quality.
244 lines
7.9 KiB
JavaScript
244 lines
7.9 KiB
JavaScript
/**
|
|
* Build the data model used by the skill / anti-pattern / tutorial page
|
|
* generators.
|
|
*
|
|
* Single source of truth:
|
|
* - source/skills/{id}/SKILL.md → skill frontmatter + body
|
|
* - source/skills/{id}/reference/*.md → skill reference files
|
|
* - src/detect-antipatterns.mjs → ANTIPATTERNS array (parsed)
|
|
* - content/site/skills/{id}.md → optional editorial wrapper
|
|
* - content/site/tutorials/{slug}.md → full tutorial content
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { pathToFileURL } from 'node:url';
|
|
import { readSourceFiles, parseFrontmatter } from './utils.js';
|
|
import {
|
|
DETECTION_LAYERS,
|
|
VISUAL_EXAMPLES,
|
|
LLM_ONLY_RULES,
|
|
} from '../../content/site/anti-patterns-catalog.js';
|
|
|
|
export { LAYER_LABELS, LAYER_DESCRIPTIONS } from '../../content/site/anti-patterns-catalog.js';
|
|
|
|
/**
|
|
* Skills that should be excluded from the index and not get a detail page.
|
|
* These are deprecated shims or internal skills that users shouldn't browse.
|
|
*/
|
|
const EXCLUDED_SKILLS = new Set([
|
|
'frontend-design', // deprecated, renamed to impeccable
|
|
'teach-impeccable', // deprecated, folded into /impeccable teach
|
|
]);
|
|
|
|
/**
|
|
* Hand-curated category map for user-invocable skills.
|
|
* Mirrors public/js/data.js commandCategories. Validated below: the
|
|
* generator fails if any user-invocable skill is missing from this map.
|
|
*/
|
|
const SKILL_CATEGORIES = {
|
|
// CREATE - build something new
|
|
impeccable: 'create',
|
|
shape: 'create',
|
|
// EVALUATE - review and assess
|
|
critique: 'evaluate',
|
|
audit: 'evaluate',
|
|
// REFINE - improve existing design
|
|
typeset: 'refine',
|
|
arrange: 'refine',
|
|
colorize: 'refine',
|
|
animate: 'refine',
|
|
delight: 'refine',
|
|
bolder: 'refine',
|
|
quieter: 'refine',
|
|
onboard: 'refine',
|
|
overdrive: 'refine',
|
|
// SIMPLIFY - reduce and clarify
|
|
distill: 'simplify',
|
|
clarify: 'simplify',
|
|
adapt: 'simplify',
|
|
// HARDEN - production-ready
|
|
normalize: 'harden',
|
|
polish: 'harden',
|
|
optimize: 'harden',
|
|
harden: 'harden',
|
|
// SYSTEM - setup and tooling
|
|
extract: 'system',
|
|
};
|
|
|
|
export const CATEGORY_ORDER = ['create', 'evaluate', 'refine', 'simplify', 'harden', 'system'];
|
|
|
|
export const CATEGORY_LABELS = {
|
|
create: 'Create',
|
|
evaluate: 'Evaluate',
|
|
refine: 'Refine',
|
|
simplify: 'Simplify',
|
|
harden: 'Harden',
|
|
system: 'System',
|
|
};
|
|
|
|
export const CATEGORY_DESCRIPTIONS = {
|
|
create: 'Build something new, from a blank page to a working feature.',
|
|
evaluate: 'Review what you have. Score it, critique it, find what to fix.',
|
|
refine: 'Improve one dimension at a time: type, layout, color, motion.',
|
|
simplify: 'Strip complexity. Remove what does not earn its place.',
|
|
harden: 'Make it production-ready. Edge cases, performance, polish.',
|
|
system: 'Setup and tooling. Design system work, extraction, organization.',
|
|
};
|
|
|
|
/**
|
|
* Parse the ANTIPATTERNS array out of src/detect-antipatterns.mjs.
|
|
* Mirrors the trick in scripts/build.js validateAntipatternRules() so we
|
|
* don't have to run the browser-only module.
|
|
*/
|
|
export function readAntipatternRules(rootDir) {
|
|
const detectPath = path.join(rootDir, 'src/detect-antipatterns.mjs');
|
|
const src = fs.readFileSync(detectPath, 'utf-8');
|
|
const match = src.match(/const ANTIPATTERNS = \[([\s\S]*?)\n\];/);
|
|
if (!match) {
|
|
throw new Error(`Could not extract ANTIPATTERNS from ${detectPath}`);
|
|
}
|
|
// eslint-disable-next-line no-new-func
|
|
return new Function(`return [${match[1]}]`)();
|
|
}
|
|
|
|
/**
|
|
* Read an optional editorial wrapper file for a skill or tutorial.
|
|
* Returns { frontmatter, body } or null if the file doesn't exist.
|
|
*/
|
|
export function readEditorialWrapper(contentDir, kind, slug) {
|
|
const filePath = path.join(contentDir, kind, `${slug}.md`);
|
|
if (!fs.existsSync(filePath)) return null;
|
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
return parseFrontmatter(content);
|
|
}
|
|
|
|
/**
|
|
* Load the per-command before/after demo data from public/js/demos/commands.
|
|
* Returns a { [skillId]: { id, caption, before, after } } map.
|
|
* Skills without a demo file are simply missing from the map; the caller
|
|
* should treat a missing entry as "no demo".
|
|
*/
|
|
export async function loadCommandDemos(rootDir) {
|
|
const demosDir = path.join(rootDir, 'public/js/demos/commands');
|
|
if (!fs.existsSync(demosDir)) return {};
|
|
|
|
const demos = {};
|
|
const files = fs
|
|
.readdirSync(demosDir)
|
|
.filter((f) => f.endsWith('.js') && f !== 'index.js');
|
|
|
|
for (const file of files) {
|
|
const full = path.join(demosDir, file);
|
|
try {
|
|
const mod = await import(pathToFileURL(full).href);
|
|
const demo = mod.default;
|
|
if (demo && demo.id) {
|
|
demos[demo.id] = demo;
|
|
}
|
|
} catch (err) {
|
|
// Demo files occasionally import other demo modules or use features
|
|
// that don't survive dynamic import. Log and move on rather than
|
|
// failing the whole generator.
|
|
console.warn(`[sub-pages] Could not load demo ${file}: ${err.message}`);
|
|
}
|
|
}
|
|
return demos;
|
|
}
|
|
|
|
/**
|
|
* Build the full sub-page data model.
|
|
*
|
|
* @param {string} rootDir - repo root
|
|
* @returns {{
|
|
* skills: Array,
|
|
* skillsByCategory: Record<string, Array>,
|
|
* knownSkillIds: Set<string>,
|
|
* rules: Array,
|
|
* tutorials: Array,
|
|
* }}
|
|
*/
|
|
export async function buildSubPageData(rootDir) {
|
|
const { skills: rawSkills } = readSourceFiles(rootDir);
|
|
const contentDir = path.join(rootDir, 'content/site');
|
|
const commandDemos = await loadCommandDemos(rootDir);
|
|
|
|
// Filter to user-invocable, non-deprecated skills.
|
|
const skills = rawSkills
|
|
.filter((s) => s.userInvocable && !EXCLUDED_SKILLS.has(s.name))
|
|
.map((s) => {
|
|
const category = SKILL_CATEGORIES[s.name];
|
|
const editorial = readEditorialWrapper(contentDir, 'skills', s.name);
|
|
const demo = commandDemos[s.name] || null;
|
|
return {
|
|
id: s.name,
|
|
name: s.name,
|
|
description: s.description,
|
|
argumentHint: s.argumentHint,
|
|
category,
|
|
body: s.body,
|
|
references: s.references,
|
|
editorial, // may be null
|
|
demo, // may be null (e.g. /shape has no demo)
|
|
};
|
|
})
|
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
// Validate the category map covers every user-invocable skill.
|
|
const missing = skills.filter((s) => !s.category).map((s) => s.id);
|
|
if (missing.length > 0) {
|
|
throw new Error(
|
|
`SKILL_CATEGORIES in scripts/lib/sub-pages-data.js is missing entries for: ${missing.join(', ')}`,
|
|
);
|
|
}
|
|
|
|
const knownSkillIds = new Set(skills.map((s) => s.id));
|
|
|
|
const skillsByCategory = {};
|
|
for (const cat of CATEGORY_ORDER) skillsByCategory[cat] = [];
|
|
for (const skill of skills) skillsByCategory[skill.category].push(skill);
|
|
|
|
// Anti-pattern rules, enriched with catalog metadata and merged with
|
|
// LLM-only rules from the skill's DON'T list.
|
|
const detectedRules = readAntipatternRules(rootDir).map((r) => ({
|
|
...r,
|
|
layer: DETECTION_LAYERS[r.id] || 'cli',
|
|
visual: VISUAL_EXAMPLES[r.id] || null,
|
|
}));
|
|
const llmRules = LLM_ONLY_RULES.map((r) => ({
|
|
...r,
|
|
layer: 'llm',
|
|
visual: VISUAL_EXAMPLES[r.id] || null,
|
|
}));
|
|
const rules = [...detectedRules, ...llmRules];
|
|
|
|
// Tutorials: each required file in content/site/tutorials/.
|
|
const tutorialsDir = path.join(contentDir, 'tutorials');
|
|
const tutorials = [];
|
|
if (fs.existsSync(tutorialsDir)) {
|
|
const files = fs.readdirSync(tutorialsDir).filter((f) => f.endsWith('.md'));
|
|
for (const file of files) {
|
|
const slug = path.basename(file, '.md');
|
|
const raw = fs.readFileSync(path.join(tutorialsDir, file), 'utf-8');
|
|
const { frontmatter, body } = parseFrontmatter(raw);
|
|
tutorials.push({
|
|
slug,
|
|
title: frontmatter.title || slug,
|
|
description: frontmatter.description || '',
|
|
tagline: frontmatter.tagline || '',
|
|
order: frontmatter.order ? Number(frontmatter.order) : 99,
|
|
body,
|
|
});
|
|
}
|
|
tutorials.sort((a, b) => a.order - b.order);
|
|
}
|
|
|
|
return {
|
|
skills,
|
|
skillsByCategory,
|
|
knownSkillIds,
|
|
rules,
|
|
tutorials,
|
|
};
|
|
}
|