Step-by-step guide to building the perfect build.ts for a static HTML site powered by @webhouse/cms.
What is build.ts?
build.ts is a custom static site generator that reads your CMS content (JSON files) and outputs plain HTML. No framework, no runtime JavaScript — just HTML + CSS that works everywhere.
npx tsx build.ts # Generate dist/Lesson 1: Load content
The foundation — read JSON files from content/:
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { join } from 'node:path';
const CONTENT = join(import.meta.dirname, 'content');
function getCollection(name: string) {
const dir = join(CONTENT, name);
if (!existsSync(dir)) return [];
return readdirSync(dir)
.filter(f => f.endsWith('.json'))
.map(f => JSON.parse(readFileSync(join(dir, f), 'utf-8')))
.filter(d => d.status === 'published');
}
function getDocument(collection: string, slug: string) {
const file = join(CONTENT, collection, slug + '.json');
if (!existsSync(file)) return null;
return JSON.parse(readFileSync(file, 'utf-8'));
}Lesson 2: Render markdown
Convert richtext content to HTML:
import { marked } from 'marked';
function renderMarkdown(content: string): string {
return marked.parse(content, { async: false }) as string;
}Lesson 3: HTML template
Wrap content in a complete HTML document:
function htmlPage(title: string, body: string, css: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title}</title>
<style>${css}</style>
</head>
<body>
${body}
</body>
</html>`;
}Lesson 4: Build a page
Combine content loading, markdown rendering, and HTML template:
function buildPage(doc: any, css: string): string {
const title = doc.data.title;
const content = renderMarkdown(doc.data.content || '');
const body = `
<article>
<h1>${title}</h1>
${content}
</article>
`;
return htmlPage(title, body, css);
}Lesson 5: Render blocks
If your pages use blocks (hero, features, CTA), render each block type:
interface Block { _block: string; [key: string]: unknown; }
function renderBlock(block: Block): string {
switch (block._block) {
case 'hero':
return `<section class="hero">
<h1>${block.tagline}</h1>
<p>${block.description}</p>
</section>`;
case 'features':
const items = (block.items as any[]) || [];
return `<section class="features">
<h2>${block.title}</h2>
<div class="grid">
${items.map(i => `<div><h3>${i.title}</h3><p>${i.description}</p></div>`).join('')}
</div>
</section>`;
case 'cta':
return `<section class="cta">
<h2>${block.title}</h2>
<a href="${block.buttonUrl}">${block.buttonText}</a>
</section>`;
default:
return '';
}
}
function renderBlocks(blocks: Block[]): string {
return blocks.map(renderBlock).join('\n');
}Lesson 6: SEO metadata
Extract SEO fields and generate meta tags:
function seoTags(doc: any): string {
const seo = doc.data._seo || {};
const title = seo.metaTitle || doc.data.title;
const desc = seo.metaDescription || doc.data.excerpt || '';
return `
<title>${title}</title>
<meta name="description" content="${desc}">
<meta property="og:title" content="${title}">
<meta property="og:description" content="${desc}">
${seo.ogImage ? `<meta property="og:image" content="${seo.ogImage}">` : ''}
`;
}Lesson 7: Write output
Generate files to dist/:
import { writeFileSync, mkdirSync } from 'node:fs';
const DIST = join(import.meta.dirname, 'dist');
function writePage(urlPath: string, html: string) {
const dir = join(DIST, urlPath);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'index.html'), html);
}
// Build all pages
const posts = getCollection('posts');
for (const post of posts) {
const html = buildPage(post, css);
writePage(`/blog/${post.slug}`, html);
}Lesson 8: Sitemap
Generate sitemap.xml for search engines:
function generateSitemap(baseUrl: string, pages: string[]): string {
const urls = pages.map(p =>
` <url><loc>${baseUrl}${p}</loc></url>`
).join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls}
</urlset>`;
}Lesson 9: Resolve snippets
Add snippet support to your build:
function resolveSnippets(markdown: string): string {
return markdown.replace(
/\{\{snippet:([a-z0-9-]+)\}\}/g,
(_match, slug) => {
const snippet = getDocument('snippets', slug);
if (!snippet) return '';
return '\x60\x60\x60' + (snippet.data.lang || 'text') +
'\n' + snippet.data.code + '\n\x60\x60\x60';
}
);
}
// Use in your build pipeline:
const content = resolveSnippets(doc.data.content);
const html = renderMarkdown(content);Lesson 10: Copy static assets
Copy uploads and public files:
import { cpSync } from 'node:fs';
// Copy uploads
cpSync(join(import.meta.dirname, 'public', 'uploads'),
join(DIST, 'uploads'), { recursive: true });
// Copy favicon
cpSync(join(import.meta.dirname, 'public', 'favicon.svg'),
join(DIST, 'favicon.svg'));The complete build pipeline
Putting it all together:
// 1. Load CSS (inline in HTML)
const css = readFileSync('styles.css', 'utf-8');
// 2. Build collection index pages
const posts = getCollection('posts');
writePage('/blog', buildListPage('Blog', posts, css));
// 3. Build individual pages
for (const post of posts) {
writePage(`/blog/${post.slug}`, buildPage(post, css));
}
// 4. Build homepage
const home = getDocument('pages', 'home');
if (home) writePage('/', buildPage(home, css));
// 5. Generate sitemap
const allPaths = ['/'].concat(posts.map(p => `/blog/${p.slug}`));
writeFileSync(join(DIST, 'sitemap.xml'), generateSitemap(BASE_URL, allPaths));
// 6. Copy assets
cpSync('public/uploads', join(DIST, 'uploads'), { recursive: true });
console.log('Built ' + allPaths.length + ' pages');Next steps
- Templates — start from a working boilerplate instead of scratch
- Next.js Patterns — if you want React instead of static HTML
- Shared Snippets — reusable code blocks across pages