first commit

This commit is contained in:
Matt Kane
2026-04-01 10:44:22 +01:00
commit 43fcb9a131
1789 changed files with 395041 additions and 0 deletions

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,516 @@
# 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
### Seed Validation
Validate seed files before applying:
```bash
# Validate default seed file (.emdash/seed.json)
emdash seed --validate
# Validate a specific file
emdash seed path/to/seed.json --validate
```
Catches common mistakes:
- 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.)
### Apply Seed
```bash
# Apply seed with content
emdash seed
# Apply seed without sample content
emdash seed --no-content
# Specify database path
emdash seed --database ./my-data.db
```
### 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>
```