From bef65038468dbfa75bd0e82705a2efcdfce8a3ce Mon Sep 17 00:00:00 2001 From: kjellbergzoey Date: Sun, 7 Jun 2026 13:05:43 +0000 Subject: [PATCH] feat: add kiqr seed command for demo content Add `kiqr seed [--preset blog|portfolio|woocommerce]` which generates a deterministic, valid WXR 1.2 document of realistic demo content in the current directory and prints the import command. - src/lib/seed.ts: pure, deterministic generateSeedWxr(preset). All dates are fixed literals; no Date/random APIs, so output is byte-for-byte stable. Content is block-editor friendly (core wp: block comments). - src/commands/seed.tsx: thin Pastel command, validates --preset against SEED_PRESETS, refuses to overwrite an existing file. - tests/lib/seed.test.ts: well-formedness, item/post-type counts, CDATA, determinism, and per-preset content checks. Co-Authored-By: Claude Opus 4.8 --- src/commands/seed.tsx | 87 ++++++++ src/lib/seed.ts | 462 +++++++++++++++++++++++++++++++++++++++++ tests/lib/seed.test.ts | 204 ++++++++++++++++++ 3 files changed, 753 insertions(+) create mode 100644 src/commands/seed.tsx create mode 100644 src/lib/seed.ts create mode 100644 tests/lib/seed.test.ts diff --git a/src/commands/seed.tsx b/src/commands/seed.tsx new file mode 100644 index 0000000..6ce6f6e --- /dev/null +++ b/src/commands/seed.tsx @@ -0,0 +1,87 @@ +import {existsSync, writeFileSync} from 'node:fs'; +import path from 'node:path'; +import {Box, Text, useApp} from 'ink'; +import {option} from 'pastel'; +import {useEffect, useState} from 'react'; +import zod from 'zod'; +import {generateSeedWxr, SEED_PRESETS, type SeedPreset} from '../lib/seed.js'; + +export const description = 'Generate realistic WordPress demo content as a WXR file'; + +export const options = zod.object({ + preset: zod + .enum(SEED_PRESETS) + .default('blog') + .describe( + option({ + description: 'Which demo content set to generate', + alias: 'p', + valueDescription: SEED_PRESETS.join('|'), + }), + ), +}); + +type Props = { + options: zod.infer; +}; + +type Result = + | {kind: 'done'; preset: SeedPreset; filename: string} + | {kind: 'error'; message: string}; + +export default function Seed({options}: Props) { + const {exit} = useApp(); + const [result, setResult] = useState(null); + + useEffect(() => { + const preset = options.preset; + const filename = `kiqr-seed-${preset}.xml`; + const target = path.join(process.cwd(), filename); + + if (existsSync(target)) { + setResult({ + kind: 'error', + message: `${filename} already exists. Remove it or move it before running "kiqr seed" again.`, + }); + setTimeout(() => exit(new Error()), 100); + return; + } + + try { + writeFileSync(target, generateSeedWxr(preset), 'utf8'); + setResult({kind: 'done', preset, filename}); + setTimeout(() => exit(), 100); + } catch (err) { + setResult({ + kind: 'error', + message: err instanceof Error ? err.message : String(err), + }); + setTimeout(() => exit(new Error()), 100); + } + }, []); + + if (!result) return Generating demo content...; + + if (result.kind === 'error') return {result.message}; + + return ( + + + Wrote {result.filename} ({result.preset} preset). + + + Next steps: + + 1. Make sure your site is running: + + {' '} + kiqr up + + 2. Import the demo content: + + {' '} + kiqr wp import {result.filename} --authors=create + + + ); +} diff --git a/src/lib/seed.ts b/src/lib/seed.ts new file mode 100644 index 0000000..3ff3150 --- /dev/null +++ b/src/lib/seed.ts @@ -0,0 +1,462 @@ +/** + * Pure, deterministic generator for WordPress demo content. + * + * The output is a WXR (WordPress eXtended RSS) 1.2 document that can be fed + * straight into `wp import`. Everything here is intentionally free of any + * time- or randomness-based APIs: every date is a fixed literal so that the + * exact same bytes are produced on every run (which keeps it diff-friendly and + * unit-testable). + */ + +export const SEED_PRESETS = ['blog', 'portfolio', 'woocommerce'] as const; + +export type SeedPreset = (typeof SEED_PRESETS)[number]; + +/** A single content node that becomes one `` in the WXR feed. */ +type SeedItem = { + /** Sequential id; also used for the post id and menu order. */ + id: number; + title: string; + /** URL slug, used for `` and the guid. */ + slug: string; + /** WordPress post type: post, page, product, etc. */ + postType: string; + /** Full block-editor markup for ``. */ + content: string; + /** Short excerpt for ``. */ + excerpt: string; + /** Literal date string, e.g. '2024-01-15T10:00:00' (no timezone, no `Z`). */ + date: string; +}; + +const SITE_TITLE = 'Kiqr Demo Site'; +const SITE_LINK = 'https://example.test'; +const SITE_DESCRIPTION = 'Demo content generated by the Kiqr CLI'; +const AUTHOR_LOGIN = 'kiqr'; +const AUTHOR_NAME = 'Kiqr Demo'; +const AUTHOR_EMAIL = 'demo@example.test'; + +/** + * Build a single block-editor paragraph. Wrapping the markup in the core block + * comments keeps the imported content editable as native Gutenberg blocks + * rather than a "Classic" blob. + */ +function paragraph(text: string): string { + return `\n

${escapeHtml(text)}

\n`; +} + +function heading(text: string): string { + return `\n

${escapeHtml(text)}

\n`; +} + +function list(items: string[]): string { + const inner = items + .map( + (item) => + `\n
  • ${escapeHtml(item)}
  • \n`, + ) + .join('\n'); + return `\n
      \n${inner}\n
    \n`; +} + +/** Compose several block fragments into one `` payload. */ +function blocks(...fragments: string[]): string { + return fragments.join('\n\n'); +} + +/** Escape the five XML-significant characters for use in element text. */ +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** Wrap a value in a CDATA section, neutralising any nested terminators. */ +function cdata(value: string): string { + return `/g, ']]]]>')}]]>`; +} + +/** + * WordPress stores two timestamps per post: site-local and GMT. The demo + * content uses UTC, so both columns share the same literal value (just + * reformatted from ISO `T` notation to the space-separated SQL form). + */ +function sqlDate(date: string): string { + return date.replace('T', ' '); +} + +const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const; +const MONTHS = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +] as const; + +/** Convert an ISO-ish literal to the RFC-822 `` WordPress emits. */ +function pubDate(date: string): string { + const datePart = date.split('T')[0] ?? ''; + const timePart = date.split('T')[1] ?? '00:00:00'; + const parts = datePart.split('-'); + const year = Number.parseInt(parts[0] ?? '0', 10); + const month = Number.parseInt(parts[1] ?? '1', 10); + const day = Number.parseInt(parts[2] ?? '1', 10); + + // Zeller-style weekday calculation; deterministic and dependency-free. + const m = month < 3 ? month + 12 : month; + const y = month < 3 ? year - 1 : year; + const k = y % 100; + const j = Math.floor(y / 100); + const h = + (day + + Math.floor((13 * (m + 1)) / 5) + + k + + Math.floor(k / 4) + + Math.floor(j / 4) + + 5 * j) % + 7; + const weekday = WEEKDAYS[(h + 6) % 7]; + const monthName = MONTHS[month - 1]; + return `${weekday}, ${String(day).padStart(2, '0')} ${monthName} ${year} ${timePart} +0000`; +} + +function renderItem(item: SeedItem): string { + const link = `${SITE_LINK}/?p=${item.id}`; + return [ + '\t\t', + `\t\t\t${escapeHtml(item.title)}`, + `\t\t\t${link}`, + `\t\t\t${pubDate(item.date)}`, + `\t\t\t${cdata(AUTHOR_LOGIN)}`, + `\t\t\t${link}`, + '\t\t\t', + `\t\t\t${cdata(item.content)}`, + `\t\t\t${cdata(item.excerpt)}`, + `\t\t\t${item.id}`, + `\t\t\t${cdata(sqlDate(item.date))}`, + `\t\t\t${cdata(sqlDate(item.date))}`, + `\t\t\t${cdata('open')}`, + `\t\t\t${cdata('open')}`, + `\t\t\t${cdata(item.slug)}`, + `\t\t\t${cdata('publish')}`, + `\t\t\t0`, + `\t\t\t${item.id}`, + `\t\t\t${cdata(item.postType)}`, + `\t\t\t${cdata('')}`, + `\t\t\t0`, + '\t\t', + ].join('\n'); +} + +/** Items for the `blog` preset: a handful of posts plus an About page. */ +function blogItems(): SeedItem[] { + const posts: SeedItem[] = [ + { + id: 10, + title: 'Welcome to the Kiqr Demo', + slug: 'welcome-to-the-kiqr-demo', + postType: 'post', + date: '2024-01-15T10:00:00', + excerpt: 'A quick tour of what this demo content is for.', + content: blocks( + paragraph( + 'This post and the rest of this demo content were generated by the Kiqr CLI so you can preview your theme against a realistic, populated site instead of an empty install.', + ), + heading('Why demo content?'), + paragraph( + 'A fresh WordPress install has no posts, pages, or media, which makes it hard to judge typography, spacing, and archive layouts. Seeded content fixes that in seconds.', + ), + ), + }, + { + id: 11, + title: 'Designing Readable Typography', + slug: 'designing-readable-typography', + postType: 'post', + date: '2024-02-03T09:30:00', + excerpt: 'Notes on line length, rhythm, and contrast for body text.', + content: blocks( + paragraph( + 'Good body typography is mostly about measure and rhythm. Aim for 60 to 75 characters per line and a comfortable line height around 1.6.', + ), + list([ + 'Keep your measure between 60 and 75 characters.', + 'Use a modular scale for headings.', + 'Test contrast against your lightest background.', + ]), + ), + }, + { + id: 12, + title: 'A Field Guide to Whitespace', + slug: 'a-field-guide-to-whitespace', + postType: 'post', + date: '2024-03-12T14:15:00', + excerpt: 'Whitespace is a feature, not wasted space.', + content: blocks( + paragraph( + 'Whitespace gives content room to breathe and guides the eye through a page. Treat it as a deliberate design tool rather than leftover margin.', + ), + paragraph( + 'When a layout feels cramped, the fix is rarely a smaller font. More often it is more space between elements.', + ), + ), + }, + { + id: 13, + title: 'Shipping a Theme with Confidence', + slug: 'shipping-a-theme-with-confidence', + postType: 'post', + date: '2024-04-08T08:00:00', + excerpt: 'A checklist for the day you hand a theme to a client.', + content: blocks( + paragraph( + 'Before you ship, walk through every template against real content. Long titles, missing images, and empty archives are where themes usually break.', + ), + heading('Pre-flight checklist'), + list([ + 'Single post and page templates.', + 'Category, tag, and author archives.', + 'Search results and the 404 page.', + ]), + ), + }, + { + id: 14, + title: 'Color and Contrast Basics', + slug: 'color-and-contrast-basics', + postType: 'post', + date: '2024-05-20T11:45:00', + excerpt: 'Accessible color is non-negotiable.', + content: blocks( + paragraph( + 'Aim for a contrast ratio of at least 4.5 to 1 for body text. Tools in the browser dev tools can flag failing combinations instantly.', + ), + paragraph( + 'Never rely on color alone to convey meaning; pair it with text, icons, or underlines so the information survives for every reader.', + ), + ), + }, + ]; + + const aboutPage: SeedItem = { + id: 20, + title: 'About', + slug: 'about', + postType: 'page', + date: '2024-01-10T08:00:00', + excerpt: 'About this demo site.', + content: blocks( + heading('About this site'), + paragraph( + 'This is a demo About page. It exists so you can see how your theme renders a long-form static page, complete with headings and paragraphs.', + ), + paragraph( + 'Replace this content with your own story once you are happy with the look and feel.', + ), + ), + }; + + return [...posts, aboutPage]; +} + +/** Items for the `portfolio` preset: an intro page plus project pages. */ +function portfolioItems(): SeedItem[] { + const intro: SeedItem = { + id: 30, + title: 'Selected Work', + slug: 'selected-work', + postType: 'page', + date: '2024-01-05T09:00:00', + excerpt: 'An introduction to the portfolio.', + content: blocks( + heading('Selected work'), + paragraph( + 'A small collection of recent projects, generated as demo content so you can preview portfolio and project templates with real-feeling entries.', + ), + ), + }; + + const projects: Array<{title: string; slug: string; summary: string; role: string}> = [ + { + title: 'Northwind Coffee Rebrand', + slug: 'northwind-coffee-rebrand', + summary: + 'A full visual identity for a specialty coffee roaster, from logo to packaging and a refreshed storefront.', + role: 'Brand identity and web design', + }, + { + title: 'Harbor Analytics Dashboard', + slug: 'harbor-analytics-dashboard', + summary: + 'A data-heavy dashboard that turns shipping logistics into clear, glanceable charts for operations teams.', + role: 'Product design and front-end', + }, + { + title: 'Meadow Botanicals Storefront', + slug: 'meadow-botanicals-storefront', + summary: + 'An online shop for a small-batch skincare brand, with a calm palette and a focus on ingredient storytelling.', + role: 'E-commerce design and build', + }, + { + title: 'Atlas Travel Journal', + slug: 'atlas-travel-journal', + summary: + 'An editorial travel publication with immersive photo galleries and a typographic system built for long reads.', + role: 'Editorial design and theming', + }, + ]; + + const projectItems: SeedItem[] = projects.map((project, index) => ({ + id: 40 + index, + title: project.title, + slug: project.slug, + postType: 'page', + date: `2024-0${index + 2}-15T10:00:00`, + excerpt: project.summary, + content: blocks( + `\n
    \n\n

    ${escapeHtml(project.title)}

    \n\n
    \n`, + `\n
    \n\n
    ${paragraph(project.summary)}
    \n\n\n
    ${paragraph(`Role: ${project.role}`)}
    \n\n
    \n`, + heading('Outcome'), + paragraph( + 'This is placeholder project copy. Swap it for a case study describing the problem, your approach, and the result.', + ), + ), + })); + + return [intro, ...projectItems]; +} + +/** Items for the `woocommerce` preset: a set of demo products. */ +function woocommerceItems(): SeedItem[] { + const products: Array<{ + title: string; + slug: string; + price: string; + blurb: string; + }> = [ + { + title: 'Stoneware Pour-Over Set', + slug: 'stoneware-pour-over-set', + price: '48.00', + blurb: + 'A hand-glazed ceramic dripper and matching carafe for a slow, deliberate morning brew.', + }, + { + title: 'Merino Travel Blanket', + slug: 'merino-travel-blanket', + price: '120.00', + blurb: + 'Lightweight, breathable merino wool that packs down small and keeps its shape.', + }, + { + title: 'Brass Desk Lamp', + slug: 'brass-desk-lamp', + price: '95.00', + blurb: 'An adjustable task lamp with a warm dimmable bulb and a solid brass arm.', + }, + { + title: 'Recycled Canvas Tote', + slug: 'recycled-canvas-tote', + price: '32.00', + blurb: + 'A roomy everyday tote sewn from recycled canvas with reinforced shoulder straps.', + }, + { + title: 'Cold Brew Concentrate', + slug: 'cold-brew-concentrate', + price: '18.50', + blurb: + 'Small-batch concentrate steeped for eighteen hours; just add water or milk to taste.', + }, + { + title: 'Linen Apron', + slug: 'linen-apron', + price: '54.00', + blurb: 'A heavyweight linen apron with adjustable straps and a deep front pocket.', + }, + ]; + + return products.map((product, index) => ({ + id: 50 + index, + title: product.title, + slug: product.slug, + postType: 'product', + date: `2024-06-${String(index + 1).padStart(2, '0')}T10:00:00`, + excerpt: product.blurb, + content: blocks( + paragraph(product.blurb), + heading('Details'), + list([ + `Price: $${product.price}`, + 'Ships within 2 to 3 business days.', + 'Free returns within 30 days.', + ]), + paragraph( + 'This is demo product copy. Connect WooCommerce to manage real pricing, inventory, and checkout.', + ), + ), + })); +} + +const PRESET_ITEMS: Record SeedItem[]> = { + blog: blogItems, + portfolio: portfolioItems, + woocommerce: woocommerceItems, +}; + +/** + * Generate a complete, valid WXR 1.2 document for the given preset. + * + * Pure and deterministic: calling it twice with the same preset returns + * byte-for-byte identical output. + */ +export function generateSeedWxr(preset: SeedPreset): string { + const items = PRESET_ITEMS[preset](); + const itemsXml = items.map(renderItem).join('\n'); + + return ` + + + +\t +\t\t${escapeHtml(SITE_TITLE)} +\t\t${SITE_LINK} +\t\t${escapeHtml(SITE_DESCRIPTION)} +\t\t${pubDate('2024-01-01T00:00:00')} +\t\ten-US +\t\t1.2 +\t\t${SITE_LINK} +\t\t${SITE_LINK} +\t\t +\t\t\t1 +\t\t\t${cdata(AUTHOR_LOGIN)} +\t\t\t${cdata(AUTHOR_EMAIL)} +\t\t\t${cdata(AUTHOR_NAME)} +\t\t\t${cdata('')} +\t\t\t${cdata('')} +\t\t +\t\thttps://github.com/kiqr/cli +${itemsXml} +\t + +`; +} diff --git a/tests/lib/seed.test.ts b/tests/lib/seed.test.ts new file mode 100644 index 0000000..1414524 --- /dev/null +++ b/tests/lib/seed.test.ts @@ -0,0 +1,204 @@ +import {describe, expect, it} from 'vitest'; +import {generateSeedWxr, SEED_PRESETS, type SeedPreset} from '../../src/lib/seed.js'; + +/** + * Minimal, dependency-free XML well-formedness checker. + * + * It is not a full parser — it just walks the document, skipping over CDATA + * sections, comments, the XML declaration, and attribute values, then verifies + * that every start tag has a matching end tag in the correct order. That is + * enough to catch the structural mistakes a hand-rolled generator could make + * (unbalanced tags, stray `<`/`>`, broken CDATA). + */ +function assertWellFormed(xml: string): void { + const stack: string[] = []; + let i = 0; + const n = xml.length; + + while (i < n) { + const lt = xml.indexOf('<', i); + if (lt === -1) break; + + // Text between tags must not contain a raw '<' (already handled) — and + // crucially must not contain a raw '>' that would imply a broken tag. + const text = xml.slice(i, lt); + if (text.includes('>')) { + throw new Error(`Unescaped '>' in text content near: ${text.slice(0, 40)}`); + } + + if (xml.startsWith('', lt); + if (end === -1) throw new Error('Unterminated CDATA section'); + i = end + 3; + continue; + } + + if (xml.startsWith('', lt); + if (end === -1) throw new Error('Unterminated comment'); + i = end + 3; + continue; + } + + if (xml.startsWith('', lt); + if (end === -1) throw new Error('Unterminated processing instruction'); + i = end + 2; + continue; + } + + const gt = xml.indexOf('>', lt); + if (gt === -1) throw new Error('Unterminated tag'); + const raw = xml.slice(lt + 1, gt).trim(); + + if (raw.endsWith('/')) { + // self-closing tag — nothing to push + i = gt + 1; + continue; + } + + if (raw.startsWith('/')) { + const name = raw.slice(1).trim(); + const open = stack.pop(); + if (open !== name) { + throw new Error(`Mismatched closing tag (expected )`); + } + i = gt + 1; + continue; + } + + // Start tag: the name is everything up to the first whitespace. + const name = raw.split(/\s/)[0]; + stack.push(name); + i = gt + 1; + } + + if (stack.length !== 0) { + throw new Error(`Unclosed tags: ${stack.join(', ')}`); + } +} + +/** Count occurrences of a substring. */ +function count(haystack: string, needle: string): number { + let total = 0; + let from = 0; + for (;;) { + const idx = haystack.indexOf(needle, from); + if (idx === -1) break; + total += 1; + from = idx + needle.length; + } + return total; +} + +/** Extract the value of every `` element. */ +function postTypes(xml: string): string[] { + const matches = [ + ...xml.matchAll(/<\/wp:post_type>/g), + ]; + return matches.map((m) => m[1]); +} + +const EXPECTED: Record}> = { + blog: {items: 6, types: {post: 5, page: 1}}, + portfolio: {items: 5, types: {page: 5}}, + woocommerce: {items: 6, types: {product: 6}}, +}; + +describe('SEED_PRESETS', () => { + it('exposes the three documented presets', () => { + expect(SEED_PRESETS).toEqual(['blog', 'portfolio', 'woocommerce']); + }); +}); + +describe('generateSeedWxr', () => { + it.each(SEED_PRESETS)('produces well-formed XML for the %s preset', (preset) => { + const xml = generateSeedWxr(preset); + expect(() => assertWellFormed(xml)).not.toThrow(); + }); + + it.each(SEED_PRESETS)('emits a valid WXR 1.2 envelope for %s', (preset) => { + const xml = generateSeedWxr(preset); + expect(xml).toContain(''); + expect(xml).toContain('1.2'); + expect(count(xml, '')).toBe(1); + expect(count(xml, '')).toBe(1); + }); + + it.each(SEED_PRESETS)('contains the expected item count for %s', (preset) => { + const xml = generateSeedWxr(preset); + expect(count(xml, '')).toBe(EXPECTED[preset].items); + expect(count(xml, '')).toBe(EXPECTED[preset].items); + }); + + it.each(SEED_PRESETS)('uses the expected post types for %s', (preset) => { + const xml = generateSeedWxr(preset); + const types = postTypes(xml); + const counts: Record = {}; + for (const t of types) counts[t] = (counts[t] ?? 0) + 1; + expect(counts).toEqual(EXPECTED[preset].types); + }); + + it.each(SEED_PRESETS)('publishes every item for %s', (preset) => { + const xml = generateSeedWxr(preset); + const items = EXPECTED[preset].items; + expect(count(xml, '')).toBe(items); + }); + + it.each(SEED_PRESETS)('wraps content in CDATA for %s', (preset) => { + const xml = generateSeedWxr(preset); + const items = EXPECTED[preset].items; + expect(count(xml, ' { + const xml = generateSeedWxr(preset); + expect(xml).toContain(''); + expect(xml).toContain(''); + }); + + it('renders WooCommerce products with prices in the content', () => { + const xml = generateSeedWxr('woocommerce'); + expect(xml).toContain('Price: $48.00'); + expect(xml).toContain('Price: $18.50'); + }); + + it('includes an About page for the blog preset', () => { + const xml = generateSeedWxr('blog'); + expect(xml).toContain(''); + }); + + it('includes an intro page plus four projects for the portfolio preset', () => { + const xml = generateSeedWxr('portfolio'); + expect(xml).toContain(''); + expect(xml).toContain(''); + }); + + it.each(SEED_PRESETS)('derives dates from fixed literals for %s', (preset) => { + const xml = generateSeedWxr(preset); + // No ISO timestamp with a millisecond/Z suffix, which would betray a + // runtime Date() call leaking into the output. + expect(xml).not.toMatch(/\dT\d{2}:\d{2}:\d{2}\.\d{3}Z/); + expect(xml).toContain('2024-'); + }); + + it.each(SEED_PRESETS)('is byte-for-byte deterministic for %s', (preset) => { + expect(generateSeedWxr(preset)).toBe(generateSeedWxr(preset)); + }); + + it('produces different output per preset', () => { + const blog = generateSeedWxr('blog'); + const portfolio = generateSeedWxr('portfolio'); + const woo = generateSeedWxr('woocommerce'); + expect(blog).not.toBe(portfolio); + expect(portfolio).not.toBe(woo); + expect(blog).not.toBe(woo); + }); +});