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

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>
```