diff --git a/README.md b/README.md index 91eceea..0d690bf 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,8 @@ Theme Name: My Theme | Command | Description | |---------|-------------| +| `kiqr scaffold theme ` | Scaffold a new WordPress theme (block by default) | +| `kiqr scaffold theme --type classic` | Scaffold a classic (PHP template) theme | | `kiqr up` | Start the development environment | | `kiqr down` | Stop the development environment | | `kiqr restart` | Restart the development environment | diff --git a/src/commands/index.tsx b/src/commands/index.tsx index 7476da3..280fc0c 100644 --- a/src/commands/index.tsx +++ b/src/commands/index.tsx @@ -13,6 +13,10 @@ export default function Index() { {' '} kiqr doctor Check your environment for common problems + + {' '} + kiqr scaffold Generate a new WordPress theme + {' '} kiqr init Initialize a new project diff --git a/src/commands/scaffold/index.tsx b/src/commands/scaffold/index.tsx new file mode 100644 index 0000000..9a89957 --- /dev/null +++ b/src/commands/scaffold/index.tsx @@ -0,0 +1,23 @@ +import {Box, Text} from 'ink'; + +export const description = 'Scaffold a new WordPress theme'; + +export default function ScaffoldIndex() { + return ( + + Kiqr Scaffold + Generate a production-ready WordPress theme + + Commands: + + {' '} + kiqr scaffold theme <name> Create a new theme directory + + + + Use --type block (default) for a Full Site Editing theme, or{' '} + --type classic for a classic theme. + + + ); +} diff --git a/src/commands/scaffold/theme.tsx b/src/commands/scaffold/theme.tsx new file mode 100644 index 0000000..4b6e472 --- /dev/null +++ b/src/commands/scaffold/theme.tsx @@ -0,0 +1,123 @@ +import path from 'node:path'; +import {Box, Text, useApp} from 'ink'; +import {argument, option} from 'pastel'; +import {useEffect, useState} from 'react'; +import zod from 'zod'; +import {generateTheme, writeTheme} from '../../lib/scaffold.js'; +import {slugify} from '../../lib/theme.js'; + +export const description = 'Scaffold a new WordPress theme in a new directory'; + +export const args = zod.tuple([ + zod.string().describe( + argument({ + name: 'name', + description: 'Human-readable theme name (e.g. "My Cool Theme")', + }), + ), +]); + +export const options = zod.object({ + type: zod + .enum(['block', 'classic']) + .default('block') + .describe( + option({ + description: 'Theme type: "block" (Full Site Editing) or "classic"', + alias: 't', + }), + ), + author: zod + .string() + .optional() + .describe(option({description: 'Theme author name'})), +}); + +type Props = { + args: zod.infer; + options: zod.infer; +}; + +export default function ScaffoldTheme({args, options}: Props) { + const {exit} = useApp(); + const [error, setError] = useState(null); + const [result, setResult] = useState<{ + slug: string; + targetDir: string; + files: string[]; + type: 'block' | 'classic'; + } | null>(null); + + useEffect(() => { + const name = args[0].trim(); + if (!name) { + setError('Theme name cannot be empty.'); + setTimeout(() => exit(new Error()), 100); + return; + } + + const slug = slugify(name); + if (!slug) { + setError( + `Could not derive a valid theme slug from "${name}". Use letters or numbers.`, + ); + setTimeout(() => exit(new Error()), 100); + return; + } + + const targetDir = path.join(process.cwd(), slug); + + try { + const files = generateTheme({ + name, + slug, + type: options.type, + author: options.author, + }); + writeTheme(targetDir, files); + setResult({ + slug, + targetDir, + files: Object.keys(files).sort(), + type: options.type, + }); + setTimeout(() => exit(), 100); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + setTimeout(() => exit(new Error()), 100); + } + }, []); + + if (error) return {error}; + if (!result) return Scaffolding theme...; + + return ( + + + Created {result.type} theme "{args[0]}"! + + + + Location: {result.slug}/ + + + {result.files.map((file) => ( + + {' '} + {result.slug}/{file} + + ))} + + + Next steps: + + {' '} + cd {result.slug} + + + {' '} + kiqr init && kiqr up + + + ); +} diff --git a/src/lib/scaffold.ts b/src/lib/scaffold.ts new file mode 100644 index 0000000..33671ed --- /dev/null +++ b/src/lib/scaffold.ts @@ -0,0 +1,669 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export type ThemeType = 'block' | 'classic'; + +export interface ScaffoldOptions { + name: string; + slug: string; + type: ThemeType; + author?: string; + description?: string; +} + +const THEME_VERSION = '0.1.0'; +const REQUIRES_WP = '6.5'; +const TESTED_WP = '6.8'; +const REQUIRES_PHP = '8.0'; + +function styleHeader(opts: ScaffoldOptions): string { + const author = opts.author ?? 'Your Name'; + const description = + opts.description ?? + `A ${opts.type === 'block' ? 'block (Full Site Editing)' : 'classic'} WordPress theme scaffolded with Kiqr.`; + return `/* +Theme Name: ${opts.name} +Theme URI: https://example.com/themes/${opts.slug} +Author: ${author} +Author URI: https://example.com +Description: ${description} +Version: ${THEME_VERSION} +Requires at least: ${REQUIRES_WP} +Tested up to: ${TESTED_WP} +Requires PHP: ${REQUIRES_PHP} +License: GNU General Public License v2 or later +License URI: http://www.gnu.org/licenses/gpl-2.0.html +Text Domain: ${opts.slug} +*/ +`; +} + +function readme(opts: ScaffoldOptions): string { + const author = opts.author ?? 'Your Name'; + const description = + opts.description ?? + `A ${opts.type === 'block' ? 'block (Full Site Editing)' : 'classic'} WordPress theme scaffolded with Kiqr.`; + return `=== ${opts.name} === +Contributors: ${slugContributor(author)} +Requires at least: ${REQUIRES_WP} +Tested up to: ${TESTED_WP} +Requires PHP: ${REQUIRES_PHP} +Stable tag: ${THEME_VERSION} +License: GPLv2 or later +License URI: http://www.gnu.org/licenses/gpl-2.0.html + +${description} + +== Description == + +${description} + +== Changelog == + += ${THEME_VERSION} = +* Initial release. +`; +} + +function slugContributor(author: string): string { + return author + .toLowerCase() + .replace(/[^a-z0-9]+/g, '') + .slice(0, 30); +} + +function gitignore(): string { + return `# Dependencies +node_modules/ +vendor/ + +# Build output +build/ +dist/ + +# OS / editor +.DS_Store +Thumbs.db +*.log + +# Kiqr local runtime config +.kiqr/ +`; +} + +function themeJson(): string { + const data = { + $schema: 'https://schemas.wp.org/trunk/theme.json', + version: 3, + settings: { + appearanceTools: true, + layout: { + contentSize: '640px', + wideSize: '1200px', + }, + color: { + defaultPalette: false, + defaultGradients: false, + palette: [ + {slug: 'base', color: '#ffffff', name: 'Base'}, + {slug: 'contrast', color: '#111111', name: 'Contrast'}, + {slug: 'primary', color: '#3858e9', name: 'Primary'}, + {slug: 'secondary', color: '#f0f0f0', name: 'Secondary'}, + {slug: 'accent', color: '#e26d5c', name: 'Accent'}, + ], + }, + typography: { + fluid: true, + fontFamilies: [ + { + slug: 'system', + name: 'System Sans', + fontFamily: + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif', + }, + { + slug: 'serif', + name: 'Serif', + fontFamily: 'Georgia, "Times New Roman", serif', + }, + { + slug: 'mono', + name: 'Monospace', + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', + }, + ], + fontSizes: [ + { + slug: 'small', + name: 'Small', + size: '0.875rem', + fluid: {min: '0.8125rem', max: '0.875rem'}, + }, + { + slug: 'medium', + name: 'Medium', + size: '1rem', + fluid: {min: '1rem', max: '1.125rem'}, + }, + { + slug: 'large', + name: 'Large', + size: '1.5rem', + fluid: {min: '1.25rem', max: '1.5rem'}, + }, + { + slug: 'x-large', + name: 'Extra Large', + size: '2.25rem', + fluid: {min: '1.75rem', max: '2.25rem'}, + }, + { + slug: 'xx-large', + name: 'Huge', + size: '3.5rem', + fluid: {min: '2.5rem', max: '3.5rem'}, + }, + ], + }, + spacing: { + units: ['px', 'em', 'rem', '%', 'vw', 'vh'], + spacingScale: { + operator: '*', + increment: 1.5, + steps: 7, + mediumStep: 1.5, + unit: 'rem', + }, + }, + useRootPaddingAwareAlignments: true, + }, + styles: { + color: { + background: 'var(--wp--preset--color--base)', + text: 'var(--wp--preset--color--contrast)', + }, + typography: { + fontFamily: 'var(--wp--preset--font-family--system)', + fontSize: 'var(--wp--preset--font-size--medium)', + lineHeight: '1.6', + }, + spacing: { + padding: { + top: '0', + right: 'var(--wp--preset--spacing--50)', + bottom: '0', + left: 'var(--wp--preset--spacing--50)', + }, + }, + elements: { + link: { + color: {text: 'var(--wp--preset--color--primary)'}, + }, + heading: { + typography: { + fontFamily: 'var(--wp--preset--font-family--system)', + fontWeight: '700', + lineHeight: '1.2', + }, + }, + button: { + color: { + background: 'var(--wp--preset--color--primary)', + text: 'var(--wp--preset--color--base)', + }, + spacing: { + padding: { + top: '0.6rem', + right: '1.2rem', + bottom: '0.6rem', + left: '1.2rem', + }, + }, + }, + }, + }, + templateParts: [ + {name: 'header', title: 'Header', area: 'header'}, + {name: 'footer', title: 'Footer', area: 'footer'}, + ], + }; + return `${JSON.stringify(data, null, 2)}\n`; +} + +function blockStyleCss(opts: ScaffoldOptions): string { + return `${styleHeader(opts)} +/* + * This is a block (Full Site Editing) theme. Most styling is handled by + * theme.json. Add any additional global CSS below. + */ + +body { + margin: 0; +} +`; +} + +function blockFunctionsPhp(opts: ScaffoldOptions): string { + const fn = phpPrefix(opts.slug); + return `get('Version') + ); + } +} +add_action('wp_enqueue_scripts', '${fn}_enqueue_assets'); +`; +} + +function phpPrefix(slug: string): string { + return slug.replace(/-/g, '_'); +} + +function headerHtml(): string { + return ` +
+\t +\t
+\t\t + +\t\t +\t
+\t +
+ +`; +} + +function footerHtml(opts: ScaffoldOptions): string { + return ` +
+\t +\t

© — Powered by WordPress & ${opts.name}.

+\t +
+ +`; +} + +function indexHtml(): string { + return ` + + +
+\t +\t
+\t\t +\t\t\t +\t\t\t +\t\t + +\t\t +\t\t\t +\t\t\t +\t\t\t +\t\t + +\t\t +\t\t\t +\t\t\t

No posts found.

+\t\t\t +\t\t +\t
+\t +
+ + + +`; +} + +function singleHtml(): string { + return ` + + +
+\t +\t +\t +\t + +\t +\t +\t + +\t +\t
+\t\t +\t\t\t +\t\t\t +\t\t\t +\t\t +\t\t +\t
+\t +
+ + + +`; +} + +function pageHtml(): string { + return ` + + +
+\t +\t +\t +
+ + + +`; +} + +function heroPattern(opts: ScaffoldOptions): string { + return ` + +
+\t +\t

+\t + +\t +\t

+\t + +\t +\t
+\t\t +\t\t
+\t\t +\t
+\t +
+ +`; +} + +function generateBlockTheme(opts: ScaffoldOptions): Record { + return { + 'style.css': blockStyleCss(opts), + 'theme.json': themeJson(), + 'functions.php': blockFunctionsPhp(opts), + 'readme.txt': readme(opts), + '.gitignore': gitignore(), + 'templates/index.html': indexHtml(), + 'templates/single.html': singleHtml(), + 'templates/page.html': pageHtml(), + 'parts/header.html': headerHtml(), + 'parts/footer.html': footerHtml(opts), + 'patterns/hero.php': heroPattern(opts), + }; +} + +function classicStyleCss(opts: ScaffoldOptions): string { + return `${styleHeader(opts)} +body { + margin: 0; + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, + sans-serif; + line-height: 1.6; + color: #111; +} + +.site-header, +.site-footer { + padding: 1.5rem; +} + +.site-content { + max-width: 640px; + margin: 0 auto; + padding: 1.5rem; +} + +a { + color: #3858e9; +} +`; +} + +function classicFunctionsPhp(opts: ScaffoldOptions): string { + const fn = phpPrefix(opts.slug); + return ` __('Primary Menu', '${opts.slug}'), + ]); + load_theme_textdomain('${opts.slug}', get_template_directory() . '/languages'); + } +} +add_action('after_setup_theme', '${fn}_setup'); + +if (!function_exists('${fn}_enqueue_assets')) { + /** + * Enqueue the theme stylesheet. + */ + function ${fn}_enqueue_assets(): void { + wp_enqueue_style( + '${opts.slug}-style', + get_stylesheet_uri(), + [], + wp_get_theme()->get('Version') + ); + } +} +add_action('wp_enqueue_scripts', '${fn}_enqueue_assets'); +`; +} + +function classicHeaderPhp(opts: ScaffoldOptions): string { + return ` + +> + +\t +\t +\t + +> + + +
+`; +} + +function classicFooterPhp(opts: ScaffoldOptions): string { + return ` +
+
+\t

© — Powered by WordPress & ${opts.name}.

+
+ + + +`; +} + +function classicIndexPhp(opts: ScaffoldOptions): string { + return ` + +
+\t +\t\t +\t\t\t
> +\t\t\t\t

+\t\t\t\t\t +\t\t\t\t

+\t\t\t\t
+\t\t\t\t\t +\t\t\t\t
+\t\t\t
+\t\t + +\t\t +\t +\t\t

+\t +
+ + { + return { + 'style.css': classicStyleCss(opts), + 'index.php': classicIndexPhp(opts), + 'functions.php': classicFunctionsPhp(opts), + 'header.php': classicHeaderPhp(opts), + 'footer.php': classicFooterPhp(opts), + 'readme.txt': readme(opts), + '.gitignore': gitignore(), + }; +} + +/** + * Generate the complete file map for a WordPress theme. + * + * Pure function: returns a map of `{ relativeFilePath: fileContents }`. + * It does not touch the filesystem so it can be unit tested. + */ +export function generateTheme(opts: ScaffoldOptions): Record { + return opts.type === 'classic' ? generateClassicTheme(opts) : generateBlockTheme(opts); +} + +function isNonEmptyDir(dir: string): boolean { + if (!fs.existsSync(dir)) return false; + if (!fs.statSync(dir).isDirectory()) return true; + return fs.readdirSync(dir).length > 0; +} + +/** + * Write a generated theme file map to disk. + * + * Creates nested directories as needed. Refuses to write if `targetDir` + * already exists and is non-empty, to avoid clobbering existing files. + */ +export function writeTheme(targetDir: string, files: Record): void { + if (isNonEmptyDir(targetDir)) { + throw new Error( + `Directory "${targetDir}" already exists and is not empty. Choose a different name or remove it first.`, + ); + } + + fs.mkdirSync(targetDir, {recursive: true}); + + for (const [relativePath, contents] of Object.entries(files)) { + const fullPath = path.join(targetDir, relativePath); + fs.mkdirSync(path.dirname(fullPath), {recursive: true}); + fs.writeFileSync(fullPath, contents, 'utf-8'); + } +} diff --git a/tests/lib/scaffold.test.ts b/tests/lib/scaffold.test.ts new file mode 100644 index 0000000..f3150ec --- /dev/null +++ b/tests/lib/scaffold.test.ts @@ -0,0 +1,202 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import {afterEach, beforeEach, describe, expect, it} from 'vitest'; +import {generateTheme, type ScaffoldOptions, writeTheme} from '../../src/lib/scaffold.js'; +import {slugify} from '../../src/lib/theme.js'; + +function blockOpts(overrides: Partial = {}): ScaffoldOptions { + return { + name: 'My Cool Theme', + slug: 'my-cool-theme', + type: 'block', + ...overrides, + }; +} + +function classicOpts(overrides: Partial = {}): ScaffoldOptions { + return { + name: 'My Cool Theme', + slug: 'my-cool-theme', + type: 'classic', + ...overrides, + }; +} + +describe('slug handling', () => { + it('slugifies a human-readable name', () => { + expect(slugify('My Cool Theme')).toBe('my-cool-theme'); + }); +}); + +describe('generateTheme (block)', () => { + const files = generateTheme(blockOpts()); + + it('returns the expected block file set', () => { + expect(Object.keys(files).sort()).toEqual( + [ + '.gitignore', + 'functions.php', + 'parts/footer.html', + 'parts/header.html', + 'patterns/hero.php', + 'readme.txt', + 'style.css', + 'templates/index.html', + 'templates/page.html', + 'templates/single.html', + 'theme.json', + ].sort(), + ); + }); + + it('style.css contains the theme header and slug as Text Domain', () => { + const css = files['style.css']; + expect(css).toContain('Theme Name: My Cool Theme'); + expect(css).toContain('Text Domain: my-cool-theme'); + expect(css).toContain('Version: 0.1.0'); + expect(css).toContain('Requires at least: 6.5'); + expect(css).toContain('Requires PHP: 8.0'); + }); + + it('theme.json is valid JSON with version 3, a palette and a schema', () => { + const parsed = JSON.parse(files['theme.json']!); + expect(parsed.version).toBe(3); + expect(parsed.$schema).toBe('https://schemas.wp.org/trunk/theme.json'); + expect(Array.isArray(parsed.settings.color.palette)).toBe(true); + expect(parsed.settings.color.palette.length).toBeGreaterThan(0); + expect(parsed.settings.appearanceTools).toBe(true); + expect(Array.isArray(parsed.settings.typography.fontFamilies)).toBe(true); + expect(Array.isArray(parsed.settings.typography.fontSizes)).toBe(true); + expect(parsed.settings.layout.contentSize).toBeTruthy(); + expect(parsed.settings.layout.wideSize).toBeTruthy(); + expect(parsed.styles).toBeTruthy(); + }); + + it('templates reference template parts', () => { + for (const tpl of [ + 'templates/index.html', + 'templates/single.html', + 'templates/page.html', + ]) { + expect(files[tpl]).toContain('wp:template-part'); + expect(files[tpl]).toContain('"slug":"header"'); + expect(files[tpl]).toContain('"slug":"footer"'); + } + expect(files['templates/index.html']).toContain('wp:query'); + expect(files['templates/single.html']).toContain('wp:post-content'); + }); + + it('parts contain real block markup', () => { + expect(files['parts/header.html']).toContain('wp:site-title'); + expect(files['parts/header.html']).toContain('wp:navigation'); + expect(files['parts/footer.html']).toContain('wp:site-title'); + }); + + it('hero pattern has a valid pattern header', () => { + const pattern = files['patterns/hero.php']!; + expect(pattern).toContain('Title: Hero'); + expect(pattern).toContain('Slug: my-cool-theme/hero'); + expect(pattern).toContain('Categories:'); + expect(pattern).toContain('wp:button'); + }); + + it('functions.php is present and uses the slug as text domain', () => { + const fns = files['functions.php']!; + expect(fns).toContain(' { + const custom = generateTheme( + blockOpts({author: 'Jane Dev', description: 'A bespoke theme.'}), + ); + expect(custom['style.css']).toContain('Author: Jane Dev'); + expect(custom['style.css']).toContain('Description: A bespoke theme.'); + }); +}); + +describe('generateTheme (classic)', () => { + const files = generateTheme(classicOpts()); + + it('returns the expected classic file set', () => { + expect(Object.keys(files).sort()).toEqual( + [ + '.gitignore', + 'footer.php', + 'functions.php', + 'header.php', + 'index.php', + 'readme.txt', + 'style.css', + ].sort(), + ); + }); + + it('includes index.php, header.php and footer.php with markup', () => { + expect(files['index.php']).toContain('get_header()'); + expect(files['index.php']).toContain('have_posts()'); + expect(files['header.php']).toContain('wp_head()'); + expect(files['header.php']).toContain(''); + expect(files['footer.php']).toContain('wp_footer()'); + }); + + it('functions.php enqueues styles and adds theme support', () => { + const fns = files['functions.php']!; + expect(fns).toContain('wp_enqueue_style'); + expect(fns).toContain("add_theme_support('title-tag')"); + expect(fns).toContain("load_theme_textdomain('my-cool-theme'"); + }); + + it('style.css contains the theme header', () => { + expect(files['style.css']).toContain('Theme Name: My Cool Theme'); + expect(files['style.css']).toContain('Text Domain: my-cool-theme'); + }); +}); + +describe('writeTheme', () => { + let tmpRoot: string; + + beforeEach(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'kiqr-scaffold-')); + }); + + afterEach(() => { + fs.rmSync(tmpRoot, {recursive: true, force: true}); + }); + + it('writes all files (including nested dirs) to disk', () => { + const files = generateTheme(blockOpts()); + const target = path.join(tmpRoot, 'my-cool-theme'); + writeTheme(target, files); + + for (const rel of Object.keys(files)) { + const full = path.join(target, rel); + expect(fs.existsSync(full)).toBe(true); + expect(fs.readFileSync(full, 'utf-8')).toBe(files[rel]); + } + expect(fs.existsSync(path.join(target, 'templates', 'index.html'))).toBe(true); + expect(fs.existsSync(path.join(target, 'parts', 'header.html'))).toBe(true); + }); + + it('writes into an existing empty directory', () => { + const target = path.join(tmpRoot, 'empty-theme'); + fs.mkdirSync(target); + const files = generateTheme(classicOpts()); + expect(() => writeTheme(target, files)).not.toThrow(); + expect(fs.existsSync(path.join(target, 'index.php'))).toBe(true); + }); + + it('refuses to clobber a non-empty existing directory', () => { + const target = path.join(tmpRoot, 'occupied'); + fs.mkdirSync(target); + fs.writeFileSync(path.join(target, 'keep.txt'), 'do not delete'); + + const files = generateTheme(blockOpts()); + expect(() => writeTheme(target, files)).toThrow(/already exists/i); + // existing content is untouched + expect(fs.readFileSync(path.join(target, 'keep.txt'), 'utf-8')).toBe('do not delete'); + }); +});