Initial: pi-skill — 68 skills, 43 extensions, 11 themes for Pi

This commit is contained in:
Kunthawat Greethong
2026-05-25 16:38:02 +07:00
commit 69f7d8bdda
1689 changed files with 342427 additions and 0 deletions

View File

@@ -0,0 +1,83 @@
---
name: wordpress-theme-to-emdash
description: Port WordPress themes to EmDash CMS. Use when asked to convert, migrate, or port a WordPress theme to EmDash, or when creating an EmDash site that should match an existing WordPress site's design. Handles design extraction, template conversion, and EmDash-specific features like menus, taxonomies, and widgets.
---
# WordPress Theme to EmDash
Port WordPress themes to EmDash in six phases. **Read the phase file before starting each phase.**
## Critical Rules
1. **Copy scaffold first** - Start every theme by copying `scaffold/` from this skill
2. **Take screenshots of demo** - Identify the demo URL and capture all page types using agent-browser before starting work
3. **No hard-coded content** - Use `getSiteSettings()` for title/tagline, `getMenu()` for navigation
4. **Server-rendered pages** - Never use `getStaticPaths()` for EmDash content
5. **Astro 6** - Use `ClientRouter` not `ViewTransitions`, Zod 4 syntax, Node 22+
6. **Use emdash Image component** - For all images, import Image from "emdash/ui"
## Phases
| Phase | File | Summary |
| ----- | ----------------------- | ----------------------------------------------- |
| 1 | `phases/1-discovery.md` | Download theme, screenshot demo, capture images |
| 2 | `phases/2-design.md` | Extract CSS variables, fonts, colors |
| 3 | `phases/3-templates.md` | Convert PHP templates to Astro |
| 4 | `phases/4-dynamic.md` | Site settings, menus, taxonomies, widgets |
| 5 | `phases/5-seed.md` | Create seed file with demo content |
| 6 | `phases/6-verify.md` | Screenshot, compare, iterate, build |
## Checklist
### Setup
- [ ] Copy `scaffold/` to new theme directory. Unless otherwise specified by the user, make this a subdirectory of `themes/` and name it after the WordPress theme (e.g., `themes/twentytwentyfour/`).
- [ ] Rename folder, update `package.json`
- [ ] Verify build: `pnpm build`
### Phase 1: Discovery
- [ ] Theme source downloaded
- [ ] Demo site identified
- [ ] `discovery/` folder created with `screenshots/`, `images/`, `notes.md`
- [ ] All page types screenshotted
- [ ] Sample images downloaded
### Phase 2: Design
- [ ] CSS variables in `global.css`
- [ ] Fonts loading
- [ ] Colors match demo
### Phase 3: Templates
- [ ] Homepage, single post, archive, category, tag, page, 404
- [ ] Components extracted (PostCard, etc.)
### Phase 4: Dynamic
- [ ] Site settings (title, tagline, logo from CMS)
- [ ] Navigation menus (from CMS, not hard-coded)
- [ ] Taxonomies
- [ ] Widget areas (if applicable)
### Phase 5: Seed
- [ ] Seed file created with demo images at `.emdash/seed.json` (or wired up via `package.json#emdash.seed`)
### Phase 6: Verify
- [ ] Dev server applied seed cleanly on first request (no validation errors in logs)
- [ ] Output screenshots captured
- [ ] Visual comparison done
- [ ] Build succeeds: `pnpm build`
- [ ] LICENSE file downloaded (GPL-2.0 in most cases)
- [ ] README credits original theme
## Reference Documents
- `references/astro-essentials.md` - Astro 6 patterns
- `references/template-patterns.md` - PHP → Astro conversion
- `references/concept-mapping.md` - WP → EmDash concepts
- `references/emdash-api.md` - Full API reference
- `references/design-extraction.md` - CSS extraction techniques

View File

@@ -0,0 +1,149 @@
# Phase 1: Discovery & Reference Capture
Before writing any code, gather comprehensive reference materials from the demo site.
## 1.0 Create Discovery Folder
Create a `discovery/` folder in your theme directory to store all reference materials:
```
discovery/
├── screenshots/ # Reference screenshots from demo site
│ ├── homepage.png
│ ├── single-post.png
│ ├── archive.png
│ ├── category.png
│ ├── page.png
│ └── 404.png
├── images/ # Sample images downloaded for seed content
│ ├── featured-1.jpg
│ ├── featured-2.jpg
│ └── hero.jpg
└── notes.md # Design decisions and observations
```
The `notes.md` file should capture:
- Color values extracted from the demo
- Font families and sizes observed
- Layout patterns (header style, sidebar position, footer columns)
- Special components or interactions to recreate
- Anything that might be forgotten between sessions
## 1.1 Identify All Page Types
Identify the URL of the demo site for the WordPress theme you are converting. For wordpress.org themes, this is usually wp-themes.com/theme-name/. For other themes, use the "Live Preview" link. This may show it inside a frame; if so, ignore the frame and focus on the theme's actual content.
Use the agent-browser to explore the demo site to find every distinct page type:
- **Homepage** - Often has unique layout (hero, featured posts, etc.)
- **Blog/Archive** - Post listing page
- **Single Post** - Individual blog post with content
- **Page** - Static page (About, Contact, etc.)
- **Category/Tag Archive** - Taxonomy listing pages
- **Search Results** - If the theme has custom search styling
- **404 Page** - Error page styling
Use agent-browser to navigate the demo and discover pages:
```bash
agent-browser open https://demo-site.com
# Click around to find different page types
# Check the navigation menu for page links
# Look for "View all posts" or category links
```
## 1.2 Screenshot All Page Types
Capture full-page screenshots of each page type to `discovery/screenshots/`:
```bash
# Homepage
agent-browser open https://demo-site.com
agent-browser screenshot discovery/screenshots/homepage.png --full
# Single post (find a post with featured image and good content)
agent-browser open https://demo-site.com/sample-post/
agent-browser screenshot discovery/screenshots/single-post.png --full
# Blog archive
agent-browser open https://demo-site.com/blog/
agent-browser screenshot discovery/screenshots/archive.png --full
# Category page
agent-browser open https://demo-site.com/category/news/
agent-browser screenshot discovery/screenshots/category.png --full
# Static page
agent-browser open https://demo-site.com/about/
agent-browser screenshot discovery/screenshots/page.png --full
# 404 page
agent-browser open https://demo-site.com/nonexistent-page-xyz/
agent-browser screenshot discovery/screenshots/404.png --full
```
## 1.3 Download Sample Images
If the theme is open source (GPL), download sample images from the demo to `discovery/images/`. This ensures visual consistency when comparing.
```bash
# Find featured images in demo posts
agent-browser eval "Array.from(document.querySelectorAll('article img')).map(i => i.src)"
# Download images for seed content
curl -o discovery/images/featured-1.jpg "https://demo-site.com/wp-content/uploads/photo1.jpg"
curl -o discovery/images/featured-2.jpg "https://demo-site.com/wp-content/uploads/photo2.jpg"
```
For premium themes or when images aren't freely available, use Unsplash images that match the demo's visual style (same aspect ratios, similar subjects).
## 1.4 Document Page Structure
For each page type, document observations in `discovery/notes.md`:
- Header style (sticky? transparent? logo position?)
- Sidebar presence and position
- Footer layout (columns? widgets?)
- Special components (hero sections, CTAs, etc.)
- Color values (use browser DevTools color picker)
- Font families and sizes
- Spacing patterns
This inventory guides which templates and components you need to build, and preserves details that might be forgotten between sessions.
## Theme Source Discovery
### WordPress.org Themes
For themes on wordpress.org (e.g., `https://wordpress.org/themes/theme-name/`):
1. **Demo/Preview**: Click "Preview" button or visit `https://wp-themes.com/theme-name/`
2. **Source Download**: The "Download" button provides a ZIP, or use:
```bash
curl -O https://downloads.wordpress.org/theme/theme-name.zip
unzip theme-name.zip
```
3. **Theme Info**: The page includes author, version, tags, and description
### GitHub-Hosted Themes
1. **Source**: Clone or download the repository
2. **Demo**: Check README for demo URL, or look for `Demo:` in theme description
3. **Documentation**: Usually in README or `/docs` folder
### ThemeForest / Premium Themes
1. **Demo**: Use the "Live Preview" button on the product page
2. **Source**: Requires purchase - ask the user to provide the unzipped theme files
3. **Documentation**: Usually included in the download or linked from the product page
### Auto-Discovery
When given only a theme URL or name, derive URLs yourself:
1. Fetch the listing page to extract demo URL, download URL, and theme info
2. Download the source (if freely available)
3. Open the demo in agent-browser
Don't ask the user for URLs you can derive yourself.

View File

@@ -0,0 +1,122 @@
# Phase 2: Design Extraction
Extract design tokens from the WordPress theme source and live demo.
## 2.1 Analyze the Live Site
Use `agent-browser` to extract computed styles:
```bash
agent-browser eval "(() => {
const body = getComputedStyle(document.body);
const header = document.querySelector('header, .site-header');
return JSON.stringify({
body: {
fontFamily: body.fontFamily,
fontSize: body.fontSize,
color: body.color,
background: body.backgroundColor,
},
header: header ? {
background: getComputedStyle(header).backgroundColor,
height: getComputedStyle(header).height,
} : null,
}, null, 2);
})()"
```
## 2.2 Extract Design Tokens
Read the theme's CSS files. Look for:
```
style.css # Main stylesheet (has theme header)
assets/css/ # Additional stylesheets
theme.json # Block themes (WP 5.9+) - structured design tokens
```
### CSS Variable Mapping
| WP Pattern | EmDash Variable |
| ---------------- | ------------------ |
| Body font family | `--font-body` |
| Heading font | `--font-heading` |
| Primary color | `--color-primary` |
| Background | `--color-base` |
| Text color | `--color-contrast` |
| Content width | `--content-width` |
### Block Theme (theme.json)
Block themes store design tokens in `theme.json`:
```json
{
"settings": {
"color": {
"palette": [{ "slug": "primary", "color": "#0073aa", "name": "Primary" }]
},
"typography": {
"fontFamilies": [{ "fontFamily": "'Open Sans', sans-serif", "slug": "body" }]
},
"layout": {
"contentSize": "650px",
"wideSize": "1200px"
}
}
}
```
## 2.3 Create Base Layout
Create `src/layouts/Base.astro` with:
- Extracted CSS variables in `:root`
- Header/footer structure matching WP theme
- Font loading (Google Fonts or local)
- Responsive breakpoints
### CSS Variables Template
```css
:root {
/* Colors */
--color-base: #ffffff;
--color-contrast: #1a1a1a;
--color-primary: #0073aa;
--color-accent: #ff6b35;
--color-muted: #6b7280;
--color-border: #e5e7eb;
/* Typography */
--font-body: system-ui, sans-serif;
--font-heading: Georgia, serif;
/* Font sizes */
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
--text-3xl: 1.875rem;
--text-4xl: 2.25rem;
--text-5xl: clamp(2.5rem, 5vw, 3rem);
/* Spacing */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-4: 1rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-12: 3rem;
--space-16: 4rem;
--space-24: 6rem;
/* Layout */
--content-width: 720px;
--wide-width: 1200px;
--header-height: 80px;
}
```
See `references/design-extraction.md` for detailed extraction techniques.

View File

@@ -0,0 +1,114 @@
# Phase 3: Template Conversion
Convert WordPress PHP templates to Astro components.
## 3.1 Analyze Theme Structure
Read `functions.php` to identify:
- `register_nav_menu()` → EmDash menus
- `register_sidebar()` → EmDash widget areas
- `add_theme_support()` → Features (thumbnails, formats, etc.)
- `register_post_type()` → Collections
- `register_taxonomy()` → EmDash taxonomy defs
- `add_shortcode()` → Portable Text blocks
## 3.2 Template Mapping
| WP Template | Astro Route |
| -------------- | ----------------------------------- |
| `index.php` | `src/pages/index.astro` |
| `single.php` | `src/pages/posts/[slug].astro` |
| `page.php` | `src/pages/pages/[slug].astro` |
| `archive.php` | `src/pages/posts/index.astro` |
| `category.php` | `src/pages/categories/[slug].astro` |
| `tag.php` | `src/pages/tags/[slug].astro` |
| `search.php` | `src/pages/search.astro` |
| `404.php` | `src/pages/404.astro` |
| `header.php` | Component in layout |
| `footer.php` | Component in layout |
## 3.3 Convert Templates
### The Loop → getEmDashCollection
```php
// WordPress
<?php while (have_posts()) : the_post(); ?>
<h2><?php the_title(); ?></h2>
<?php endwhile; ?>
```
```astro
---
// Astro/EmDash
import { getEmDashCollection } from "emdash";
const { entries: posts } = await getEmDashCollection("posts");
---
{posts.map(post => <h2>{post.data.title}</h2>)}
```
### Single Post → getEmDashEntry
```php
// WordPress
<?php the_content(); ?>
```
```astro
---
// Astro/EmDash
import { getEmDashEntry } from "emdash";
import { PortableText } from "emdash/ui";
const { entry: post } = await getEmDashEntry("posts", Astro.params.slug);
---
{post && <PortableText value={post.data.content} />}
```
## 3.4 Page Templates
WordPress themes often register page templates (Full Width, Sidebar, Landing Page, etc.). In EmDash, this is a `select` field on the pages collection:
1. Add a `template` select field to the pages collection with the theme's template names as options (e.g. "Default", "Full Width", "Landing Page")
2. Create an Astro layout component for each template in `src/layouts/`
3. Map the field value to a layout component in the page route:
```astro
---
// src/pages/pages/[slug].astro
import { getEmDashEntry } from "emdash";
import PageDefault from "../../layouts/PageDefault.astro";
import PageFullWidth from "../../layouts/PageFullWidth.astro";
const { slug } = Astro.params;
const { entry: page } = await getEmDashEntry("pages", slug!);
if (!page) return Astro.redirect("/404");
const layouts = {
"Default": PageDefault,
"Full Width": PageFullWidth,
};
const Layout = layouts[page.data.template as keyof typeof layouts] ?? PageDefault;
---
<Layout page={page} />
```
Use human-readable option names (matching what the WP theme displayed) since these appear in the admin dropdown.
## Important: Server-Rendered Pages
**Never use `getStaticPaths()` or `export const prerender = true` for EmDash content pages.** Content changes at runtime, so pages must be server-rendered.
```astro
---
// CORRECT - server-rendered
const { slug } = Astro.params;
const { entry: post } = await getEmDashEntry("posts", slug!);
if (!post) {
return Astro.redirect("/404");
}
---
```
See `references/template-patterns.md` for more conversion patterns.

View File

@@ -0,0 +1,147 @@
# Phase 4: Dynamic Features
Implement CMS-driven features: site settings, menus, taxonomies, and widgets.
## 4.1 Site Settings
Map WordPress customizer values to EmDash site settings:
| WP Customizer Setting | EmDash Site Setting |
| --------------------- | ------------------- |
| Site Title | `title` |
| Tagline | `tagline` |
| Site Icon | `favicon` |
| Custom Logo | `logo` |
| Posts per page | `postsPerPage` |
| Date format | `dateFormat` |
```astro
---
import { getSiteSettings } from "emdash";
const settings = await getSiteSettings();
---
<header>
{settings.logo ? (
<img src={settings.logo.url} alt={settings.title} />
) : (
<span class="site-title">{settings.title}</span>
)}
{settings.tagline && <p class="tagline">{settings.tagline}</p>}
</header>
```
## 4.2 Navigation Menus
Identify menus in `functions.php`:
```php
register_nav_menus([
'primary' => 'Primary Navigation',
'footer' => 'Footer Links',
]);
```
Use in templates:
```astro
---
import { getMenu } from "emdash";
const primaryNav = await getMenu("primary");
---
<nav class="primary-nav">
{primaryNav && (
<ul>
{primaryNav.items.map(item => (
<li>
<a href={item.url} aria-current={Astro.url.pathname === item.url ? 'page' : undefined}>
{item.label}
</a>
{item.children.length > 0 && (
<ul class="submenu">
{item.children.map(child => (
<li><a href={child.url}>{child.label}</a></li>
))}
</ul>
)}
</li>
))}
</ul>
)}
</nav>
```
## 4.3 Taxonomies
Identify taxonomies in theme:
```php
register_taxonomy('genre', 'book', [
'label' => 'Genres',
'hierarchical' => true,
]);
```
Use in templates:
```astro
---
import { getTaxonomyTerms, getEntryTerms, getEntriesByTerm } from "emdash";
// Get all terms
const genres = await getTaxonomyTerms("genre");
// Get terms for a specific entry
const bookGenres = await getEntryTerms("books", book.id, "genre");
// Get entries by term
const fictionBooks = await getEntriesByTerm("books", "genre", "fiction");
---
```
## 4.4 Widget Areas
Identify sidebars in theme:
```php
register_sidebar([
'name' => 'Main Sidebar',
'id' => 'sidebar-1',
]);
```
Use in templates:
```astro
---
import { getWidgetArea } from "emdash";
import { PortableText } from "emdash/ui";
const sidebar = await getWidgetArea("sidebar");
---
{sidebar && sidebar.widgets.length > 0 && (
<aside class="sidebar">
{sidebar.widgets.map(widget => (
<div class="widget">
{widget.title && <h3>{widget.title}</h3>}
{widget.type === "content" && <PortableText value={widget.content} />}
</div>
))}
</aside>
)}
```
## 4.5 Widget Components
Map WP widgets to Astro components:
| WP Widget | EmDash Component |
| ---------------- | ------------------- |
| Recent Posts | `core:recent-posts` |
| Categories | `core:categories` |
| Tag Cloud | `core:tags` |
| Search | `core:search` |
| Archives | `core:archives` |
| Text/Custom HTML | `type: 'content'` |
| Navigation Menu | `type: 'menu'` |
See `references/emdash-api.md` for full API reference.

View File

@@ -0,0 +1,203 @@
# Phase 5: Create Seed File
Combine all theme features into a seed file with sample content.
## 5.1 Image Strategy
**Use the same images you downloaded in Phase 1** for visual consistency.
1. **Open source themes (GPL)**: Use exact images from the demo
2. **Premium themes**: Use Unsplash images matching the demo's style
3. **Local images**: Reference with `file:./` prefix:
```json
"featured_image": {
"$media": {
"url": "file:./discovery/images/hero.jpg",
"alt": "Hero image"
}
}
```
## 5.2 Validation
The seed is applied automatically on the first request when the database is empty (and no setup wizard run yet). Validation runs at apply time — errors show up in the dev server logs. Restart the dev server after fixing them.
The validator catches common mistakes:
| Check | Error |
| ---------------------------- | ------------------------- |
| Image using raw URL | "must use $media syntax" |
| Reference using raw ID | "must use $ref:id syntax" |
| PortableText not an array | "expected array" |
| PortableText missing `_type` | "missing required \_type" |
### Common Fixes
```json
// WRONG - raw URL
"featured_image": "https://example.com/photo.jpg"
// CORRECT - $media syntax
"featured_image": {
"$media": {
"url": "https://example.com/photo.jpg",
"alt": "Description"
}
}
// WRONG - unknown byline reference
"bylines": [{ "byline": "author-1" }]
// CORRECT - define root bylines[] and reference byline IDs
"bylines": [{ "byline": "byline-author-1" }]
```
## 5.3 Seed File Structure
```json
{
"$schema": "https://emdashcms.com/seed.schema.json",
"version": "1",
"meta": {
"name": "Theme Name",
"description": "Ported from WordPress theme"
},
"settings": {
"title": "Site Title",
"tagline": "Site tagline"
},
"collections": [
{
"slug": "posts",
"label": "Posts",
"fields": [
{ "slug": "title", "type": "string", "required": true },
{ "slug": "content", "type": "portableText" },
{ "slug": "featured_image", "type": "image" }
]
}
],
"taxonomies": [
{
"name": "categories",
"label": "Categories",
"hierarchical": true,
"collections": ["posts"],
"terms": [{ "slug": "news", "label": "News" }]
}
],
"bylines": [
{
"id": "byline-author-1",
"slug": "theme-author",
"displayName": "Theme Author"
}
],
"menus": [
{
"name": "primary",
"label": "Primary Navigation",
"items": [
{ "type": "custom", "label": "Home", "url": "/" },
{ "type": "custom", "label": "Blog", "url": "/posts" }
]
}
],
"content": {
"posts": [
{
"id": "post-1",
"slug": "hello-world",
"status": "published",
"bylines": [{ "byline": "byline-author-1" }],
"data": {
"title": "Hello World",
"content": [{ "_type": "block", "children": [{ "text": "Welcome!" }] }],
"featured_image": {
"$media": {
"url": "file:./discovery/images/featured-1.jpg",
"alt": "Featured image"
}
}
}
}
]
}
}
```
## 5.4 Adding Sections (Reusable Blocks)
If the theme has reusable block patterns, add them as sections:
```json
{
"sections": [
{
"slug": "hero-centered",
"title": "Centered Hero",
"description": "Full-width hero with centered heading and CTA button",
"keywords": ["hero", "banner", "header", "landing"],
"content": [
{
"_type": "block",
"style": "h1",
"children": [{ "_type": "span", "text": "Welcome to Our Site" }]
},
{
"_type": "block",
"children": [{ "_type": "span", "text": "Your compelling tagline goes here." }]
}
]
},
{
"slug": "newsletter-cta",
"title": "Newsletter Signup",
"keywords": ["newsletter", "subscribe", "email", "signup"],
"content": [
{
"_type": "block",
"style": "h3",
"children": [{ "_type": "span", "text": "Subscribe to our newsletter" }]
},
{
"_type": "block",
"children": [
{ "_type": "span", "text": "Get the latest updates delivered to your inbox." }
]
}
]
}
]
}
```
Editors can insert these sections using the `/section` slash command in the rich text editor.
## 5.5 Add Redirects for Legacy WordPress URLs
Include redirects in the seed when the WordPress theme used different URL structures.
```json
{
"redirects": [
{ "source": "/?p=123", "destination": "/hello-world" },
{ "source": "/2024/01/hello-world", "destination": "/hello-world", "type": 301 },
{ "source": "/category/news", "destination": "/categories/news" }
]
}
```
Rules:
- `source` and `destination` must be local paths (start with `/`)
- Supported `type` values are `301`, `302`, `307`, `308`
- Redirects are idempotent during seeding (existing `source` entries are skipped)
See `references/emdash-api.md` for full seed file schema.

View File

@@ -0,0 +1,92 @@
# Phase 6: Verify & Iterate
Seed content, run the dev server, compare screenshots, and iterate until pages match.
## 6.1 Start Dev Server
The seed is applied on the first request when the database is empty. If you've already run the dev server against an existing database, delete `data.db` first so the seed reapplies.
Kill any existing server first:
```bash
lsof -ti:4321 | xargs kill -9 2>/dev/null || true
rm -f data.db # only if you need a clean slate
pnpm dev
```
Watch the logs for any seed validation errors and fix them before continuing.
## 6.3 Screenshot Each Page Type
Screenshot every page type you captured in Phase 1:
```bash
# Homepage
agent-browser open http://localhost:4321
agent-browser screenshot output/homepage.png --full
# Single post
agent-browser open http://localhost:4321/posts/hello-world
agent-browser screenshot output/single-post.png --full
# Blog archive
agent-browser open http://localhost:4321/posts
agent-browser screenshot output/archive.png --full
# Category page
agent-browser open http://localhost:4321/categories/news
agent-browser screenshot output/category.png --full
# Static page
agent-browser open http://localhost:4321/pages/about
agent-browser screenshot output/page.png --full
# 404 page
agent-browser open http://localhost:4321/nonexistent
agent-browser screenshot output/404.png --full
```
## 6.4 Compare & Iterate
Compare each screenshot pair:
| Page Type | Reference | Output |
| ----------- | --------------------------------------- | ------------------------ |
| Homepage | `discovery/screenshots/homepage.png` | `output/homepage.png` |
| Single Post | `discovery/screenshots/single-post.png` | `output/single-post.png` |
| Archive | `discovery/screenshots/archive.png` | `output/archive.png` |
| Category | `discovery/screenshots/category.png` | `output/category.png` |
| Page | `discovery/screenshots/page.png` | `output/page.png` |
| 404 | `discovery/screenshots/404.png` | `output/404.png` |
For each page, identify differences and fix:
1. **Layout** - CSS grid/flexbox, content width, spacing
2. **Typography** - Font family, sizes, line height
3. **Colors** - Background, text, links, borders
4. **Components** - Headers, footers, cards, buttons
5. **Responsive** - Check mobile viewport too
Re-screenshot after each round of fixes.
**Don't aim for pixel-perfect** - aim for "same design language."
## 6.5 Final Build Test
```bash
pnpm run build
```
## License Compliance
WordPress themes are GPL-licensed. Every ported theme needs:
1. **LICENSE** - GPL-2.0 text (download with curl, don't output directly):
```bash
curl -o LICENSE https://raw.githubusercontent.com/spdx/license-list-data/main/text/GPL-2.0-or-later.txt
```
2. **README.md** - Credits to original theme
3. **package.json** - `"license": "GPL-2.0-or-later"`

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,410 @@
# WP Theme → EmDash Concept Mapping
## Template Hierarchy
| WP Template | Purpose | EmDash Equivalent |
| ------------------------- | ----------------------- | ------------------------------------------------------- |
| `index.php` | Fallback for everything | `src/pages/index.astro` |
| `front-page.php` | Static front page | `src/pages/index.astro` |
| `home.php` | Blog posts page | `src/pages/index.astro` or `src/pages/blog/index.astro` |
| `single.php` | Single post | `src/pages/posts/[slug].astro` |
| `single-{post_type}.php` | Custom post type single | `src/pages/{type}/[slug].astro` |
| `page.php` | Single page | `src/pages/pages/[slug].astro` |
| `page-{slug}.php` | Specific page template | `src/pages/pages/{slug}.astro` (static) |
| `archive.php` | Post archives | `src/pages/posts/index.astro` |
| `archive-{post_type}.php` | CPT archive | `src/pages/{type}/index.astro` |
| `category.php` | Category archive | `src/pages/categories/[slug].astro` |
| `tag.php` | Tag archive | `src/pages/tags/[slug].astro` |
| `author.php` | Author archive | `src/pages/authors/[slug].astro` |
| `date.php` | Date archive | `src/pages/archive/[year]/[month].astro` |
| `search.php` | Search results | `src/pages/search.astro` (use `search()` API) |
| `404.php` | Not found | `src/pages/404.astro` |
| `header.php` | Site header | Part of `src/layouts/Base.astro` |
| `footer.php` | Site footer | Part of `src/layouts/Base.astro` |
| `sidebar.php` | Sidebar widget area | Component: `src/components/Sidebar.astro` |
| `comments.php` | Comments template | Component or third-party (Giscus, etc.) |
## Template Parts
| WP Pattern | EmDash Pattern |
| ---------------------------------------------------------- | ---------------------------- |
| `get_template_part('content', 'post')` | `<PostCard />` component |
| `get_template_part('template-parts/header/site-branding')` | `<SiteBranding />` component |
| `template-parts/` directory | `src/components/` directory |
## Functions.php Registrations
### Navigation Menus
```php
// WordPress
register_nav_menus([
'primary' => 'Primary Menu',
'footer' => 'Footer Menu',
]);
```
EmDash has first-class menu support with automatic URL resolution:
```typescript
import { getMenu } from "emdash";
const primaryMenu = await getMenu("primary");
// Returns { id, name, label, items: MenuItem[] }
// Items have resolved URLs and support nesting
```
Menus are created via:
- Admin UI
- Seed files (JSON)
- WordPress import (automatic migration)
### Sidebars/Widget Areas
```php
// WordPress
register_sidebar([
'name' => 'Main Sidebar',
'id' => 'sidebar-1',
]);
```
EmDash has first-class widget area support:
```typescript
import { getWidgetArea } from "emdash";
const sidebar = await getWidgetArea("sidebar");
// Returns { id, name, label, widgets: Widget[] }
// Widgets can be content (Portable Text), menu, or component
```
Widget areas are created via:
- Admin UI
- Seed files (JSON)
- WordPress import (automatic migration)
### Theme Support
```php
// WordPress
add_theme_support('post-thumbnails');
add_theme_support('title-tag');
add_theme_support('custom-logo');
add_theme_support('post-formats');
```
EmDash equivalents:
- `post-thumbnails``featured_image` field on collections (automatic)
- `title-tag` → Astro handles `<title>` in layout
- `custom-logo``getSiteSetting("logo")` returns `{ mediaId, alt, url }`
- `post-formats` → Field on collection (select type)
### Custom Post Types
```php
// WordPress
register_post_type('portfolio', [...]);
```
EmDash: Create collection via admin UI or API. The collection will be created during content import if it doesn't exist.
## Template Tags → EmDash
### Content Retrieval
| WP Function | EmDash Equivalent |
| ----------------------------- | ------------------------------------------------- |
| `have_posts()` / `the_post()` | `getEmDashCollection()` |
| `get_post()` | `getEmDashEntry()` |
| `the_title()` | `post.data.title` |
| `the_content()` | `<PortableText value={post.data.content} />` |
| `the_excerpt()` | `post.data.excerpt` |
| `the_permalink()` | `/posts/${post.id}` or `/posts/${post.data.slug}` |
| `the_post_thumbnail()` | `post.data.featured_image` |
| `get_the_date()` | `post.data.publishedAt` |
| `get_the_author()` | `post.data.byline?.displayName` |
| `get_the_category()` | `getEntryTerms(coll, id, "categories")` |
| `get_the_tags()` | `getEntryTerms(coll, id, "tags")` |
### Taxonomies
| WP Function | EmDash Equivalent |
| ---------------------------- | -------------------------------------------------- |
| `get_categories()` | `getTaxonomyTerms("categories")` |
| `get_tags()` | `getTaxonomyTerms("tags")` |
| `get_terms($taxonomy)` | `getTaxonomyTerms(taxonomy)` |
| `get_term($id, $taxonomy)` | `getTerm(taxonomy, slug)` |
| `get_term_by('slug', ...)` | `getTerm(taxonomy, slug)` |
| `get_the_terms($post, $tax)` | `getEntryTerms(collection, entryId, taxonomy)` |
| `wp_get_post_categories()` | `getEntryTerms(collection, entryId, "categories")` |
| `wp_get_post_tags()` | `getEntryTerms(collection, entryId, "tags")` |
| `get_category_link($cat)` | `/categories/${term.slug}` |
| `get_tag_link($tag)` | `/tags/${term.slug}` |
EmDash supports hierarchical taxonomies (like categories) and flat taxonomies (like tags):
### Site Info
| WP Function | EmDash Equivalent |
| ------------------------- | --------------------------------------- |
| `bloginfo('name')` | `getSiteSetting("title")` |
| `bloginfo('description')` | `getSiteSetting("tagline")` |
| `home_url()` | `Astro.site` or `import.meta.env.SITE` |
| `get_theme_mod()` | `getSiteSetting(key)` or plugin storage |
| `get_option()` | `getSiteSetting(key)` or plugin storage |
| `get_custom_logo()` | `getSiteSetting("logo")` returns URL |
### Conditional Tags
| WP Function | Astro Equivalent |
| ----------------- | ---------------------------------- |
| `is_home()` | `Astro.url.pathname === '/'` |
| `is_front_page()` | `Astro.url.pathname === '/'` |
| `is_single()` | Check route pattern |
| `is_page()` | Check route pattern |
| `is_archive()` | Check route pattern |
| `is_category()` | Check route pattern |
| `is_search()` | `Astro.url.pathname === '/search'` |
| `is_404()` | N/A (404.astro handles this) |
### Media
| WP Function | EmDash Equivalent |
| --------------------------- | -------------------------- |
| `wp_get_attachment_image()` | `<img src={media.url} />` |
| `wp_get_attachment_url()` | `media.url` |
| `the_post_thumbnail()` | `post.data.featured_image` |
### Navigation
| WP Function | EmDash Equivalent |
| ------------------------ | ------------------------------------- |
| `wp_nav_menu()` | `getMenu("menu-name")` + render items |
| `wp_list_pages()` | Query pages collection or use menu |
| `the_posts_navigation()` | Custom pagination component |
| `the_posts_pagination()` | Custom pagination component |
| `get_nav_menu_items()` | `getMenu("name").items` |
## Hooks → EmDash Events
WordPress hooks don't have direct equivalents. Most hook functionality becomes:
1. **Astro middleware** - For request/response modification
2. **EmDash plugin hooks** - For content lifecycle events
3. **Build-time logic** - In Astro config or components
| WP Hook | EmDash Approach |
| -------------------- | ---------------------------------------- |
| `wp_head` | Add to `<head>` in layout |
| `wp_footer` | Add before `</body>` in layout |
| `the_content` filter | PortableText components |
| `pre_get_posts` | Query filters in `getEmDashCollection()` |
| `save_post` | EmDash plugin hook: `content:beforeSave` |
## Asset Enqueueing
```php
// WordPress
wp_enqueue_style('theme-style', get_stylesheet_uri());
wp_enqueue_script('theme-script', get_template_directory_uri() . '/js/main.js');
```
Astro:
```astro
---
// In layout or component
import '../styles/main.css';
import '../scripts/main.js';
---
<link rel="stylesheet" href="/styles/main.css" />
<script src="/scripts/main.js"></script>
```
Or use Astro's built-in bundling:
```astro
<style>
/* Scoped styles */
</style>
<script>
// Client-side JS
</script>
```
## Shortcodes → Portable Text Blocks
```php
// WordPress shortcode
add_shortcode('gallery', function($atts) {
return '<div class="gallery">...</div>';
});
// Usage: [gallery ids="1,2,3"]
```
EmDash: Custom Portable Text block type + component:
```astro
---
// GalleryBlock.astro
const { ids } = Astro.props;
---
<div class="gallery">
<!-- Render images -->
</div>
```
```astro
<PortableText
value={content}
components={{ gallery: GalleryBlock }}
/>
```
## Widgets → Widget Areas
EmDash has first-class widget support with `getWidgetArea()`:
```typescript
import { getWidgetArea } from "emdash";
const sidebar = await getWidgetArea("sidebar");
sidebar?.widgets.forEach((widget) => {
// widget.type: "content" | "menu" | "component"
});
```
### Widget Types
| WP Widget | EmDash Widget Type | Notes |
| ------------ | ------------------------------- | ----------------------------- |
| Text/HTML | `content` | Portable Text (rich content) |
| Custom Menu | `menu` | References menu by name |
| Recent Posts | `component` `core:recent-posts` | Built-in component with props |
| Categories | `component` `core:categories` | Built-in component |
| Tag Cloud | `component` `core:tag-cloud` | Built-in component |
| Search | `<LiveSearch />` component | Use `emdash/ui` LiveSearch |
| Archives | `component` `core:archives` | Built-in component |
### Core Widget Components
| Component ID | Props |
| ------------------- | ------------------------ |
| `core:recent-posts` | `limit`, `collection` |
| `core:categories` | `taxonomy`, `showCounts` |
| `core:tag-cloud` | `taxonomy`, `limit` |
| `core:search` | `placeholder` |
| `core:archives` | `collection`, `format` |
## Search
WordPress search maps to EmDash's FTS5-based search system:
```php
// WordPress search form
get_search_form();
// WordPress search query
$results = new WP_Query(['s' => 'hello world']);
```
EmDash:
```typescript
import { search } from "emdash";
import LiveSearch from "emdash/ui/search";
// Programmatic search
const results = await search("hello world", {
collections: ["posts", "pages"],
limit: 20,
});
// Or use the LiveSearch component
<LiveSearch placeholder="Search..." />
```
### Search Page Pattern
```astro
---
// src/pages/search.astro
import { search } from "emdash";
import Base from "../layouts/Base.astro";
const query = Astro.url.searchParams.get("q") || "";
const results = query ? await search(query, { limit: 20 }) : { results: [] };
---
<Base title={`Search: ${query}`}>
<h1>Search Results for "{query}"</h1>
{results.results.length === 0 ? (
<p>No results found.</p>
) : (
<ul>
{results.results.map(r => (
<li>
<a href={`/${r.collection}/${r.slug}`}>{r.title}</a>
<p set:html={r.snippet} />
</li>
))}
</ul>
)}
</Base>
```
### Search Features
| WordPress | EmDash |
| ---------------------------- | ------------------------------- |
| Basic keyword search | FTS5 with Porter stemming |
| Search all public post types | Per-collection search enable |
| `s` query parameter | `q` query parameter |
| Relevance sorting | BM25 ranking with field weights |
| Search widget | `<LiveSearch />` component |
**Note:** Search must be enabled per-collection in admin. Mark fields as "Searchable" to include them in the index.
## Reusable Blocks → Sections
WordPress reusable blocks (`wp_block` post type) map to EmDash sections:
```php
// WordPress - creating a reusable block
// Done via Gutenberg editor, saved as wp_block post type
```
EmDash:
```typescript
import { getSection, getSections } from "emdash";
// Get a specific section
const cta = await getSection("newsletter-cta");
// List sections by category
const heroes = await getSections({ category: "heroes" });
```
### Inserting Sections
In WordPress, you insert reusable blocks from the block inserter. In EmDash, editors use the `/section` slash command in the rich text editor.
### Section Sources
| Source | Origin |
| -------- | --------------------------------------- |
| `theme` | Defined in seed file (theme patterns) |
| `user` | Created by editors in admin |
| `import` | Imported from WordPress reusable blocks |
### Migration
WordPress `wp_block` posts are automatically imported as sections:
- Content converted from Gutenberg to Portable Text
- Placed in "Imported" category
- Source set to `"import"`

View File

@@ -0,0 +1,396 @@
# Design Extraction Guide
Extract design tokens from WordPress themes for use in EmDash.
## CSS Variable Extraction
### Finding Design Tokens in Classic Themes
Look in these files (in order of priority):
1. **`style.css`** - Main stylesheet, often has custom properties
2. **`assets/css/custom-properties.css`** - Some themes separate variables
3. **`inc/customizer.php`** - Default values for customizer options
4. **`functions.php`** - Inline styles with defaults
Common patterns to search for:
```css
/* Root variables */
:root {
--primary-color: #0073aa;
--font-family: 'Open Sans', sans-serif;
}
/* Theme-specific prefixes */
--theme-name-color-primary
--wp--preset--color--primary
```
### Finding Design Tokens in Block Themes (theme.json)
Block themes (WordPress 5.9+) store design tokens in `theme.json`:
```json
{
"settings": {
"color": {
"palette": [
{ "slug": "primary", "color": "#0073aa", "name": "Primary" },
{ "slug": "secondary", "color": "#23282d", "name": "Secondary" }
]
},
"typography": {
"fontFamilies": [
{
"fontFamily": "'Open Sans', sans-serif",
"slug": "body",
"name": "Body"
}
],
"fontSizes": [{ "size": "1rem", "slug": "medium", "name": "Medium" }]
},
"spacing": {
"units": ["px", "em", "rem", "%"],
"spacingSizes": [{ "size": "1rem", "slug": "20", "name": "Small" }]
},
"layout": {
"contentSize": "650px",
"wideSize": "1200px"
}
}
}
```
Convert to EmDash CSS variables:
```css
:root {
/* Colors from theme.json palette */
--color-primary: #0073aa;
--color-secondary: #23282d;
/* Typography */
--font-body: "Open Sans", sans-serif;
--font-size-medium: 1rem;
/* Layout */
--content-width: 650px;
--wide-width: 1200px;
/* Spacing */
--space-20: 1rem;
}
```
## Color Extraction
### From Live Site
Use browser DevTools or automation:
1. **Background colors**: `document.body.style.backgroundColor`
2. **Text colors**: Inspect body, headings, links
3. **Accent colors**: Buttons, links, highlights
Common elements to check:
- Body background and text
- Header/footer backgrounds
- Link colors (normal, hover, visited)
- Button colors (primary, secondary)
- Border colors
- Selection highlight
### Common Color Mapping
| WP Pattern | EmDash Variable |
| ---------------- | ------------------- |
| Background | `--color-base` |
| Text | `--color-contrast` |
| Primary brand | `--color-primary` |
| Secondary brand | `--color-secondary` |
| Accent/highlight | `--color-accent-1` |
| Muted text | `--color-muted` |
| Border | `--color-border` |
| Error | `--color-error` |
| Success | `--color-success` |
## Typography Extraction
### Font Families
Check for:
1. Google Fonts in `<head>` - `fonts.googleapis.com`
2. `@font-face` declarations in CSS
3. Font files in `assets/fonts/` or `fonts/`
4. Customizer settings for typography
Extract the stack:
```css
/* WP theme might have */
font-family: "Playfair Display", Georgia, serif;
/* Convert to EmDash */
:root {
--font-heading: "Playfair Display", Georgia, serif;
}
```
### Font Sizes
Common patterns:
```css
/* WP theme */
body {
font-size: 18px;
}
h1 {
font-size: 2.5em;
}
h2 {
font-size: 2em;
}
/* EmDash with clamp for responsiveness */
:root {
--font-size-base: clamp(1rem, 0.5vw + 0.9rem, 1.125rem);
--font-size-xl: clamp(1.75rem, 1vw + 1.5rem, 2rem);
--font-size-xxl: clamp(2.15rem, 2vw + 1.5rem, 3rem);
}
```
### Line Height and Spacing
```css
/* Extract these values */
line-height: 1.6;
letter-spacing: -0.01em;
```
## Spacing System
### Identify the Scale
Look for consistent spacing values:
```css
/* Common WordPress patterns */
padding: 20px;
margin-bottom: 30px;
gap: 2rem;
```
Create a scale:
```css
:root {
--space-10: 0.25rem; /* 4px */
--space-20: 0.5rem; /* 8px */
--space-30: 1rem; /* 16px */
--space-40: 1.5rem; /* 24px */
--space-50: 2rem; /* 32px */
--space-60: 3rem; /* 48px */
--space-70: 4rem; /* 64px */
--space-80: 6rem; /* 96px */
}
```
## Layout Extraction
### Content Width
Find in:
- `.container`, `.wrapper`, `.site-content` max-width
- `theme.json` layout.contentSize
- Customizer settings
```css
/* WP theme */
.container {
max-width: 1140px;
}
.content-area {
max-width: 720px;
}
/* EmDash */
:root {
--content-width: 720px;
--wide-width: 1140px;
}
```
### Breakpoints
Common WordPress breakpoints:
```css
/* Find in theme CSS */
@media (max-width: 1200px) {
}
@media (max-width: 992px) {
}
@media (max-width: 768px) {
}
@media (max-width: 576px) {
}
```
Document for use in Astro:
```css
/* EmDash breakpoints */
@media (max-width: 1024px) {
/* Tablet landscape */
}
@media (max-width: 768px) {
/* Tablet portrait */
}
@media (max-width: 640px) {
/* Mobile */
}
```
## Component Patterns
### Header Pattern
Identify header structure:
- Logo position (left, center)
- Navigation style (horizontal, hamburger)
- Background (solid, transparent, sticky)
```css
/* Extract key values */
.site-header {
height: 80px;
background: #ffffff;
position: sticky;
top: 0;
z-index: 100;
}
```
### Card Pattern
```css
/* WP card styles */
.post-card {
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
/* EmDash equivalent */
:root {
--radius-card: 8px;
--shadow-card: 0 2px 8px rgba(0, 0, 0, 0.1);
}
```
### Button Pattern
```css
/* WP button */
.button,
.wp-block-button__link {
padding: 12px 24px;
border-radius: 4px;
font-weight: 600;
text-transform: uppercase;
}
/* EmDash */
.btn {
padding: var(--space-30) var(--space-40);
border-radius: var(--radius-button);
font-weight: 600;
}
```
## Automated Extraction Script
For complex themes, consider a script approach:
```javascript
// Run in browser console on live WP site
const styles = getComputedStyle(document.body);
const tokens = {
colors: {
background: styles.backgroundColor,
text: styles.color,
},
typography: {
fontFamily: styles.fontFamily,
fontSize: styles.fontSize,
lineHeight: styles.lineHeight,
},
};
// Check header
const header = document.querySelector("header, .site-header");
if (header) {
const headerStyles = getComputedStyle(header);
tokens.header = {
background: headerStyles.backgroundColor,
height: headerStyles.height,
};
}
console.log(JSON.stringify(tokens, null, 2));
```
## Output Template
Final CSS variables for EmDash Base.astro:
```css
:root {
/* Colors */
--color-base: #ffffff;
--color-contrast: #1a1a1a;
--color-primary: #0073aa;
--color-secondary: #23282d;
--color-muted: #757575;
--color-border: #e0e0e0;
/* Typography */
--font-body: "Open Sans", system-ui, sans-serif;
--font-heading: "Playfair Display", Georgia, serif;
--font-mono: "Fira Code", monospace;
/* Font sizes */
--font-size-small: 0.875rem;
--font-size-base: 1rem;
--font-size-large: 1.125rem;
--font-size-xl: 1.5rem;
--font-size-xxl: 2rem;
--font-size-xxxl: 3rem;
/* Spacing */
--space-20: 0.5rem;
--space-30: 1rem;
--space-40: 1.5rem;
--space-50: 2rem;
--space-60: 3rem;
--space-70: 4rem;
--space-80: 6rem;
/* Layout */
--content-width: 720px;
--wide-width: 1200px;
/* Components */
--radius-small: 4px;
--radius-medium: 8px;
--radius-large: 16px;
--shadow-small: 0 1px 3px rgba(0, 0, 0, 0.1);
--shadow-medium: 0 4px 12px rgba(0, 0, 0, 0.15);
}
```

View File

@@ -0,0 +1,495 @@
# EmDash API Reference
Quick reference for EmDash-specific APIs used when porting themes.
> **See also:** The `scaffold/` directory contains working examples of all these patterns. When in doubt, copy from there.
## Content Retrieval
EmDash's query functions follow Astro's [live content collections](https://docs.astro.build/en/reference/experimental-flags/live-content-collections/) pattern, returning structured results for graceful error handling.
### getEmDashCollection
Fetch multiple entries from a collection.
```typescript
import { getEmDashCollection } from "emdash";
// Returns { entries, error }
const { entries: posts } = await getEmDashCollection("posts");
// With filters
const { entries: posts } = await getEmDashCollection("posts", {
status: "published",
limit: 10,
where: { category: "news" },
});
```
### getEmDashEntry
Fetch a single entry by slug.
```typescript
import { getEmDashEntry } from "emdash";
// Returns { entry, error, isPreview }
const { entry: post } = await getEmDashEntry("posts", "hello-world");
if (!post) {
return Astro.redirect("/404");
}
```
### Entry Shape
```typescript
interface Entry {
id: string;
collection: string;
data: {
title: string;
slug: string;
content: PortableTextBlock[];
featured_image?: ImageField; // { src, alt } - NOT a string!
// ... custom fields
};
}
```
## Field Types at Runtime
**IMPORTANT:** Field types have specific runtime shapes. The most common mistake is treating image fields as strings.
### Image Fields
Image fields are **objects**, not strings:
```typescript
interface ImageField {
src: string; // The resolved URL
alt?: string;
}
```
```astro
{/* CORRECT */}
{post.data.featured_image?.src && (
<img
src={post.data.featured_image.src}
alt={post.data.featured_image.alt || post.data.title}
/>
)}
{/* WRONG - renders [object Object] */}
<img src={post.data.featured_image} />
```
### Reference Fields
In seed files use `"$ref:id"` format. At runtime they may be resolved objects or strings.
### PortableText Fields
Rich content is an array of blocks with `_type` property.
## Site Settings
### getSiteSettings
Get all site settings.
```typescript
import { getSiteSettings } from "emdash";
const settings = await getSiteSettings();
console.log(settings.title); // "My Site"
console.log(settings.logo?.url); // Resolved media URL
```
### getSiteSetting
Get a single setting.
```typescript
import { getSiteSetting } from "emdash";
const title = await getSiteSetting("title");
const logo = await getSiteSetting("logo");
```
### Available Settings
| Key | Type | Description |
| ------------ | ---------------- | ------------------------ |
| `title` | `string` | Site name |
| `tagline` | `string` | Site tagline/description |
| `logo` | `MediaReference` | Site logo with URL |
| `favicon` | `MediaReference` | Favicon with URL |
| `social` | `SocialLinks` | Social media URLs |
| `timezone` | `string` | Site timezone |
| `dateFormat` | `string` | Date display format |
## Navigation Menus
### getMenu
Fetch a menu by name with resolved URLs.
```typescript
import { getMenu } from "emdash";
const menu = await getMenu("primary");
if (menu) {
console.log(menu.items); // MenuItem[]
}
```
### getMenus
Get all menus (names only).
```typescript
import { getMenus } from "emdash";
const menus = await getMenus();
// [{ id, name, label }]
```
### MenuItem Shape
```typescript
interface MenuItem {
id: string;
label: string;
url: string; // Resolved URL
target?: string;
children: MenuItem[];
}
```
### Rendering Menus
```astro
---
import { getMenu } from "emdash";
const primaryMenu = await getMenu("primary");
---
<nav>
{primaryMenu?.items.map(item => (
<a href={item.url}>{item.label}</a>
))}
</nav>
```
## Taxonomies
### getTaxonomyTerms
Get all terms for a taxonomy.
```typescript
import { getTaxonomyTerms } from "emdash";
const categories = await getTaxonomyTerms("categories");
const tags = await getTaxonomyTerms("tags");
```
### getTerm
Get a single term by slug.
```typescript
import { getTerm } from "emdash";
const term = await getTerm("categories", "news");
console.log(term?.label); // "News"
console.log(term?.count); // Number of entries
```
### getEntryTerms
Get terms assigned to a specific entry.
> **IMPORTANT:** This function does NOT take a `db` parameter.
```typescript
import { getEntryTerms } from "emdash";
// Get all terms for an entry
const terms = await getEntryTerms("posts", post.id);
// Get only categories
const categories = await getEntryTerms("posts", post.id, "categories");
```
### getEntriesByTerm
Get entries that have a specific term.
```typescript
import { getEntriesByTerm } from "emdash";
const posts = await getEntriesByTerm("posts", "categories", "news");
```
### TaxonomyTerm Shape
```typescript
interface TaxonomyTerm {
id: string;
name: string; // Taxonomy name
slug: string; // Term slug
label: string; // Display label
children: TaxonomyTerm[];
count?: number;
}
```
## Widget Areas
### getWidgetArea
Get a widget area by name.
```typescript
import { getWidgetArea } from "emdash";
const sidebar = await getWidgetArea("sidebar");
if (sidebar) {
console.log(sidebar.widgets); // Widget[]
}
```
### Widget Types
| Type | Description | Key Fields |
| ----------- | -------------------- | ---------------------- |
| `content` | Rich text (PT) | `content` |
| `menu` | Navigation menu | `menuName` |
| `component` | Registered component | `componentId`, `props` |
## Sections (Reusable Blocks)
Sections are reusable content blocks that editors can insert via `/section` slash command.
### getSection
Get a single section by slug.
```typescript
import { getSection } from "emdash";
const cta = await getSection("newsletter-cta");
// Returns { id, slug, title, content, keywords, source }
```
### getSections
List sections with optional filters.
```typescript
import { getSections } from "emdash";
// Get all sections
const all = await getSections();
// Filter by source: "theme" | "user" | "import"
const imported = await getSections({ source: "import" });
```
### Section Sources
| Source | Description |
| -------- | --------------------------------------- |
| `theme` | Defined in seed file |
| `user` | Created by editors in admin |
| `import` | Imported from WordPress reusable blocks |
## Search
### search
Global search across collections.
```typescript
import { search } from "emdash";
const results = await search("hello world", {
collections: ["posts", "pages"], // Optional: limit to specific collections
status: "published", // Optional: filter by status
limit: 20, // Optional: max results
});
// Returns { results: SearchResult[], total, nextCursor? }
results.results.forEach((r) => {
console.log(r.collection); // "posts"
console.log(r.id); // Entry ID
console.log(r.title); // Entry title
console.log(r.slug); // Entry slug
console.log(r.snippet); // HTML snippet with <mark> highlights
console.log(r.score); // Relevance score
});
```
### LiveSearch Component
Ready-to-use search with instant results:
```astro
---
import LiveSearch from "emdash/ui/search";
---
<LiveSearch
placeholder="Search..."
collections={["posts", "pages"]}
/>
```
Features:
- Debounced instant search
- Prefix matching (automatic `*` suffix)
- Porter stemming ("run" finds "running")
- Result snippets with `<mark>` highlights
### Search Configuration
Search is enabled per-collection via admin UI:
1. Edit Content Type → check "Search" in Features
2. Edit fields → check "Searchable" for text fields
Only collections with search enabled are indexed.
## Rendering Content
### PortableText Component
```astro
---
import { PortableText } from "emdash/ui";
---
<PortableText value={post.data.content} />
```
## CLI Commands
### Apply Seed
The seed is inlined into the build and applied automatically on the first request when the database is empty and the setup wizard hasn't been completed. Validation runs at apply time — errors surface in the dev server logs. To re-seed during iteration, delete `data.db` and restart the dev server.
Common validation errors caught:
- Image fields with raw URLs (should use `$media`)
- Reference fields with raw IDs (should use `$ref:id`)
- PortableText not an array or missing `_type`
- Type mismatches (string vs number, etc.)
### Export Seed
```bash
# Export schema only
emdash export-seed
# Export schema and all content
emdash export-seed --with-content
# Export specific collections
emdash export-seed --with-content=posts,pages
```
## Configuration
### astro.config.mjs
```javascript
import { defineConfig } from "astro/config";
import emdash, { local } from "emdash/astro";
import { sqlite } from "emdash/db";
export default defineConfig({
integrations: [
emdash({
database: sqlite({ url: "file:./data.db" }),
storage: local({
directory: "./uploads",
baseUrl: "/_emdash/api/media/file",
}),
}),
],
});
```
### live.config.ts
```typescript
// src/live.config.ts
import { defineLiveCollection } from "astro:content";
import { emdashLoader } from "emdash/runtime";
export const collections = {
_emdash: defineLiveCollection({ loader: emdashLoader() }),
};
```
## Common Patterns
### Homepage with Recent Posts
```astro
---
import { getEmDashCollection, getSiteSettings } from "emdash";
import Base from "../layouts/Base.astro";
const settings = await getSiteSettings();
const { entries: posts } = await getEmDashCollection("posts", { limit: 10 });
---
<Base title={settings.title}>
{posts.map(post => (
<article>
<a href={`/posts/${post.data.slug}`}>{post.data.title}</a>
</article>
))}
</Base>
```
### Category Archive
```astro
---
import { getTerm, getEntriesByTerm } from "emdash";
const { slug } = Astro.params;
const category = await getTerm("categories", slug);
const posts = await getEntriesByTerm("posts", "categories", slug);
---
<h1>{category?.label}</h1>
{posts.map(post => (
<a href={`/posts/${post.data.slug}`}>{post.data.title}</a>
))}
```
### Dynamic Navigation
```astro
---
import { getMenu, getSiteSettings } from "emdash";
const settings = await getSiteSettings();
const primaryMenu = await getMenu("primary");
---
<header>
<a href="/">{settings.title}</a>
<nav>
{primaryMenu?.items.map(item => (
<a href={item.url}>{item.label}</a>
))}
</nav>
</header>
```

View File

@@ -0,0 +1,839 @@
# PHP → Astro Template Patterns
Common WordPress PHP patterns and their Astro/EmDash equivalents.
## The Loop
### Basic Loop
```php
// WordPress
<?php if (have_posts()) : ?>
<?php while (have_posts()) : the_post(); ?>
<article>
<h2><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></h2>
<?php the_excerpt(); ?>
</article>
<?php endwhile; ?>
<?php else : ?>
<p>No posts found.</p>
<?php endif; ?>
```
```astro
---
// Astro/EmDash
import { getEmDashCollection } from "emdash";
const { entries: posts } = await getEmDashCollection("posts");
---
{posts.length > 0 ? (
posts.map(post => (
<article>
<h2><a href={`/posts/${post.id}`}>{post.data.title}</a></h2>
<p>{post.data.excerpt}</p>
</article>
))
) : (
<p>No posts found.</p>
)}
```
### Custom Query
```php
// WordPress
$args = [
'post_type' => 'portfolio',
'posts_per_page' => 6,
'orderby' => 'date',
'order' => 'DESC',
];
$query = new WP_Query($args);
while ($query->have_posts()) : $query->the_post();
// ...
endwhile;
wp_reset_postdata();
```
```astro
---
// Astro/EmDash
import { getEmDashCollection } from "emdash";
const { entries: items } = await getEmDashCollection("portfolio", {
limit: 6,
orderBy: { published_at: "desc" },
});
---
{items.map(item => (
// ...
))}
```
## Single Post/Page
### Basic Single
```php
// WordPress single.php
<?php get_header(); ?>
<main>
<?php while (have_posts()) : the_post(); ?>
<article>
<h1><?php the_title(); ?></h1>
<div class="meta">
<?php the_date(); ?> | <?php the_author(); ?>
</div>
<?php the_content(); ?>
</article>
<?php endwhile; ?>
</main>
<?php get_footer(); ?>
```
```astro
---
// Astro pages/posts/[slug].astro
// NOTE: EmDash pages are always server-rendered (no getStaticPaths)
import { getEmDashEntry } from "emdash";
import { PortableText } from "emdash/ui";
import Base from "../../layouts/Base.astro";
const { slug } = Astro.params;
const { entry: post } = await getEmDashEntry("posts", slug!);
if (!post) {
return Astro.redirect("/404");
}
---
<Base title={post.data.title}>
<main>
<article>
<h1>{post.data.title}</h1>
<div class="meta">
{post.data.publishedAt} | {post.data.byline?.displayName ?? "Unknown"}
</div>
<PortableText value={post.data.content} />
</article>
</main>
</Base>
```
## Featured Image
```php
// WordPress
<?php if (has_post_thumbnail()) : ?>
<figure class="featured-image">
<?php the_post_thumbnail('large'); ?>
</figure>
<?php endif; ?>
```
```astro
---
// Astro
const { featured_image } = post.data;
---
{featured_image && (
<figure class="featured-image">
<img src={featured_image} alt={post.data.title} />
</figure>
)}
```
## Pagination
### Archive Pagination
```php
// WordPress
<?php
the_posts_pagination([
'prev_text' => '&laquo; Previous',
'next_text' => 'Next &raquo;',
]);
?>
```
```astro
---
// Astro - using cursor pagination
import { getEmDashCollection } from "emdash";
const page = Astro.url.searchParams.get('page') || '1';
const { entries, nextCursor, prevCursor } = await getEmDashCollection("posts", {
limit: 10,
cursor: Astro.url.searchParams.get('cursor'),
});
---
<nav class="pagination">
{prevCursor && <a href={`?cursor=${prevCursor}`}>&laquo; Previous</a>}
{nextCursor && <a href={`?cursor=${nextCursor}`}>Next &raquo;</a>}
</nav>
```
### Post Navigation (Prev/Next)
```php
// WordPress
<?php
the_post_navigation([
'prev_text' => '&larr; %title',
'next_text' => '%title &rarr;',
]);
?>
```
```astro
---
// Astro - requires fetching adjacent posts
// This is more complex; typically done at query time
// or by storing prev/next references
---
```
## Conditionals
### Check Post Type
```php
// WordPress
<?php if (is_singular('portfolio')) : ?>
<!-- Portfolio-specific content -->
<?php endif; ?>
```
```astro
---
// Astro - handled by file-based routing
// pages/portfolio/[slug].astro IS the portfolio single
---
```
### Check Page Template
```php
// WordPress
<?php if (is_page_template('templates/full-width.php')) : ?>
<div class="full-width">
<?php else : ?>
<div class="with-sidebar">
<?php endif; ?>
```
```astro
---
// Astro - add a "template" select field to your pages collection
// with options like "Default", "Full Width", etc.
// Then in your page route, map templates to layout components:
import PageDefault from "../../layouts/PageDefault.astro";
import PageFullWidth from "../../layouts/PageFullWidth.astro";
const layouts = {
"Default": PageDefault,
"Full Width": PageFullWidth,
};
const Layout = layouts[page.data.template as keyof typeof layouts] ?? PageDefault;
---
<Layout page={page} />
```
## Template Parts
### Include Template Part
```php
// WordPress
<?php get_template_part('template-parts/content', get_post_type()); ?>
// Loads template-parts/content-{post_type}.php
```
```astro
---
// Astro - use components
import PostCard from '../components/PostCard.astro';
import PortfolioCard from '../components/PortfolioCard.astro';
const CardComponent = post.collection === 'portfolio' ? PortfolioCard : PostCard;
---
<CardComponent post={post} />
```
### Reusable Card Component
```php
// WordPress template-parts/content.php
<article id="post-<?php the_ID(); ?>" <?php post_class(); ?>>
<header class="entry-header">
<?php the_title('<h2 class="entry-title"><a href="' . esc_url(get_permalink()) . '">', '</a></h2>'); ?>
</header>
<div class="entry-content">
<?php the_excerpt(); ?>
</div>
</article>
```
```astro
---
// Astro components/PostCard.astro
interface Props {
post: {
id: string;
data: {
title: string;
excerpt?: string;
};
};
}
const { post } = Astro.props;
---
<article id={`post-${post.id}`} class="post">
<header class="entry-header">
<h2 class="entry-title">
<a href={`/posts/${post.id}`}>{post.data.title}</a>
</h2>
</header>
<div class="entry-content">
<p>{post.data.excerpt}</p>
</div>
</article>
```
## Navigation Menus
```php
// WordPress
<?php
wp_nav_menu([
'theme_location' => 'primary',
'container' => 'nav',
'container_class' => 'primary-nav',
]);
?>
```
```astro
---
// Astro/EmDash - First-class menu support
import { getMenu } from "emdash";
const primaryMenu = await getMenu("primary");
---
<nav class="primary-nav">
{primaryMenu && (
<ul>
{primaryMenu.items.map(item => (
<li class={item.cssClasses}>
<a
href={item.url}
target={item.target}
title={item.titleAttr}
>
{item.label}
</a>
{/* Nested items for dropdowns */}
{item.children.length > 0 && (
<ul class="submenu">
{item.children.map(child => (
<li><a href={child.url}>{child.label}</a></li>
))}
</ul>
)}
</li>
))}
</ul>
)}
</nav>
```
### Recursive Menu Component
```astro
---
// components/MenuItem.astro
interface Props {
item: {
label: string;
url: string;
target?: string;
cssClasses?: string;
children: Props['item'][];
};
}
const { item } = Astro.props;
---
<li class={item.cssClasses}>
<a href={item.url} target={item.target}>{item.label}</a>
{item.children.length > 0 && (
<ul class="submenu">
{item.children.map(child => (
<Astro.self item={child} />
))}
</ul>
)}
</li>
```
## Sidebars / Widget Areas
```php
// WordPress
<?php if (is_active_sidebar('sidebar-1')) : ?>
<aside class="sidebar">
<?php dynamic_sidebar('sidebar-1'); ?>
</aside>
<?php endif; ?>
```
```astro
---
// Astro/EmDash - First-class widget area support
import { getWidgetArea, getMenu, getTaxonomyTerms, getEmDashCollection } from "emdash";
import { PortableText } from "emdash/astro";
const sidebar = await getWidgetArea("sidebar");
---
{sidebar && (
<aside class="sidebar">
{sidebar.widgets.map(async (widget) => (
<div class="widget">
{widget.title && <h3 class="widget-title">{widget.title}</h3>}
{/* Content widget - rich text */}
{widget.type === "content" && widget.content && (
<PortableText value={widget.content} />
)}
{/* Menu widget - displays a navigation menu */}
{widget.type === "menu" && widget.menuName && (
<MenuWidget menuName={widget.menuName} />
)}
{/* Component widget - renders a registered component */}
{widget.type === "component" && (
<WidgetComponent
componentId={widget.componentId}
props={widget.componentProps}
/>
)}
</div>
))}
</aside>
)}
```
### Widget Component Handler
```astro
---
// components/WidgetComponent.astro
import { getTaxonomyTerms, getEmDashCollection } from "emdash";
interface Props {
componentId: string;
props?: Record<string, unknown>;
}
const { componentId, props = {} } = Astro.props;
// Handle core widget components
let content = null;
if (componentId === "core:recent-posts") {
const limit = (props.limit as number) || 5;
const collection = (props.collection as string) || "posts";
const { entries: posts } = await getEmDashCollection(collection, { limit });
content = posts;
}
if (componentId === "core:categories") {
const taxonomy = (props.taxonomy as string) || "categories";
content = await getTaxonomyTerms(taxonomy);
}
if (componentId === "core:tag-cloud") {
const taxonomy = (props.taxonomy as string) || "tags";
content = await getTaxonomyTerms(taxonomy);
}
---
{componentId === "core:recent-posts" && content && (
<ul class="recent-posts">
{content.map(post => (
<li><a href={`/posts/${post.data.slug}`}>{post.data.title}</a></li>
))}
</ul>
)}
{componentId === "core:categories" && content && (
<ul class="categories">
{content.map(cat => (
<li>
<a href={`/categories/${cat.slug}`}>
{cat.label}
{props.showCounts && <span>({cat.count})</span>}
</a>
</li>
))}
</ul>
)}
{componentId === "core:tag-cloud" && content && (
<div class="tag-cloud">
{content.map(tag => (
<a href={`/tags/${tag.slug}`} class="tag">{tag.label}</a>
))}
</div>
)}
{componentId === "core:search" && (
<form action="/search" method="get">
<input
type="search"
name="q"
placeholder={props.placeholder || "Search..."}
/>
<button type="submit">Search</button>
</form>
)}
```
## Taxonomy Archives
### Category Archive
```php
// WordPress category.php
<?php
$category = get_queried_object();
?>
<h1><?php echo $category->name; ?></h1>
<p><?php echo $category->description; ?></p>
<?php while (have_posts()) : the_post(); ?>
<!-- post loop -->
<?php endwhile; ?>
```
```astro
---
// Astro pages/categories/[slug].astro
// NOTE: EmDash pages are always server-rendered (no getStaticPaths)
import { getTerm, getEntriesByTerm } from "emdash";
import Base from "../../layouts/Base.astro";
const { slug } = Astro.params;
const category = await getTerm("categories", slug!);
const posts = await getEntriesByTerm("posts", "categories", slug!);
if (!category) {
return Astro.redirect("/404");
}
---
<Base title={category.label}>
<h1>{category.label}</h1>
{category.description && <p>{category.description}</p>}
{posts.map(post => (
<article>
<a href={`/posts/${post.data.slug}`}>{post.data.title}</a>
</article>
))}
</Base>
```
### Tag Archive
```php
// WordPress tag.php
<?php
$tag = get_queried_object();
?>
<h1>Posts tagged: <?php echo $tag->name; ?></h1>
```
```astro
---
// Astro pages/tags/[slug].astro
// NOTE: EmDash pages are always server-rendered (no getStaticPaths)
import { getTerm, getEntriesByTerm } from "emdash";
const { slug } = Astro.params;
const tag = await getTerm("tags", slug!);
const posts = await getEntriesByTerm("posts", "tags", slug!);
if (!tag) {
return Astro.redirect("/404");
}
---
<h1>Posts tagged: {tag.label}</h1>
{posts.map(post => (
<article>
<a href={`/posts/${post.data.slug}`}>{post.data.title}</a>
</article>
))}
```
### Display Post Terms
```php
// WordPress - in single.php
<?php
$categories = get_the_category();
$tags = get_the_tags();
?>
<div class="post-meta">
<span>Categories:
<?php foreach ($categories as $cat) : ?>
<a href="<?php echo get_category_link($cat); ?>"><?php echo $cat->name; ?></a>
<?php endforeach; ?>
</span>
<span>Tags:
<?php the_tags('', ', '); ?>
</span>
</div>
```
```astro
---
// Astro - in post template
import { getEntryTerms } from "emdash";
const categories = await getEntryTerms("posts", post.id, "categories");
const tags = await getEntryTerms("posts", post.id, "tags");
---
<div class="post-meta">
{categories.length > 0 && (
<span>Categories:
{categories.map((cat, i) => (
<>
{i > 0 && ", "}
<a href={`/categories/${cat.slug}`}>{cat.label}</a>
</>
))}
</span>
)}
{tags.length > 0 && (
<span>Tags:
{tags.map((tag, i) => (
<>
{i > 0 && ", "}
<a href={`/tags/${tag.slug}`}>{tag.label}</a>
</>
))}
</span>
)}
</div>
```
### Hierarchical Category List
```php
// WordPress
<?php wp_list_categories(['hierarchical' => true]); ?>
```
```astro
---
// Astro - recursive category tree
import { getTaxonomyTerms } from "emdash";
const categories = await getTaxonomyTerms("categories");
// Recursive component for nested categories
function CategoryTree({ terms }) {
return (
<ul>
{terms.map(term => (
<li>
<a href={`/categories/${term.slug}`}>
{term.label} ({term.count})
</a>
{term.children?.length > 0 && (
<CategoryTree terms={term.children} />
)}
</li>
))}
</ul>
);
}
---
<CategoryTree terms={categories} />
```
## Site Settings
```php
// WordPress
<?php
$site_name = get_bloginfo('name');
$site_desc = get_bloginfo('description');
$custom_logo_id = get_theme_mod('custom_logo');
$logo_url = wp_get_attachment_image_url($custom_logo_id, 'full');
?>
<header>
<?php if ($logo_url) : ?>
<img src="<?php echo $logo_url; ?>" alt="<?php echo $site_name; ?>" />
<?php endif; ?>
<h1><?php echo $site_name; ?></h1>
<p><?php echo $site_desc; ?></p>
</header>
```
```astro
---
// Astro - using EmDash site settings
import { getSiteSettings } from "emdash";
const settings = await getSiteSettings();
---
<header>
{settings.logo?.url && (
<img src={settings.logo.url} alt={settings.logo.alt || settings.title} />
)}
<h1>{settings.title}</h1>
{settings.tagline && <p>{settings.tagline}</p>}
</header>
```
## Comments
```php
// WordPress
<?php
if (comments_open() || get_comments_number()) :
comments_template();
endif;
?>
```
EmDash doesn't include comments. Options:
1. **Giscus** - GitHub Discussions-based
2. **Disqus** - Third-party
3. **Custom** - Build with EmDash collections
```astro
---
// Astro with Giscus
---
<script src="https://giscus.app/client.js"
data-repo="your/repo"
data-repo-id="..."
data-category="Comments"
data-category-id="..."
data-mapping="pathname"
crossorigin="anonymous"
async>
</script>
```
## Search
```php
// WordPress
<?php get_search_form(); ?>
// search.php
<?php if (have_posts()) : ?>
<h1>Search Results for: <?php the_search_query(); ?></h1>
<?php while (have_posts()) : the_post(); ?>
<!-- results -->
<?php endwhile; ?>
<?php else : ?>
<p>No results found.</p>
<?php endif; ?>
```
```astro
---
// Astro pages/search.astro
import { getEmDashCollection } from "emdash";
import Base from "../layouts/Base.astro";
const query = Astro.url.searchParams.get('q') || '';
let results = [];
if (query) {
// Note: Full-text search depends on EmDash implementation
const { entries: posts } = await getEmDashCollection("posts");
results = posts.filter(p =>
p.data.title.toLowerCase().includes(query.toLowerCase())
);
}
---
<Base title={`Search: ${query}`}>
<form action="/search" method="get">
<input type="search" name="q" value={query} />
<button type="submit">Search</button>
</form>
{query && (
<h1>Search Results for: {query}</h1>
{results.length > 0 ? (
results.map(post => (
<!-- results -->
))
) : (
<p>No results found.</p>
)}
)}
</Base>
```
## Custom Fields (ACF-style)
```php
// WordPress with ACF
<?php
$subtitle = get_field('subtitle');
$gallery = get_field('gallery');
?>
<h2><?php echo $subtitle; ?></h2>
<div class="gallery">
<?php foreach ($gallery as $image) : ?>
<img src="<?php echo $image['url']; ?>" />
<?php endforeach; ?>
</div>
```
```astro
---
// Astro - fields are on post.data
const { subtitle, gallery } = post.data;
---
<h2>{subtitle}</h2>
<div class="gallery">
{gallery?.map(image => (
<img src={image.url} />
))}
</div>
```
## Date Formatting
```php
// WordPress
<?php echo get_the_date('F j, Y'); ?> // January 23, 2025
<?php echo human_time_diff(get_the_time('U'), current_time('timestamp')); ?> ago
```
```astro
---
// Astro
const date = post.data.publishedAt;
const formatted = date?.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
// For relative time
const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
const diff = date ? Date.now() - date.getTime() : 0;
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const relative = rtf.format(-days, 'day');
---
<time datetime={date?.toISOString()}>{formatted}</time>
<span>{relative}</span>
```

View File

@@ -0,0 +1,122 @@
# Theme Porting Checklist
Use this checklist to track progress when porting a WordPress theme to EmDash.
## Phase 1: Discovery & Reference Capture
- [ ] Theme source downloaded and unzipped
- [ ] Demo site URL identified
- [ ] Created `discovery/` folder structure:
- [ ] `discovery/screenshots/`
- [ ] `discovery/images/`
- [ ] `discovery/notes.md`
- [ ] Identified all page types in demo
- [ ] Screenshots captured:
- [ ] Homepage (`discovery/screenshots/homepage.png`)
- [ ] Single post (`discovery/screenshots/single-post.png`)
- [ ] Blog archive (`discovery/screenshots/archive.png`)
- [ ] Category archive (`discovery/screenshots/category.png`)
- [ ] Static page (`discovery/screenshots/page.png`)
- [ ] 404 page (`discovery/screenshots/404.png`)
- [ ] Sample images downloaded to `discovery/images/`
- [ ] Design notes documented in `discovery/notes.md`:
- [ ] Colors (background, text, primary, accent, borders)
- [ ] Typography (font families, sizes, line heights)
- [ ] Layout (content width, header height, sidebar position)
- [ ] Special components to recreate
## Phase 2: Design Extraction
- [ ] CSS variables defined in `src/styles/global.css`:
- [ ] Color palette (`--color-base`, `--color-contrast`, `--color-primary`, etc.)
- [ ] Typography (`--font-body`, `--font-heading`, `--font-mono`)
- [ ] Font sizes (`--text-sm` through `--text-5xl`)
- [ ] Spacing scale (`--space-1` through `--space-24`)
- [ ] Layout (`--content-width`, `--wide-width`, `--header-height`)
- [ ] Fonts loading correctly (Google Fonts or local)
- [ ] Color scheme matches original demo
- [ ] Responsive breakpoints defined
## Phase 3: Template Conversion
- [ ] Base layout created (`src/layouts/Base.astro`)
- [ ] Homepage (`src/pages/index.astro`)
- [ ] Single post (`src/pages/posts/[slug].astro`)
- [ ] Blog archive (`src/pages/posts/index.astro`)
- [ ] Category archive (`src/pages/categories/[slug].astro`)
- [ ] Tag archive (`src/pages/tags/[slug].astro`)
- [ ] Static pages (`src/pages/pages/[slug].astro`)
- [ ] 404 page (`src/pages/404.astro`)
- [ ] Reusable components extracted (PostCard, etc.)
## Phase 4: Dynamic Features
- [ ] Site settings configured:
- [ ] Title and tagline
- [ ] Logo (if applicable)
- [ ] Favicon
- [ ] Navigation menus:
- [ ] Primary menu
- [ ] Footer menu (if applicable)
- [ ] Mobile menu (if different)
- [ ] Taxonomies:
- [ ] Categories
- [ ] Tags
- [ ] Custom taxonomies (if any)
- [ ] Widget areas (if applicable):
- [ ] Sidebar
- [ ] Footer widgets
## Phase 5: Create Seed File
- [ ] Seed file created (`.emdash/seed.json`)
- [ ] Collections defined with all fields
- [ ] Taxonomies defined with sample terms
- [ ] Menus defined with items
- [ ] Sample content created:
- [ ] Posts (3-5 with varied content)
- [ ] Pages (About, Contact, etc.)
- [ ] Images use `$media` syntax with `discovery/images/` files
## Phase 6: Verify & Iterate
- [ ] Dev server applied seed cleanly on first request (no validation errors in logs)
- [ ] Dev server running: `pnpm dev`
- [ ] Output screenshots captured to `output/`:
- [ ] Homepage
- [ ] Single post
- [ ] Blog archive
- [ ] Category archive
- [ ] Static page
- [ ] 404 page
- [ ] Visual comparison completed for each page
- [ ] Differences identified and fixed
- [ ] Production build succeeds: `pnpm build`
## License Compliance
- [ ] `README.md` credits original theme
If the original theme is GPL-licensed:
- [ ] `LICENSE` file added (GPL-2.0 text)
- [ ] `package.json` has `"license": "GPL-2.0-or-later"`
## Final Review
- [ ] All pages render without errors
- [ ] Mobile responsive design works
- [ ] Navigation works on all pages
- [ ] Images load correctly
- [ ] Typography matches design intent
- [ ] Colors match design intent
- [ ] No console errors in browser
## No Hard-Coded Content
- [ ] Site title uses `settings.title`, not hard-coded string
- [ ] Site tagline uses `settings.tagline`, not hard-coded string
- [ ] Logo uses `settings.logo`, not hard-coded path
- [ ] Navigation uses `getMenu()`, not hard-coded `<a>` tags
- [ ] Footer content uses site settings or widget areas
- [ ] No placeholder text like "My Blog" or "Lorem ipsum" in templates

View File

@@ -0,0 +1,101 @@
# EmDash Theme Scaffold
This is a minimal, working EmDash theme that demonstrates correct patterns for:
- **Site settings** - Use `getSiteSettings()` for title, tagline, logo - never hard-code
- **Menus** - Use `getMenu()` for navigation - never hard-code links
- **Image fields** - Always access `.src` and `.alt`, never the field directly
- **Taxonomy terms** - Use `getEntryTerms()` without a db parameter
- **PortableText** - Use the `<PortableText>` component from `emdash/ui`
## Critical: No Hard-Coded Content
The theme is a shell that displays CMS content. Never hard-code:
- Site title or tagline (use `settings.title`, `settings.tagline`)
- Navigation links (use `getMenu("primary")`)
- Logo or favicon (use `settings.logo`, `settings.favicon`)
- Footer content (use site settings or widget areas)
## Usage
When porting a WordPress theme:
1. Copy this scaffold to your theme directory
2. Run `pnpm install` from monorepo root
3. Verify it builds: `pnpm --filter your-theme build`
4. Use these templates as reference for correct API usage
## Key Patterns
### Image Fields
```astro
{/* CORRECT - check .src exists */}
{post.data.featured_image?.src && (
<img
src={post.data.featured_image.src}
alt={post.data.featured_image.alt || post.data.title}
/>
)}
{/* WRONG - field is an object, not a string */}
{post.data.featured_image && (
<img src={post.data.featured_image} /> // Renders [object Object]
)}
```
### Taxonomy Terms
```astro
{/* CORRECT - no db parameter */}
const categories = await getEntryTerms("posts", post.id, "categories");
{/* WRONG - db is not a parameter */}
const categories = await getEntryTerms("posts", post.id, "categories", db);
```
### Seed File Images
```json
{
"featured_image": {
"$media": {
"url": "https://example.com/image.jpg",
"alt": "Description",
"filename": "image.jpg"
}
}
}
```
At runtime, this becomes `{ src: "...", alt: "..." }`.
## Files
```
scaffold/
├── package.json # Working dependency versions
├── astro.config.mjs # Minimal config
├── tsconfig.json
├── src/
│ ├── env.d.ts
│ ├── live.config.ts # Collection loader setup
│ ├── styles/global.css # Minimal styles with comments
│ ├── layouts/Base.astro # Header, footer, menus
│ ├── components/
│ │ └── PostCard.astro # Image field handling example
│ └── pages/
│ ├── index.astro
│ ├── 404.astro
│ ├── posts/
│ │ ├── index.astro
│ │ └── [slug].astro # Taxonomy terms example
│ ├── pages/[slug].astro
│ ├── categories/[slug].astro
│ └── tags/[slug].astro
├── public/
│ └── favicon.svg
└── .emdash/
└── seed.json # All field types demonstrated
```

View File

@@ -0,0 +1,27 @@
// @ts-check
import node from "@astrojs/node";
import react from "@astrojs/react";
import { defineConfig } from "astro/config";
import emdash from "emdash/astro";
import { sqlite } from "emdash/db";
export default defineConfig({
output: "server",
adapter: node({ mode: "standalone" }),
integrations: [
react(),
emdash({
database: sqlite({ url: "file:./data.db" }),
}),
],
// Optional: Add custom fonts
// experimental: {
// fonts: [
// {
// provider: "google",
// family: "Inter",
// weights: [400, 500, 600, 700],
// },
// ],
// },
});

View File

@@ -0,0 +1,26 @@
{
"name": "@emdash-cms/theme-scaffold",
"type": "module",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"check": "astro check"
},
"dependencies": {
"@astrojs/node": "^10.0.0-beta.0",
"@astrojs/react": "^5.0.0-beta.1",
"astro": "^6.0.0-beta.0",
"better-sqlite3": "^11.10.0",
"emdash": "workspace:*",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@astrojs/check": "^0.9.6",
"@types/node": "^24.10.9",
"typescript": "^5.9.3"
}
}

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" fill="#0066cc" rx="6"/>
<text x="16" y="22" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="white" text-anchor="middle">E</text>
</svg>

After

Width:  |  Height:  |  Size: 257 B

View File

@@ -0,0 +1,52 @@
---
/**
* PostCard Component
*
* Displays a post preview with optional featured image.
*
* IMPORTANT: Image fields are objects with { src, alt }, not strings!
*/
interface Props {
title: string;
href: string;
date?: string;
excerpt?: string;
// Image fields from EmDash are always { src?: string, alt?: string }
featuredImage?: {
src?: string;
alt?: string;
};
}
const { title, href, date, excerpt, featuredImage } = Astro.props;
const formattedDate = date
? new Date(date).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})
: null;
---
<article class="post-card">
{/* Check featuredImage.src, not just featuredImage */}
{
featuredImage?.src && (
<a href={href} class="post-card-image">
<img src={featuredImage.src} alt={featuredImage.alt || title} loading="lazy" />
</a>
)
}
<div class="post-card-content">
<h2 class="post-card-title">
<a href={href}>{title}</a>
</h2>
{formattedDate && <p class="post-card-meta">{formattedDate}</p>}
{excerpt && <p class="post-card-excerpt">{excerpt}</p>}
</div>
</article>

View File

@@ -0,0 +1,2 @@
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" />

View File

@@ -0,0 +1,72 @@
---
/**
* Base Layout
*
* Main layout with header, footer, and navigation from EmDash menus.
*/
import { getMenu, getSiteSettings } from "emdash";
import "../styles/global.css";
interface Props {
title?: string;
description?: string;
}
const { title, description } = Astro.props;
// These APIs automatically get the database from the Astro context
const settings = await getSiteSettings();
const primaryMenu = await getMenu("primary");
const footerMenu = await getMenu("footer");
const siteTitle = settings.title || "My Site";
const pageTitle = title ? `${title} | ${siteTitle}` : siteTitle;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{pageTitle}</title>
{description && <meta name="description" content={description} />}
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</head>
<body>
<header class="site-header">
<div class="container">
<a href="/" class="site-title">{siteTitle}</a>
{
primaryMenu && primaryMenu.items.length > 0 && (
<nav class="site-nav">
{primaryMenu.items.map((item) => (
<a href={item.url}>{item.label}</a>
))}
</nav>
)
}
</div>
</header>
<main>
<slot />
</main>
<footer class="site-footer">
<div class="container">
{
footerMenu && footerMenu.items.length > 0 && (
<nav class="footer-nav">
{footerMenu.items.map((item) => (
<a href={item.url}>{item.label}</a>
))}
</nav>
)
}
<p class="copyright">&copy; {new Date().getFullYear()} {siteTitle}</p>
</div>
</footer>
</body>
</html>

View File

@@ -0,0 +1,19 @@
/**
* EmDash Live Config
*
* This file defines your content collections using EmDash's loader.
* It replaces Astro's content collections for CMS-managed content.
*/
import { defineCollection } from "astro:content";
import { emdashLoader } from "emdash";
// Posts collection - loaded from EmDash CMS
export const collections = {
posts: defineCollection({
loader: emdashLoader({ collection: "posts" }),
}),
pages: defineCollection({
loader: emdashLoader({ collection: "pages" }),
}),
};

View File

@@ -0,0 +1,17 @@
---
/**
* 404 Page
*/
import Base from "../layouts/Base.astro";
---
<Base title="Page Not Found">
<div class="container">
<div class="content-width" style="text-align: center; padding: 4rem 0;">
<h1>404</h1>
<p class="text-muted">The page you're looking for doesn't exist.</p>
<p><a href="/">Go home</a></p>
</div>
</div>
</Base>

View File

@@ -0,0 +1,57 @@
---
/**
* Category Archive
*
* Demonstrates:
* - getTerm for fetching taxonomy term details
* - getEntriesByTerm for entries with a specific term
*/
import { getTerm, getEntriesByTerm } from "emdash";
import Base from "../../layouts/Base.astro";
import PostCard from "../../components/PostCard.astro";
const { slug } = Astro.params;
const category = await getTerm("categories", slug!);
const posts = await getEntriesByTerm("posts", "categories", slug!);
if (!category) {
return Astro.redirect("/404");
}
---
<Base title={category.label}>
<div class="container">
<div class="content-width">
<header class="archive-header">
<h1>{category.label}</h1>
</header>
{
posts.length > 0 ? (
<div class="posts-list">
{posts.map((post) => (
<PostCard
title={post.data.title}
href={`/posts/${post.data.slug || post.id}`}
date={post.data.published_at}
excerpt={post.data.excerpt}
featuredImage={
post.data.featured_image?.src
? {
src: post.data.featured_image.src,
alt: post.data.featured_image.alt || post.data.title,
}
: undefined
}
/>
))}
</div>
) : (
<p class="text-muted">No posts in this category.</p>
)
}
</div>
</div>
</Base>

View File

@@ -0,0 +1,54 @@
---
/**
* Homepage / Blog Index
*
* Demonstrates:
* - getEmDashCollection for listing entries
* - Passing image fields correctly to components
*/
import { getEmDashCollection } from "emdash";
import Base from "../layouts/Base.astro";
import PostCard from "../components/PostCard.astro";
const { entries: posts } = await getEmDashCollection("posts", {
status: "published",
limit: 10,
});
---
<Base>
<div class="container">
<div class="content-width">
{
posts.length > 0 ? (
<div class="posts-list">
{posts.map((post) => (
<PostCard
title={post.data.title}
href={`/posts/${post.data.slug || post.id}`}
date={post.data.published_at}
excerpt={post.data.excerpt}
{
/*
IMPORTANT: featured_image is { src, alt }, not a string!
Pass the whole object, or extract src/alt explicitly.
*/ }
featuredImage={
post.data.featured_image?.src
? {
src: post.data.featured_image.src,
alt: post.data.featured_image.alt || post.data.title,
}
: undefined
}
/>
))}
</div>
) : (
<p class="text-muted">No posts yet.</p>
)
}
</div>
</div>
</Base>

View File

@@ -0,0 +1,35 @@
---
/**
* Single Page
*/
import { getEmDashEntry } from "emdash";
import { PortableText } from "emdash/ui";
import Base from "../../layouts/Base.astro";
const { slug } = Astro.params;
const { entry: page, error } = await getEmDashEntry("pages", slug!);
if (error) {
return new Response("Server error", { status: 500 });
}
if (!page) {
return Astro.redirect("/404");
}
---
<Base title={page.data.title}>
<article class="container">
<div class="content-width">
<header class="page-header">
<h1>{page.data.title}</h1>
</header>
<div class="prose">
{page.data.content && <PortableText value={page.data.content} />}
</div>
</div>
</article>
</Base>

View File

@@ -0,0 +1,99 @@
---
/**
* Single Post
*
* Demonstrates:
* - getEmDashEntry for fetching a single entry
* - getEntryTerms for taxonomy terms (NO db parameter!)
* - PortableText component for rich content
* - Proper image field access
*/
import { getEmDashEntry, getEntryTerms } from "emdash";
import { PortableText } from "emdash/ui";
import Base from "../../layouts/Base.astro";
const { slug } = Astro.params;
const { entry: post, error } = await getEmDashEntry("posts", slug!);
if (error) {
return new Response("Server error", { status: 500 });
}
if (!post) {
return Astro.redirect("/404");
}
// Get taxonomy terms - NOTE: no db parameter!
const categories = await getEntryTerms("posts", post.id, "categories");
const tags = await getEntryTerms("posts", post.id, "tags");
const formattedDate = post.data.published_at
? new Date(post.data.published_at).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})
: null;
---
<Base title={post.data.title} description={post.data.excerpt}>
<article class="container">
<div class="content-width">
<header class="post-header">
<h1>{post.data.title}</h1>
<div class="post-meta">
{
formattedDate && (
<time datetime={post.data.published_at}>{formattedDate}</time>
)
}
{
categories.length > 0 && (
<span class="post-categories">
in{" "}
{categories.map((cat, i) => (
<>
{i > 0 && ", "}
<a href={`/categories/${cat.slug}`}>{cat.label}</a>
</>
))}
</span>
)
}
</div>
</header>
{/* IMPORTANT: Check .src, not just the field */}
{
post.data.featured_image?.src && (
<div class="post-featured-image">
<img
src={post.data.featured_image.src}
alt={post.data.featured_image.alt || post.data.title}
/>
</div>
)
}
<div class="prose">
{post.data.content && <PortableText value={post.data.content} />}
</div>
{
tags.length > 0 && (
<div class="post-tags">
{tags.map((tag) => (
<a href={`/tags/${tag.slug}`} class="tag">
{tag.label}
</a>
))}
</div>
)
}
</div>
</article>
</Base>

View File

@@ -0,0 +1,48 @@
---
/**
* Posts Archive
*/
import { getEmDashCollection } from "emdash";
import Base from "../../layouts/Base.astro";
import PostCard from "../../components/PostCard.astro";
const { entries: posts } = await getEmDashCollection("posts", {
status: "published",
});
---
<Base title="Blog">
<div class="container">
<div class="content-width">
<header class="archive-header">
<h1>Blog</h1>
</header>
{
posts.length > 0 ? (
<div class="posts-list">
{posts.map((post) => (
<PostCard
title={post.data.title}
href={`/posts/${post.data.slug || post.id}`}
date={post.data.published_at}
excerpt={post.data.excerpt}
featuredImage={
post.data.featured_image?.src
? {
src: post.data.featured_image.src,
alt: post.data.featured_image.alt || post.data.title,
}
: undefined
}
/>
))}
</div>
) : (
<p class="text-muted">No posts found.</p>
)
}
</div>
</div>
</Base>

View File

@@ -0,0 +1,54 @@
---
/**
* Tag Archive
*/
import { getTerm, getEntriesByTerm } from "emdash";
import Base from "../../layouts/Base.astro";
import PostCard from "../../components/PostCard.astro";
const { slug } = Astro.params;
const tag = await getTerm("tags", slug!);
const posts = await getEntriesByTerm("posts", "tags", slug!);
if (!tag) {
return Astro.redirect("/404");
}
---
<Base title={`Tagged: ${tag.label}`}>
<div class="container">
<div class="content-width">
<header class="archive-header">
<p class="text-muted">Tagged</p>
<h1>{tag.label}</h1>
</header>
{
posts.length > 0 ? (
<div class="posts-list">
{posts.map((post) => (
<PostCard
title={post.data.title}
href={`/posts/${post.data.slug || post.id}`}
date={post.data.published_at}
excerpt={post.data.excerpt}
featuredImage={
post.data.featured_image?.src
? {
src: post.data.featured_image.src,
alt: post.data.featured_image.alt || post.data.title,
}
: undefined
}
/>
))}
</div>
) : (
<p class="text-muted">No posts with this tag.</p>
)
}
</div>
</div>
</Base>

View File

@@ -0,0 +1,274 @@
/**
* Global Styles - Scaffold Theme
*
* Minimal styles for demonstration. Replace with your theme's design tokens.
*/
/* Reset */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* Variables - replace with your theme's tokens */
:root {
--color-text: #1a1a1a;
--color-text-muted: #666;
--color-bg: #fff;
--color-border: #e5e5e5;
--color-accent: #0066cc;
--font-body: system-ui, -apple-system, sans-serif;
--font-mono: ui-monospace, monospace;
--space-sm: 0.5rem;
--space-md: 1rem;
--space-lg: 2rem;
--space-xl: 4rem;
--content-width: 40rem;
--container-width: 60rem;
}
/* Base */
html {
font-family: var(--font-body);
font-size: 16px;
line-height: 1.6;
color: var(--color-text);
background: var(--color-bg);
}
body {
min-height: 100vh;
display: flex;
flex-direction: column;
}
main {
flex: 1;
padding: var(--space-xl) var(--space-md);
}
/* Layout */
.container {
max-width: var(--container-width);
margin: 0 auto;
padding: 0 var(--space-md);
}
.content-width {
max-width: var(--content-width);
}
/* Header */
.site-header {
padding: var(--space-md) 0;
border-bottom: 1px solid var(--color-border);
}
.site-header .container {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
}
.site-title {
font-size: 1.25rem;
font-weight: 600;
text-decoration: none;
color: inherit;
}
.site-nav {
display: flex;
gap: var(--space-md);
}
.site-nav a {
color: var(--color-text-muted);
text-decoration: none;
}
.site-nav a:hover {
color: var(--color-text);
}
/* Footer */
.site-footer {
padding: var(--space-lg) 0;
border-top: 1px solid var(--color-border);
color: var(--color-text-muted);
font-size: 0.875rem;
}
.footer-nav {
display: flex;
gap: var(--space-md);
margin-bottom: var(--space-sm);
}
.footer-nav a {
color: inherit;
}
/* Typography */
h1,
h2,
h3,
h4,
h5,
h6 {
line-height: 1.3;
margin-bottom: var(--space-sm);
}
h1 {
font-size: 2rem;
}
h2 {
font-size: 1.5rem;
}
h3 {
font-size: 1.25rem;
}
p {
margin-bottom: var(--space-md);
}
a {
color: var(--color-accent);
}
code {
font-family: var(--font-mono);
font-size: 0.9em;
background: var(--color-border);
padding: 0.1em 0.3em;
border-radius: 3px;
}
/* Images */
img {
max-width: 100%;
height: auto;
display: block;
}
/* Post Card */
.post-card {
margin-bottom: var(--space-lg);
padding-bottom: var(--space-lg);
border-bottom: 1px solid var(--color-border);
}
.post-card:last-child {
border-bottom: none;
}
.post-card-image {
margin-bottom: var(--space-md);
}
.post-card-image img {
border-radius: 4px;
}
.post-card-title {
font-size: 1.25rem;
margin-bottom: var(--space-sm);
}
.post-card-title a {
color: inherit;
text-decoration: none;
}
.post-card-title a:hover {
color: var(--color-accent);
}
.post-card-meta {
font-size: 0.875rem;
color: var(--color-text-muted);
margin-bottom: var(--space-sm);
}
.post-card-excerpt {
color: var(--color-text-muted);
}
/* Single Post/Page */
.post-header,
.page-header {
margin-bottom: var(--space-lg);
}
.post-meta {
font-size: 0.875rem;
color: var(--color-text-muted);
display: flex;
gap: var(--space-md);
flex-wrap: wrap;
}
.post-featured-image {
margin-bottom: var(--space-lg);
}
.post-featured-image img {
border-radius: 4px;
}
/* Taxonomy terms */
.post-categories a,
.post-tags a {
color: var(--color-text-muted);
}
.post-tags {
margin-top: var(--space-lg);
display: flex;
gap: var(--space-sm);
flex-wrap: wrap;
}
.tag {
font-size: 0.875rem;
padding: var(--space-sm) var(--space-md);
background: var(--color-border);
border-radius: 4px;
text-decoration: none;
color: var(--color-text-muted);
}
.tag:hover {
background: var(--color-text);
color: var(--color-bg);
}
/* Prose (PortableText content) */
.prose > * + * {
margin-top: var(--space-md);
}
.prose h2,
.prose h3,
.prose h4 {
margin-top: var(--space-lg);
}
/* Archive header */
.archive-header {
margin-bottom: var(--space-lg);
}
/* Utility */
.text-muted {
color: var(--color-text-muted);
}

View File

@@ -0,0 +1,8 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"strictNullChecks": true
},
"include": ["src/**/*", ".astro/**/*"],
"exclude": ["node_modules", "dist"]
}