build-sub-pages.js

  1/**
  2 * Generate static HTML files for /skills, /anti-patterns, /tutorials.
  3 *
  4 * Called from both scripts/build.js (before buildStaticSite) and
  5 * server/index.js (at module load), so dev and prod share the same
  6 * code path and output shape.
  7 *
  8 * Output lives under public/skills/, public/anti-patterns/,
  9 * public/tutorials/, all gitignored. Bun's HTML loader picks them up
 10 * the same way it picks up the hand-authored pages.
 11 */
 12
 13import fs from 'node:fs';
 14import path from 'node:path';
 15import {
 16  buildSubPageData,
 17  CATEGORY_ORDER,
 18  CATEGORY_LABELS,
 19  CATEGORY_DESCRIPTIONS,
 20  LAYER_LABELS,
 21  LAYER_DESCRIPTIONS,
 22  GALLERY_ITEMS,
 23} from './lib/sub-pages-data.js';
 24import { renderMarkdown, slugify } from './lib/render-markdown.js';
 25import { renderPage } from './lib/render-page.js';
 26
 27function escapeHtml(str) {
 28  return String(str || '')
 29    .replace(/&/g, '&')
 30    .replace(/</g, '&lt;')
 31    .replace(/>/g, '&gt;')
 32    .replace(/"/g, '&quot;')
 33    .replace(/'/g, '&#39;');
 34}
 35
 36/**
 37 * Render the before/after split-compare demo block for a skill.
 38 * Returns '' when the skill has no demo data (e.g. /shape).
 39 */
 40function renderSkillDemo(skill) {
 41  if (!skill.demo) return '';
 42  const { before, after, caption } = skill.demo;
 43  return `
 44<section class="skill-demo" aria-label="Before and after demo">
 45  <div class="split-comparison" data-demo="skill-${skill.id}">
 46    <p class="skill-demo-eyebrow">Drag or hover to compare</p>
 47    <div class="split-container">
 48      <div class="split-before">
 49        <div class="split-content">${before}</div>
 50      </div>
 51      <div class="split-after">
 52        <div class="split-content">${after || before}</div>
 53      </div>
 54      <div class="split-divider"></div>
 55    </div>
 56    <div class="split-labels">
 57      <span class="split-label-item" data-point="before">Before</span>
 58      ${caption ? `<p class="skill-demo-caption">${escapeHtml(caption)}</p>` : '<span></span>'}
 59      <span class="split-label-item" data-point="after">After</span>
 60    </div>
 61  </div>
 62</section>`;
 63}
 64
 65/**
 66 * Render one skill detail page HTML body (without the site shell).
 67 */
 68function renderSkillDetail(skill, knownSkillIds) {
 69  const bodyHtml = renderMarkdown(skill.body, {
 70    knownSkillIds,
 71    currentSkillId: skill.id,
 72  });
 73
 74  const editorialHtml = skill.editorial
 75    ? renderMarkdown(skill.editorial.body, { knownSkillIds, currentSkillId: skill.id })
 76    : '';
 77
 78  const demoHtml = renderSkillDemo(skill);
 79
 80  const tagline = skill.editorial?.frontmatter?.tagline || skill.description;
 81  const categoryLabel = CATEGORY_LABELS[skill.category] || skill.category;
 82
 83  // Reference files as collapsible <details> blocks
 84  let referencesHtml = '';
 85  if (skill.references && skill.references.length > 0) {
 86    const refs = skill.references
 87      .map((ref) => {
 88        const slug = slugify(ref.name);
 89        const refBody = renderMarkdown(ref.content, {
 90          knownSkillIds,
 91          currentSkillId: skill.id,
 92        });
 93        const title = ref.name
 94          .split('-')
 95          .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
 96          .join(' ');
 97        return `
 98<details class="skill-reference" id="reference-${slug}">
 99  <summary><span class="skill-reference-label">Reference</span><span class="skill-reference-title">${escapeHtml(title)}</span></summary>
100  <div class="prose skill-reference-body">
101${refBody}
102  </div>
103</details>`;
104      })
105      .join('\n');
106    referencesHtml = `
107<section class="skill-references" aria-label="Reference material">
108  <h2 class="skill-references-heading">Deeper reference</h2>
109  ${refs}
110</section>`;
111  }
112
113  const metaStrip = `
114<div class="skill-meta-strip">
115  <span class="skill-meta-chip skill-meta-category" data-category="${skill.category}">${escapeHtml(categoryLabel)}</span>
116  <span class="skill-meta-chip">User-invocable</span>
117  ${skill.argumentHint ? `<span class="skill-meta-chip skill-meta-args">${escapeHtml(skill.argumentHint)}</span>` : ''}
118</div>`;
119
120  const hasDemo = demoHtml.trim().length > 0;
121
122  return `
123<article class="skill-detail">
124  <div class="skill-detail-hero${hasDemo ? ' skill-detail-hero--has-demo' : ''}">
125    <header class="skill-detail-header">
126      <p class="skill-detail-eyebrow"><a href="/skills">Skills</a> / ${escapeHtml(categoryLabel)}</p>
127      <h1 class="skill-detail-title"><span class="skill-detail-title-slash">/</span>${escapeHtml(skill.id)}</h1>
128      <p class="skill-detail-tagline">${escapeHtml(tagline)}</p>
129      ${metaStrip}
130    </header>
131    ${demoHtml}
132  </div>
133
134  ${editorialHtml ? `<section class="skill-detail-editorial prose">\n${editorialHtml}\n</section>` : ''}
135
136  <section class="skill-source-card">
137    <header class="skill-source-card-header">
138      <span class="skill-source-card-label">SKILL.md</span>
139      <span class="skill-source-card-subtitle">The canonical skill definition your AI harness loads.</span>
140    </header>
141    <div class="skill-source-card-body prose">
142${bodyHtml}
143    </div>
144  </section>
145
146  ${referencesHtml}
147</article>
148`;
149}
150
151/**
152 * Render the unified Docs sidebar used across /skills and /tutorials.
153 * Shows every skill grouped by category, then tutorials as a final
154 * group. Pass the current page identifier so we can mark it:
155 *
156 *   { kind: 'skill', id: 'polish' }
157 *   { kind: 'tutorial', slug: 'getting-started' }
158 *   null (no current page)
159 */
160function renderDocsSidebar(skillsByCategory, tutorials, current = null) {
161  // Label the toggle button with the current page so mobile users know
162  // where they are at a glance, then open the menu to switch.
163  let currentLabel = 'Docs menu';
164  if (current?.kind === 'skill') {
165    currentLabel = `/${current.id}`;
166  } else if (current?.kind === 'tutorial') {
167    const t = tutorials.find((x) => x.slug === current.slug);
168    if (t) currentLabel = t.title;
169  }
170
171  let html = `
172<aside class="skills-sidebar" aria-label="Documentation">
173  <button class="skills-sidebar-toggle" type="button" aria-expanded="false" aria-controls="skills-sidebar-inner">
174    <span class="skills-sidebar-toggle-label">${escapeHtml(currentLabel)}</span>
175    <svg class="skills-sidebar-toggle-chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg>
176  </button>
177  <div class="skills-sidebar-inner" id="skills-sidebar-inner">
178    <p class="skills-sidebar-label">Docs</p>
179`;
180
181  // Tutorials first: walk-throughs are the on-ramp, they go at the top.
182  if (tutorials && tutorials.length > 0) {
183    html += `
184    <div class="skills-sidebar-group" data-category="tutorials">
185      <p class="skills-sidebar-group-title">Tutorials</p>
186      <ul class="skills-sidebar-list">
187${tutorials
188  .map((t) => {
189    const isCurrent = current?.kind === 'tutorial' && current.slug === t.slug;
190    const attr = isCurrent ? ' aria-current="page"' : '';
191    return `        <li><a href="/tutorials/${t.slug}"${attr}>${escapeHtml(t.title)}</a></li>`;
192  })
193  .join('\n')}
194      </ul>
195    </div>
196    <hr class="skills-sidebar-divider">
197`;
198  }
199
200  // Sub-command links that appear as indented entries after their parent skill.
201  const SUB_COMMANDS = {
202    impeccable: [
203      { id: 'impeccable-craft', label: '/impeccable craft', href: '/skills/impeccable#craft' },
204      { id: 'impeccable-teach', label: '/impeccable teach', href: '/skills/impeccable#teach' },
205      { id: 'impeccable-extract', label: '/impeccable extract', href: '/skills/impeccable#extract' },
206    ],
207  };
208
209  // Then the skills, grouped by category.
210  for (const category of CATEGORY_ORDER) {
211    const list = skillsByCategory[category] || [];
212    if (list.length === 0) continue;
213    html += `
214    <div class="skills-sidebar-group" data-category="${category}">
215      <p class="skills-sidebar-group-title">${escapeHtml(CATEGORY_LABELS[category])}</p>
216      <ul class="skills-sidebar-list">
217${list
218  .flatMap((s) => {
219    const isCurrent = current?.kind === 'skill' && current.id === s.id;
220    const attr = isCurrent ? ' aria-current="page"' : '';
221    const items = [`        <li><a href="/skills/${s.id}"${attr}>/${escapeHtml(s.id)}</a></li>`];
222    const subs = SUB_COMMANDS[s.id];
223    if (subs) {
224      for (const sub of subs) {
225        items.push(`        <li class="skills-sidebar-sub"><a href="${sub.href}">${escapeHtml(sub.label)}</a></li>`);
226      }
227    }
228    return items;
229  })
230  .join('\n')}
231      </ul>
232    </div>
233`;
234  }
235
236  html += `
237  </div>
238</aside>`;
239  return html;
240}
241
242/**
243 * Render the /skills overview main column content (not the sidebar).
244 * This is the orientation piece: what skills are, how to pick one,
245 * the six categories explained with inline cross-links to detail pages.
246 */
247function renderSkillsOverviewMain(skillsByCategory) {
248  const totalSkills = Object.values(skillsByCategory).reduce(
249    (sum, list) => sum + list.length,
250    0,
251  );
252
253  let categoriesHtml = '';
254  for (const category of CATEGORY_ORDER) {
255    const list = skillsByCategory[category] || [];
256    if (list.length === 0) continue;
257
258    const skillChips = list
259      .map(
260        (s) =>
261          `<a class="skills-overview-chip" href="/skills/${s.id}">/${escapeHtml(s.id)}</a>`,
262      )
263      .join('');
264
265    categoriesHtml += `
266    <section class="skills-overview-category" data-category="${category}" id="category-${category}">
267      <div class="skills-overview-category-meta">
268        <h2 class="skills-overview-category-title">${escapeHtml(CATEGORY_LABELS[category])}</h2>
269        <p class="skills-overview-category-count">${list.length} ${list.length === 1 ? 'skill' : 'skills'}</p>
270      </div>
271      <p class="skills-overview-category-desc">${escapeHtml(CATEGORY_DESCRIPTIONS[category])}</p>
272      <div class="skills-overview-chips">
273${skillChips}
274      </div>
275    </section>
276`;
277  }
278
279  return `
280<div class="skills-overview-content">
281  <header class="skills-overview-header">
282    <p class="sub-page-eyebrow">${totalSkills} commands</p>
283    <h1 class="sub-page-title">Skills</h1>
284    <p class="sub-page-lede">One skill, <a href="/skills/impeccable">/impeccable</a>, teaches your AI design. Eighteen commands steer the result. Each command does one job with an opinion about what good looks like.</p>
285  </header>
286
287  <section class="skills-overview-howto">
288    <h2 class="skills-overview-howto-title">How to pick one</h2>
289    <p>Skills are named after the intent you bring to them. Reviewing something? <a href="/skills/critique">/critique</a> or <a href="/skills/audit">/audit</a>. Fixing type? <a href="/skills/typeset">/typeset</a>. Last-mile pass before shipping? <a href="/skills/polish">/polish</a>. The categories below group skills by the job.</p>
290  </section>
291
292  <div class="skills-overview-categories">
293${categoriesHtml}
294  </div>
295</div>`;
296}
297
298/**
299 * Wrap sidebar + main content in the docs-browser layout shell.
300 */
301function wrapInDocsLayout(sidebarHtml, mainHtml) {
302  return `
303<div class="skills-layout">
304  ${sidebarHtml}
305  <div class="skills-main">
306${mainHtml}
307  </div>
308</div>`;
309}
310
311/**
312 * Group anti-pattern rules by skill section.
313 * Rules without a skillSection fall into a 'General quality' bucket.
314 */
315function groupRulesBySection(rules) {
316  // Canonical ordering. Additional sections referenced by rules (e.g.
317  // 'Interaction', 'Responsive' from LLM-only entries) are appended to
318  // the end, before 'General quality', so every rule renders.
319  const primaryOrder = [
320    'Visual Details',
321    'Typography',
322    'Color & Contrast',
323    'Layout & Space',
324    'Motion',
325    'Interaction',
326    'Responsive',
327  ];
328  const bySection = {};
329  for (const name of primaryOrder) bySection[name] = [];
330  bySection['General quality'] = [];
331
332  for (const rule of rules) {
333    const section = rule.skillSection || 'General quality';
334    if (!bySection[section]) bySection[section] = [];
335    bySection[section].push(rule);
336  }
337
338  // Sort each bucket: slop first (they're the named tells), then quality.
339  for (const name of Object.keys(bySection)) {
340    bySection[name].sort((a, b) => {
341      if (a.category !== b.category) return a.category === 'slop' ? -1 : 1;
342      return a.name.localeCompare(b.name);
343    });
344  }
345
346  // Final render order: primary sections first, then any extras that
347  // rules introduced, then General quality last.
348  const order = [...primaryOrder];
349  for (const name of Object.keys(bySection)) {
350    if (!order.includes(name) && name !== 'General quality') {
351      order.push(name);
352    }
353  }
354  order.push('General quality');
355
356  return { order, bySection };
357}
358
359/**
360 * Render the anti-patterns sidebar: a table of contents of rule sections
361 * with per-section rule counts. Every entry anchor-jumps to the section
362 * in the main column.
363 */
364function renderAntiPatternsSidebar(grouped) {
365  const entries = grouped.order
366    .filter((section) => grouped.bySection[section]?.length > 0)
367    .map((section) => {
368      const slug = slugify(section);
369      const count = grouped.bySection[section].length;
370      return `        <li><a href="#section-${slug}"><span>${escapeHtml(section)}</span><span class="anti-patterns-sidebar-count">${count}</span></a></li>`;
371    })
372    .join('\n');
373
374  return `
375<aside class="skills-sidebar anti-patterns-sidebar" aria-label="Anti-pattern sections">
376  <button class="skills-sidebar-toggle" type="button" aria-expanded="false" aria-controls="anti-patterns-sidebar-inner">
377    <span class="skills-sidebar-toggle-label">Sections</span>
378    <svg class="skills-sidebar-toggle-chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg>
379  </button>
380  <div class="skills-sidebar-inner" id="anti-patterns-sidebar-inner">
381    <p class="skills-sidebar-label">Sections</p>
382    <div class="skills-sidebar-group">
383      <ul class="skills-sidebar-list anti-patterns-sidebar-list">
384${entries}
385      </ul>
386    </div>
387  </div>
388</aside>`;
389}
390
391/**
392 * Render one rule card inside the anti-patterns main column.
393 */
394function renderRuleCard(rule) {
395  const categoryLabel = rule.category === 'slop' ? 'AI slop' : 'Quality';
396  const layer = rule.layer || 'cli';
397  const layerLabel = LAYER_LABELS[layer] || layer;
398  const layerTitle = LAYER_DESCRIPTIONS[layer] || '';
399  const skillLink = rule.skillSection
400    ? `<a class="rule-card-skill-link" href="/skills/impeccable#${slugify(rule.skillSection)}">See in /impeccable</a>`
401    : '';
402  const visual = rule.visual
403    ? `<div class="rule-card-visual" aria-hidden="true"><div class="rule-card-visual-inner">${rule.visual}</div></div>`
404    : '';
405  return `
406    <article class="rule-card" id="rule-${rule.id}" data-layer="${layer}">
407      ${visual}
408      <div class="rule-card-body">
409        <div class="rule-card-head">
410          <span class="rule-card-category" data-category="${rule.category}">${categoryLabel}</span>
411          <span class="rule-card-layer" data-layer="${layer}" title="${escapeAttr(layerTitle)}">${escapeHtml(layerLabel)}</span>
412        </div>
413        <h3 class="rule-card-name">${escapeHtml(rule.name)}</h3>
414        <p class="rule-card-desc">${escapeHtml(rule.description)}</p>
415        ${skillLink}
416      </div>
417    </article>`;
418}
419
420function escapeAttr(str) {
421  return String(str || '').replace(/"/g, '&quot;');
422}
423
424/**
425 * Render the /tutorials index main content.
426 */
427function renderTutorialsIndexMain(tutorials) {
428  const cards = tutorials
429    .map(
430      (t) => `
431    <a class="tutorial-card" href="/tutorials/${t.slug}">
432      <span class="tutorial-card-number">${String(t.order).padStart(2, '0')}</span>
433      <div class="tutorial-card-body">
434        <h2 class="tutorial-card-title">${escapeHtml(t.title)}</h2>
435        <p class="tutorial-card-tagline">${escapeHtml(t.tagline || t.description)}</p>
436      </div>
437      <span class="tutorial-card-arrow">→</span>
438    </a>`,
439    )
440    .join('\n');
441
442  return `
443<div class="tutorials-content">
444  <header class="sub-page-header">
445    <p class="sub-page-eyebrow">${tutorials.length} walk-throughs</p>
446    <h1 class="sub-page-title">Tutorials</h1>
447    <p class="sub-page-lede">Short, opinionated walk-throughs of the highest-leverage workflows. Each one takes around ten minutes and ends with something working in your project.</p>
448  </header>
449
450  <div class="tutorial-cards">
451${cards}
452  </div>
453</div>`;
454}
455
456/**
457 * Render the /visual-mode page main content.
458 *
459 * Single-column layout, no sidebar. Editorial header, live iframe embed
460 * of the detector running on a synthetic slop page, three-card section
461 * explaining the invocation methods, then a grid of real specimens the
462 * user can click into to see the overlay on a different page.
463 */
464function renderVisualModeMain() {
465  const specimenCards = GALLERY_ITEMS.map(
466    (item) => `
467      <a class="gallery-card" href="/antipattern-examples/${item.id}.html">
468        <div class="gallery-card-thumb">
469          <img src="../antipattern-images/${item.id}.png" alt="${escapeAttr(item.title)} specimen" loading="lazy" width="540" height="540">
470        </div>
471        <div class="gallery-card-body">
472          <h3 class="gallery-card-title">${escapeHtml(item.title)}</h3>
473          <p class="gallery-card-desc">${escapeHtml(item.desc)}</p>
474        </div>
475      </a>`,
476  ).join('\n');
477
478  return `
479<div class="visual-mode-page">
480  <header class="visual-mode-page-header">
481    <p class="sub-page-eyebrow">Live detection overlay</p>
482    <h1 class="sub-page-title">Visual Mode</h1>
483    <p class="sub-page-lede">See every anti-pattern flagged directly on the page. No screenshots, no JSON to map back to line numbers. The overlay draws an outline and a label on every element the detector catches, so you fix them in place.</p>
484  </header>
485
486  <section class="visual-mode-demo-wrap" aria-label="Visual Mode demo">
487    <div class="visual-mode-preview">
488      <div class="visual-mode-preview-header">
489        <span class="visual-mode-preview-dot red"></span>
490        <span class="visual-mode-preview-dot yellow"></span>
491        <span class="visual-mode-preview-dot green"></span>
492        <span class="visual-mode-preview-title">Live on a synthetic slop page</span>
493      </div>
494      <iframe src="/antipattern-examples/visual-mode-demo.html" class="visual-mode-frame" loading="lazy" title="Impeccable overlay running on a demo page"></iframe>
495    </div>
496    <p class="visual-mode-demo-caption">Hover or tap any outlined element to see which rule fired.</p>
497  </section>
498
499  <section class="visual-mode-methods" aria-label="Where to run Visual Mode">
500    <h2 class="visual-mode-methods-title">Three ways to run it</h2>
501    <div class="visual-mode-methods-grid">
502      <article class="visual-mode-method">
503        <p class="visual-mode-method-label">Inside /critique</p>
504        <h3 class="visual-mode-method-name"><a href="/skills/critique">/critique</a></h3>
505        <p class="visual-mode-method-desc">The design review skill opens the overlay automatically during its browser assessment pass. You get the deterministic findings highlighted in place while the LLM runs its separate heuristic review.</p>
506      </article>
507      <article class="visual-mode-method">
508        <p class="visual-mode-method-label">Standalone CLI</p>
509        <h3 class="visual-mode-method-name"><code>npx impeccable live</code></h3>
510        <p class="visual-mode-method-desc">Starts a local server that serves the detector script. Inject it into any page via a <code>&lt;script&gt;</code> tag to see the overlay. Works on your own dev server, a staging URL, or anyone's live page.</p>
511      </article>
512      <article class="visual-mode-method">
513        <p class="visual-mode-method-label">Easiest</p>
514        <h3 class="visual-mode-method-name">Chrome extension</h3>
515        <p class="visual-mode-method-desc">One-click activation on any tab. <a href="https://chromewebstore.google.com/detail/impeccable/bdkgmiklpdmaojlpflclinlofgjfpabf" target="_blank" rel="noopener">Install from Chrome Web Store &rarr;</a></p>
516      </article>
517    </div>
518  </section>
519
520  <section class="visual-mode-gallery" id="try-it-live" aria-label="Try it on synthetic specimens">
521    <header class="visual-mode-gallery-header">
522      <h2 class="visual-mode-gallery-title">Try it live</h2>
523      <p class="visual-mode-gallery-lede">These ${GALLERY_ITEMS.length} synthetic slop pages ship with the detector script baked in. Click any to see the overlay running on a real page, then scroll around and hover the outlined elements.</p>
524    </header>
525    <div class="gallery-grid">
526${specimenCards}
527    </div>
528  </section>
529</div>`;
530}
531
532/**
533 * Render a tutorial detail page main content.
534 */
535function renderTutorialDetail(tutorial, knownSkillIds) {
536  const bodyHtml = renderMarkdown(tutorial.body, { knownSkillIds });
537  return `
538<article class="tutorial-detail">
539  <header class="tutorial-detail-header">
540    <p class="skill-detail-eyebrow"><a href="/tutorials">Tutorials</a> / ${String(tutorial.order).padStart(2, '0')}</p>
541    <h1 class="tutorial-detail-title">${escapeHtml(tutorial.title)}</h1>
542    ${tutorial.tagline ? `<p class="tutorial-detail-tagline">${escapeHtml(tutorial.tagline)}</p>` : ''}
543  </header>
544
545  <section class="tutorial-detail-body prose">
546${bodyHtml}
547  </section>
548</article>`;
549}
550
551/**
552 * Render the /anti-patterns main column content.
553 */
554function renderAntiPatternsMain(grouped, totalRules) {
555  let sectionsHtml = '';
556  for (const section of grouped.order) {
557    const rules = grouped.bySection[section] || [];
558    if (rules.length === 0) continue;
559    const slug = slugify(section);
560    sectionsHtml += `
561    <section class="anti-patterns-section" id="section-${slug}">
562      <header class="anti-patterns-section-header">
563        <h2 class="anti-patterns-section-title">${escapeHtml(section)}</h2>
564        <p class="anti-patterns-section-count">${rules.length} ${rules.length === 1 ? 'rule' : 'rules'}</p>
565      </header>
566      <div class="rule-card-grid">
567${rules.map(renderRuleCard).join('\n')}
568      </div>
569    </section>`;
570  }
571
572  const detectedCount = grouped.order
573    .flatMap((s) => grouped.bySection[s] || [])
574    .filter((r) => r.layer !== 'llm').length;
575  const llmCount = totalRules - detectedCount;
576
577  return `
578<div class="anti-patterns-content">
579  <header class="anti-patterns-header">
580    <p class="sub-page-eyebrow">${totalRules} rules</p>
581    <h1 class="sub-page-title">Anti-patterns</h1>
582    <p class="sub-page-lede">The full catalog of patterns <a href="/skills/impeccable">/impeccable</a> teaches against. ${detectedCount} are caught by a deterministic detector (<code>npx impeccable detect</code> or the browser extension). ${llmCount} can only be flagged by <a href="/skills/critique">/critique</a>'s LLM review pass. Want to see them live on real pages? Try <a href="/visual-mode">Visual Mode</a>.</p>
583  </header>
584
585  <details class="anti-patterns-legend">
586    <summary class="anti-patterns-legend-summary">
587      <span class="anti-patterns-legend-title">How to read this</span>
588      <svg class="anti-patterns-legend-chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg>
589    </summary>
590    <div class="anti-patterns-legend-body">
591      <p><strong>AI slop</strong> rules flag the visible tells of AI-generated UIs. <strong>Quality</strong> rules flag general design mistakes that are not AI-specific but still hurt the work. Each rule also shows how it is detected:</p>
592      <dl class="anti-patterns-legend-layers">
593        <div><dt><span class="rule-card-layer" data-layer="cli">CLI</span></dt><dd>Deterministic. Runs from <code>npx impeccable detect</code> on files, no browser required.</dd></div>
594        <div><dt><span class="rule-card-layer" data-layer="browser">Browser</span></dt><dd>Deterministic, but needs real browser layout. Runs via the browser extension or Puppeteer, not the plain CLI.</dd></div>
595        <div><dt><span class="rule-card-layer" data-layer="llm">LLM only</span></dt><dd>No deterministic detector. Caught by <a href="/skills/critique">/critique</a> during its LLM design review.</dd></div>
596      </dl>
597    </div>
598  </details>
599
600  <div class="anti-patterns-sections">
601${sectionsHtml}
602  </div>
603</div>`;
604}
605
606/**
607 * Entry point. Generates all sub-page HTML files.
608 *
609 * @param {string} rootDir
610 * @returns {Promise<{ files: string[] }>} list of generated file paths (absolute)
611 */
612export async function generateSubPages(rootDir) {
613  const data = await buildSubPageData(rootDir);
614  const outDirs = {
615    skills: path.join(rootDir, 'public/skills'),
616    antiPatterns: path.join(rootDir, 'public/anti-patterns'),
617    tutorials: path.join(rootDir, 'public/tutorials'),
618    visualMode: path.join(rootDir, 'public/visual-mode'),
619  };
620
621  // Fresh output dirs each time so stale files don't linger.
622  for (const dir of Object.values(outDirs)) {
623    if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
624    fs.mkdirSync(dir, { recursive: true });
625  }
626
627  const generated = [];
628
629  // Skills index: docs-browser layout with unified sidebar.
630  {
631    const sidebar = renderDocsSidebar(data.skillsByCategory, data.tutorials, null);
632    const main = renderSkillsOverviewMain(data.skillsByCategory);
633    const html = renderPage({
634      title: 'Skills | Impeccable',
635      description:
636        '18 commands that teach your AI harness how to design. Browse by category: create, evaluate, refine, simplify, harden.',
637      bodyHtml: wrapInDocsLayout(sidebar, main),
638      activeNav: 'docs',
639      canonicalPath: '/skills',
640      bodyClass: 'sub-page skills-layout-page',
641    });
642    const out = path.join(outDirs.skills, 'index.html');
643    fs.writeFileSync(out, html, 'utf-8');
644    generated.push(out);
645  }
646
647  // Skills detail pages: same docs-browser shell as the overview.
648  for (const skill of data.skills) {
649    const sidebar = renderDocsSidebar(data.skillsByCategory, data.tutorials, { kind: 'skill', id: skill.id });
650    const main = renderSkillDetail(skill, data.knownSkillIds);
651    const title = `/${skill.id} | Impeccable`;
652    const description = skill.editorial?.frontmatter?.tagline || skill.description;
653    const html = renderPage({
654      title,
655      description,
656      bodyHtml: wrapInDocsLayout(sidebar, main),
657      activeNav: 'docs',
658      canonicalPath: `/skills/${skill.id}`,
659      bodyClass: 'sub-page skills-layout-page',
660    });
661    const out = path.join(outDirs.skills, `${skill.id}.html`);
662    fs.writeFileSync(out, html, 'utf-8');
663    generated.push(out);
664  }
665
666  // Anti-patterns index: single page, docs-browser shell with TOC sidebar.
667  {
668    const grouped = groupRulesBySection(data.rules);
669    const sidebar = renderAntiPatternsSidebar(grouped);
670    const main = renderAntiPatternsMain(grouped, data.rules.length);
671    const html = renderPage({
672      title: 'Anti-patterns | Impeccable',
673      description: `${data.rules.length} deterministic detection rules that flag the visible tells of AI-generated interfaces and common quality issues. Used by npx impeccable detect and the browser extension.`,
674      bodyHtml: wrapInDocsLayout(sidebar, main),
675      activeNav: 'anti-patterns',
676      canonicalPath: '/anti-patterns',
677      bodyClass: 'sub-page skills-layout-page anti-patterns-page',
678    });
679    const out = path.join(outDirs.antiPatterns, 'index.html');
680    fs.writeFileSync(out, html, 'utf-8');
681    generated.push(out);
682  }
683
684  // Tutorials index (under the unified Docs umbrella).
685  if (data.tutorials.length > 0) {
686    const sidebar = renderDocsSidebar(data.skillsByCategory, data.tutorials, null);
687    const main = renderTutorialsIndexMain(data.tutorials);
688    const html = renderPage({
689      title: 'Tutorials | Impeccable',
690      description: `${data.tutorials.length} short, opinionated walk-throughs of the highest-leverage Impeccable workflows.`,
691      bodyHtml: wrapInDocsLayout(sidebar, main),
692      activeNav: 'docs',
693      canonicalPath: '/tutorials',
694      bodyClass: 'sub-page skills-layout-page tutorials-page',
695    });
696    const out = path.join(outDirs.tutorials, 'index.html');
697    fs.writeFileSync(out, html, 'utf-8');
698    generated.push(out);
699  }
700
701  // Visual Mode: single standalone page, no sidebar, single-column layout.
702  {
703    const html = renderPage({
704      title: 'Visual Mode | Impeccable',
705      description:
706        'See every anti-pattern flagged directly on the page. Live detection overlay from Impeccable, available via /critique, npx impeccable live, or the upcoming Chrome extension.',
707      bodyHtml: renderVisualModeMain(),
708      activeNav: 'visual-mode',
709      canonicalPath: '/visual-mode',
710      bodyClass: 'sub-page visual-mode-page-body',
711    });
712    const out = path.join(outDirs.visualMode, 'index.html');
713    fs.writeFileSync(out, html, 'utf-8');
714    generated.push(out);
715  }
716
717  // Tutorial detail pages.
718  for (const tutorial of data.tutorials) {
719    const sidebar = renderDocsSidebar(data.skillsByCategory, data.tutorials, { kind: 'tutorial', slug: tutorial.slug });
720    const main = renderTutorialDetail(tutorial, data.knownSkillIds);
721    const html = renderPage({
722      title: `${tutorial.title} | Tutorials | Impeccable`,
723      description: tutorial.description || tutorial.tagline || '',
724      bodyHtml: wrapInDocsLayout(sidebar, main),
725      activeNav: 'docs',
726      canonicalPath: `/tutorials/${tutorial.slug}`,
727      bodyClass: 'sub-page skills-layout-page tutorials-page',
728    });
729    const out = path.join(outDirs.tutorials, `${tutorial.slug}.html`);
730    fs.writeFileSync(out, html, 'utf-8');
731    generated.push(out);
732  }
733
734  return { files: generated };
735}