mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-14 15:16:35 +03:00
Generate /skills index + 21 auto-rendered skill detail pages
Ships the first new sub-page section. Every user-invocable skill now has
its own page at /skills/{id}, with the canonical SKILL.md body rendered
via marked. The index at /skills lists all 21 skills grouped by category.
Editorial wrappers are opt-in: if content/site/skills/{id}.md exists, it
renders above the canonical body (with a "The skill itself" divider).
All 21 pages currently ship with the auto-rendered body only; hand-written
wrappers land in the next few commits.
- scripts/lib/sub-pages-data.js: builds the data model. Reuses
readSourceFiles() from lib/utils.js for skill content; parses the
ANTIPATTERNS array out of src/detect-antipatterns.mjs; reads optional
editorial wrappers from content/site/skills/*.md; validates that every
user-invocable skill has a category entry (build fails loudly if not).
- scripts/build-sub-pages.js: orchestrator. Writes generated HTML into
public/skills/*.html (gitignored). Called from both scripts/build.js
(before buildStaticSite) and server/index.js (at module load) so dev
and prod share the same generation code path.
- scripts/lib/render-page.js: new assetDepth parameter so generated
pages one level deep under public/ use relative paths (../favicon.svg,
../css/sub-pages.css) that Bun's HTML loader can resolve on disk.
- scripts/build.js: pass generated files into Bun.build entrypoints;
post-process to flatten build/public/* → build/* (Bun preserves the
public/ prefix when entrypoints span multiple depths).
- server/index.js: generateSubPages() runs at module load; new routes
/skills, /skills/:id, /anti-patterns, /tutorials, /tutorials/:slug
serve the pre-generated files via Bun.file().
- public/css/sub-pages.css: adds sub-page layout shell, skills index
grouped-list styling, skill detail header/meta chips/divider, collapsed
<details> reference sections, and a .prose block for rendered markdown
with editorial typography, code blocks, and inline code.
Verified: bun run build produces 26 HTML files (4 hand-authored + 22
generated), all flat under build/. Dev server returns 200 on /skills,
/skills/polish, /skills/impeccable, /skills/critique. Tests pass.
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Generate static HTML files for /skills, /anti-patterns, /tutorials.
|
||||
*
|
||||
* Called from both scripts/build.js (before buildStaticSite) and
|
||||
* server/index.js (at module load), so dev and prod share the same
|
||||
* code path and output shape.
|
||||
*
|
||||
* Output lives under public/skills/, public/anti-patterns/,
|
||||
* public/tutorials/, all gitignored. Bun's HTML loader picks them up
|
||||
* the same way it picks up the hand-authored pages.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
buildSubPageData,
|
||||
CATEGORY_ORDER,
|
||||
CATEGORY_LABELS,
|
||||
CATEGORY_DESCRIPTIONS,
|
||||
} from './lib/sub-pages-data.js';
|
||||
import { renderMarkdown, slugify } from './lib/render-markdown.js';
|
||||
import { renderPage } from './lib/render-page.js';
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one skill detail page HTML body (without the site shell).
|
||||
*/
|
||||
function renderSkillDetail(skill, knownSkillIds) {
|
||||
const bodyHtml = renderMarkdown(skill.body, {
|
||||
knownSkillIds,
|
||||
currentSkillId: skill.id,
|
||||
});
|
||||
|
||||
const editorialHtml = skill.editorial
|
||||
? renderMarkdown(skill.editorial.body, { knownSkillIds, currentSkillId: skill.id })
|
||||
: '';
|
||||
|
||||
const tagline = skill.editorial?.frontmatter?.tagline || skill.description;
|
||||
const categoryLabel = CATEGORY_LABELS[skill.category] || skill.category;
|
||||
|
||||
// Reference files as collapsible <details> blocks
|
||||
let referencesHtml = '';
|
||||
if (skill.references && skill.references.length > 0) {
|
||||
const refs = skill.references
|
||||
.map((ref) => {
|
||||
const slug = slugify(ref.name);
|
||||
const refBody = renderMarkdown(ref.content, {
|
||||
knownSkillIds,
|
||||
currentSkillId: skill.id,
|
||||
});
|
||||
const title = ref.name
|
||||
.split('-')
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(' ');
|
||||
return `
|
||||
<details class="skill-reference" id="reference-${slug}">
|
||||
<summary><span class="skill-reference-label">Reference</span><span class="skill-reference-title">${escapeHtml(title)}</span></summary>
|
||||
<div class="prose skill-reference-body">
|
||||
${refBody}
|
||||
</div>
|
||||
</details>`;
|
||||
})
|
||||
.join('\n');
|
||||
referencesHtml = `
|
||||
<section class="skill-references" aria-label="Reference material">
|
||||
<h2 class="skill-references-heading">Deeper reference</h2>
|
||||
${refs}
|
||||
</section>`;
|
||||
}
|
||||
|
||||
const metaStrip = `
|
||||
<div class="skill-meta-strip">
|
||||
<span class="skill-meta-chip skill-meta-category" data-category="${skill.category}">${escapeHtml(categoryLabel)}</span>
|
||||
<span class="skill-meta-chip">User-invocable</span>
|
||||
${skill.argumentHint ? `<span class="skill-meta-chip skill-meta-args">${escapeHtml(skill.argumentHint)}</span>` : ''}
|
||||
</div>`;
|
||||
|
||||
return `
|
||||
<article class="skill-detail">
|
||||
<header class="skill-detail-header">
|
||||
<p class="skill-detail-eyebrow"><a href="/skills">Skills</a> / ${escapeHtml(categoryLabel)}</p>
|
||||
<h1 class="skill-detail-title">/${escapeHtml(skill.id)}</h1>
|
||||
<p class="skill-detail-tagline">${escapeHtml(tagline)}</p>
|
||||
${metaStrip}
|
||||
</header>
|
||||
|
||||
${editorialHtml ? `<section class="skill-detail-editorial prose">\n${editorialHtml}\n</section>` : ''}
|
||||
|
||||
<div class="skill-detail-divider">
|
||||
<span>The skill itself</span>
|
||||
</div>
|
||||
|
||||
<section class="skill-detail-body prose">
|
||||
${bodyHtml}
|
||||
</section>
|
||||
|
||||
${referencesHtml}
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the /skills index page body.
|
||||
*/
|
||||
function renderSkillsIndex(skillsByCategory) {
|
||||
let html = `
|
||||
<article class="sub-page-content">
|
||||
<header class="sub-page-header">
|
||||
<p class="sub-page-eyebrow">22 commands</p>
|
||||
<h1 class="sub-page-title">Skills</h1>
|
||||
<p class="sub-page-lede">One skill (/impeccable) teaches your AI design. Twenty-one commands steer the result. Each one is a small, opinionated tool that knows how to fix one specific thing.</p>
|
||||
</header>
|
||||
`;
|
||||
|
||||
for (const category of CATEGORY_ORDER) {
|
||||
const list = skillsByCategory[category] || [];
|
||||
if (list.length === 0) continue;
|
||||
html += `
|
||||
<section class="skills-category" data-category="${category}">
|
||||
<div class="skills-category-header">
|
||||
<h2 class="skills-category-title">${escapeHtml(CATEGORY_LABELS[category])}</h2>
|
||||
<p class="skills-category-desc">${escapeHtml(CATEGORY_DESCRIPTIONS[category])}</p>
|
||||
</div>
|
||||
<ul class="skills-category-list">
|
||||
${list
|
||||
.map(
|
||||
(s) => ` <li class="skills-category-item">
|
||||
<a href="/skills/${s.id}" class="skills-category-link">
|
||||
<span class="skills-category-name">/${escapeHtml(s.id)}</span>
|
||||
<span class="skills-category-desc-text">${escapeHtml(s.description)}</span>
|
||||
</a>
|
||||
</li>`,
|
||||
)
|
||||
.join('\n')}
|
||||
</ul>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
html += `</article>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry point. Generates all sub-page HTML files.
|
||||
*
|
||||
* @param {string} rootDir
|
||||
* @returns {Promise<{ files: string[] }>} list of generated file paths (absolute)
|
||||
*/
|
||||
export async function generateSubPages(rootDir) {
|
||||
const data = buildSubPageData(rootDir);
|
||||
const outDirs = {
|
||||
skills: path.join(rootDir, 'public/skills'),
|
||||
antiPatterns: path.join(rootDir, 'public/anti-patterns'),
|
||||
tutorials: path.join(rootDir, 'public/tutorials'),
|
||||
};
|
||||
|
||||
// Fresh output dirs each time so stale files don't linger.
|
||||
for (const dir of Object.values(outDirs)) {
|
||||
if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const generated = [];
|
||||
|
||||
// Skills index
|
||||
{
|
||||
const html = renderPage({
|
||||
title: 'Skills — Impeccable',
|
||||
description:
|
||||
'22 commands that teach your AI harness how to design. Browse by category: create, evaluate, refine, simplify, harden, system.',
|
||||
bodyHtml: renderSkillsIndex(data.skillsByCategory),
|
||||
activeNav: 'skills',
|
||||
canonicalPath: '/skills',
|
||||
});
|
||||
const out = path.join(outDirs.skills, 'index.html');
|
||||
fs.writeFileSync(out, html, 'utf-8');
|
||||
generated.push(out);
|
||||
}
|
||||
|
||||
// Skills detail pages
|
||||
for (const skill of data.skills) {
|
||||
const bodyHtml = renderSkillDetail(skill, data.knownSkillIds);
|
||||
const title = `/${skill.id} — Impeccable skill`;
|
||||
const description = skill.editorial?.frontmatter?.tagline || skill.description;
|
||||
const html = renderPage({
|
||||
title,
|
||||
description,
|
||||
bodyHtml,
|
||||
activeNav: 'skills',
|
||||
canonicalPath: `/skills/${skill.id}`,
|
||||
});
|
||||
const out = path.join(outDirs.skills, `${skill.id}.html`);
|
||||
fs.writeFileSync(out, html, 'utf-8');
|
||||
generated.push(out);
|
||||
}
|
||||
|
||||
return { files: generated };
|
||||
}
|
||||
+27
-5
@@ -20,6 +20,7 @@ import { fileURLToPath } from 'url';
|
||||
import { readSourceFiles, readPatterns } from './lib/utils.js';
|
||||
import { createTransformer, PROVIDERS } from './lib/transformers/index.js';
|
||||
import { createAllZips } from './lib/zip.js';
|
||||
import { generateSubPages } from './build-sub-pages.js';
|
||||
|
||||
/**
|
||||
* Generate authoritative counts from source data and write to public/js/generated/counts.js.
|
||||
@@ -218,16 +219,17 @@ const DIST_DIR = path.join(ROOT_DIR, 'dist');
|
||||
* Build static site using Bun's HTML bundler
|
||||
* Bun's HTML loader resolves <link rel="stylesheet"> and inlines CSS @imports.
|
||||
*/
|
||||
async function buildStaticSite() {
|
||||
async function buildStaticSite(extraEntrypoints = []) {
|
||||
const entrypoints = [
|
||||
path.join(ROOT_DIR, 'public', 'index.html'),
|
||||
path.join(ROOT_DIR, 'public', 'cheatsheet.html'),
|
||||
path.join(ROOT_DIR, 'public', 'gallery.html'),
|
||||
path.join(ROOT_DIR, 'public', 'privacy.html'),
|
||||
...extraEntrypoints,
|
||||
];
|
||||
const outdir = path.join(ROOT_DIR, 'build');
|
||||
|
||||
console.log('📦 Building static site with Bun...');
|
||||
console.log(`📦 Building static site with Bun (${entrypoints.length} HTML entries)...`);
|
||||
|
||||
try {
|
||||
const result = await Bun.build({
|
||||
@@ -250,11 +252,26 @@ async function buildStaticSite() {
|
||||
|
||||
// Calculate total size
|
||||
const totalSize = result.outputs.reduce((sum, o) => sum + o.size, 0);
|
||||
const htmlFiles = result.outputs.filter(o => o.path.endsWith('.html'));
|
||||
const jsFiles = result.outputs.filter(o => o.path.endsWith('.js'));
|
||||
const cssFiles = result.outputs.filter(o => o.path.endsWith('.css'));
|
||||
|
||||
// When entrypoints span multiple depths under public/ (e.g. public/index.html
|
||||
// + public/skills/polish.html), Bun's HTML loader preserves the full public/
|
||||
// prefix in the output tree. Flatten build/public/* up to build/*.
|
||||
const nestedPublic = path.join(outdir, 'public');
|
||||
if (fs.existsSync(nestedPublic)) {
|
||||
for (const entry of fs.readdirSync(nestedPublic, { withFileTypes: true })) {
|
||||
const from = path.join(nestedPublic, entry.name);
|
||||
const to = path.join(outdir, entry.name);
|
||||
if (fs.existsSync(to)) fs.rmSync(to, { recursive: true, force: true });
|
||||
fs.renameSync(from, to);
|
||||
}
|
||||
fs.rmdirSync(nestedPublic);
|
||||
}
|
||||
|
||||
console.log(`✓ Static site built to ./build/`);
|
||||
console.log(` HTML: 1 file`);
|
||||
console.log(` HTML: ${htmlFiles.length} file(s)`);
|
||||
console.log(` JS: ${jsFiles.length} file(s) (${(jsFiles.reduce((s, f) => s + f.size, 0) / 1024).toFixed(1)} KB)`);
|
||||
console.log(` CSS: ${cssFiles.length} file(s) (${(cssFiles.reduce((s, f) => s + f.size, 0) / 1024).toFixed(1)} KB)`);
|
||||
console.log(` Total: ${(totalSize / 1024).toFixed(1)} KB\n`);
|
||||
@@ -453,8 +470,13 @@ function generateCFConfig(buildDir) {
|
||||
async function build() {
|
||||
console.log('🔨 Building cross-provider design skills...\n');
|
||||
|
||||
// Bundle HTML, JS, and CSS with Bun
|
||||
await buildStaticSite();
|
||||
// Pre-generate sub-pages (skills, anti-patterns, tutorials) from source
|
||||
console.log('📝 Generating sub-pages...');
|
||||
const { files: subPageFiles } = await generateSubPages(ROOT_DIR);
|
||||
console.log(`✓ Generated ${subPageFiles.length} sub-page(s)\n`);
|
||||
|
||||
// Bundle HTML, JS, and CSS with Bun (including generated sub-pages)
|
||||
await buildStaticSite(subPageFiles);
|
||||
|
||||
// Copy root-level static assets that need stable (unhashed) URLs
|
||||
const staticAssets = ['og-image.jpg', 'robots.txt', 'sitemap.xml', 'favicon.svg', 'apple-touch-icon.png'];
|
||||
|
||||
@@ -55,6 +55,7 @@ export function applyActiveNav(headerHtml, activeNav) {
|
||||
* @param {string} [opts.canonicalPath] - relative URL path for <link rel="canonical">
|
||||
* @param {string} [opts.extraHead] - raw HTML to inject into <head>
|
||||
* @param {string} [opts.bodyClass] - optional class on <body>
|
||||
* @param {number} [opts.assetDepth] - how many `..` to prepend for Bun's HTML loader to resolve on-disk paths. 1 = page is one dir deep under public/ (e.g. public/skills/polish.html). Defaults to 1.
|
||||
* @returns {string} full HTML document
|
||||
*/
|
||||
export function renderPage({
|
||||
@@ -65,6 +66,7 @@ export function renderPage({
|
||||
canonicalPath,
|
||||
extraHead = '',
|
||||
bodyClass = 'sub-page',
|
||||
assetDepth = 1,
|
||||
}) {
|
||||
const header = applyActiveNav(readHeaderPartial(), activeNav);
|
||||
const safeTitle = escapeHtml(title);
|
||||
@@ -73,6 +75,11 @@ export function renderPage({
|
||||
? `<link rel="canonical" href="https://impeccable.style${canonicalPath}">`
|
||||
: '';
|
||||
|
||||
// Relative prefix for on-disk resolution by Bun's HTML loader.
|
||||
// Bun rewrites these to hashed absolute URLs at build time, so runtime
|
||||
// serving works regardless of the request path.
|
||||
const rel = assetDepth > 0 ? '../'.repeat(assetDepth) : './';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -82,11 +89,11 @@ export function renderPage({
|
||||
<meta name="description" content="${safeDesc}">
|
||||
<meta name="theme-color" content="#fafafa">
|
||||
${canonical}
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="icon" type="image/svg+xml" href="${rel}favicon.svg">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,600;1,400&family=Instrument+Sans:wght@400;500;600;700&family=Space+Grotesk:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/css/sub-pages.css">
|
||||
<link rel="stylesheet" href="${rel}css/sub-pages.css">
|
||||
${extraHead}
|
||||
</head>
|
||||
<body class="${bodyClass}">
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* 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 { readSourceFiles, parseFrontmatter } from './utils.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',
|
||||
onboard: 'create',
|
||||
overdrive: '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',
|
||||
// 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: 'Start 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: 'Get 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 function buildSubPageData(rootDir) {
|
||||
const { skills: rawSkills } = readSourceFiles(rootDir);
|
||||
const contentDir = path.join(rootDir, 'content/site');
|
||||
|
||||
// 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);
|
||||
return {
|
||||
id: s.name,
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
argumentHint: s.argumentHint,
|
||||
category,
|
||||
body: s.body,
|
||||
references: s.references,
|
||||
editorial, // may be null
|
||||
};
|
||||
})
|
||||
.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, grouped for the index.
|
||||
const rules = readAntipatternRules(rootDir);
|
||||
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user