pnpm create emdash@latest potluck --pm pnpm --install --yes, per https://docs.emdashcms.com/ ("Create your first EmDash site"); the --yes defaults are cloudflare + blog. Replaces the old madmode/ starter (never committed). Generated-by: create-emdash 1.2.0, run by Claude Opus 5.5
13 KiB
Site Features
Site Settings
import { getSiteSettings, getSiteSettingsWithCacheHint, getSiteSetting } from "emdash";
// All settings
const settings = await getSiteSettings();
settings.title; // "My Site"
settings.tagline; // "A description"
settings.logo?.url; // Resolved media URL
settings.favicon?.url;
// Single setting
const title = await getSiteSetting("title");
// Cached route: register invalidation for settings changes
const { data: cachedSettings, cacheHint } = await getSiteSettingsWithCacheHint();
if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
Available keys: title, tagline, logo, favicon, social, timezone, dateFormat.
Use these instead of hard-coding site name, logo, etc.
Navigation Menus
import { getMenu, getMenuWithCacheHint, getMenus } from "emdash";
// Fetch a named menu
const menu = await getMenu("primary");
// List all menus
const menus = await getMenus();
// Cached route
const { data: primaryMenu, cacheHint } = await getMenuWithCacheHint("primary");
if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
Rendering a menu
---
import { getMenu } from "emdash";
const primaryMenu = await getMenu("primary");
---
<nav>
{primaryMenu?.items.map(item => (
<a
href={item.url}
target={item.target}
rel={item.target === "_blank" ? "noopener noreferrer" : undefined}
>{item.label}</a>
))}
</nav>
Nested menus (dropdowns)
{primaryMenu?.items.map(item => (
<li>
<a href={item.url}>{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>
))}
MenuItem shape
interface MenuItem {
id: string;
label: string;
url: string; // Resolved URL
target?: string; // "_blank" etc.
children: MenuItem[];
}
Taxonomies
import {
getTaxonomyTerms,
getTaxonomyTermsWithCacheHint,
getTerm,
getEntryTerms,
getEntriesByTerm,
} from "emdash";
// All terms in a taxonomy (name must match your seed's "name" field exactly)
const categories = await getTaxonomyTerms("category");
const tags = await getTaxonomyTerms("tag");
// Cached taxonomy archive or facet
const { data: cachedCategories, cacheHint } = await getTaxonomyTermsWithCacheHint("category", {
includeCounts: false,
});
if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
// Single term by slug
const term = await getTerm("category", "news");
// { id, name, slug, label, children, count }
// Terms for a specific entry (use data.id, not entry.id!)
const postCategories = await getEntryTerms("posts", post.data.id, "category");
const postTags = await getEntryTerms("posts", post.data.id, "tag");
// Entries with a specific term
const newsPosts = await getEntriesByTerm("posts", "category", "news");
Important: The taxonomy name argument must match exactly what your seed defines in "name". The blog seed uses "category" and "tag" (singular). Using "categories" returns empty results with no error.
Important: getEntryTerms takes the database ULID (post.data.id), not the slug (post.id).
Displaying post terms
---
const tags = post.data.terms?.tag ?? [];
---
{tags.map(t => (
<a href={`/tag/${t.slug}`}>{t.label}</a>
))}
Filtering by taxonomy
---
const { entries: posts } = await getEmDashCollection("posts", {
where: { category: term.slug },
orderBy: { published_at: "desc" },
});
---
Widget Areas
Render a named widget area:
---
import { WidgetArea } from "emdash/ui";
---
<aside>
<WidgetArea name="sidebar" />
</aside>
WidgetArea registers its widget-area cache hint when Astro's route cache is enabled.
The WidgetArea component automatically renders all widgets in the area (search, categories, tags, recent posts, rich text, etc.) with appropriate HTML and CSS classes.
Manual widget rendering
For more control, use the getWidgetArea function:
---
import { getWidgetArea } from "emdash";
import { PortableText } from "emdash/ui";
const sidebar = await getWidgetArea("sidebar");
---
{sidebar?.widgets.map(widget => (
<div class="widget">
{widget.title && <h3>{widget.title}</h3>}
{widget.type === "content" && widget.content && (
<PortableText value={widget.content} />
)}
</div>
))}
Search
LiveSearch component (instant search)
---
import LiveSearch from "emdash/ui/search";
---
<LiveSearch
placeholder="Search..."
collections={["posts", "pages"]}
/>
Customizable CSS classes:
<LiveSearch
placeholder="Search..."
class="site-search"
inputClass="site-search-input"
resultsClass="site-search-results"
resultClass="site-search-result"
collections={["posts", "pages"]}
expandOnFocus={{ collapsed: "180px", expanded: "280px" }}
/>
Theme via CSS variables:
:root {
--emdash-search-bg: var(--color-bg);
--emdash-search-text: var(--color-text);
--emdash-search-muted: var(--color-muted);
--emdash-search-border: var(--color-border);
--emdash-search-hover: var(--color-surface);
--emdash-search-highlight: var(--color-text);
}
Programmatic search
import { search } from "emdash";
const results = await search("hello world", {
collections: ["posts", "pages"],
status: "published",
limit: 20,
});
// { items: SearchResult[], nextCursor? }
Each item has: collection, id, title, slug, locale, snippet (sanitized HTML with <mark> highlights), and score.
Search page
---
import LiveSearch from "emdash/ui/search";
import Base from "../layouts/Base.astro";
const query = Astro.url.searchParams.get("q") || "";
---
<Base title="Search">
<h1>Search</h1>
<LiveSearch
placeholder="Search posts..."
collections={["posts", "pages"]}
/>
</Base>
Keyboard shortcut
Add Cmd+K / Ctrl+K to focus search:
<script>
document.addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
document.querySelector(".site-search-input")?.focus();
}
});
</script>
Search prerequisites
Search requires per-collection enablement:
- In admin: Edit Content Type -> check "Search" in Features
- Mark fields as
"searchable": truein the seed file - Only searchable fields of searchable collections are indexed
SEO Meta
Important
On server-rendered content pages that fetch the entry with
getEmDashEntry()and render<EmDashHead>, EmDash automatically applies the SEO panel's description, image, canonical URL, and noindex setting. The panel title supplies the social and JSON-LD title contributions. The panel data uses the entry query the page already runs, so it adds no additional query.
<EmDashHead>cannot set the document<title>, so usegetSeoMeta()for the title. Prerendered pages, pages without<EmDashHead>, hand-rolled query paths, and multi-entry collection results do not receive this overlay. UsegetSeoMeta()or the raw SEO data on those paths.
Use getSeoMeta() when the template needs to set <title> or the page does not
receive the automatic overlay:
import { getSeoMeta } from "emdash";
const seo = getSeoMeta(post, {
siteTitle: "My Blog",
siteUrl: Astro.url.origin,
path: `/posts/${slug}`,
defaultOgImage: featuredImageUrl, // Optional fallback
});
Use in your layout's <head>:
<title>{seo.title}</title>
{seo.description && <meta name="description" content={seo.description} />}
{seo.canonical && <link rel="canonical" href={seo.canonical} />}
{seo.ogImage && <meta property="og:image" content={seo.ogImage} />}
{seo.robots && <meta name="robots" content={seo.robots} />}
Custom title and description defaults
Pass computed fallbacks through defaultTitle and defaultDescription. Values
set in the SEO panel take precedence over these defaults:
import { getSeoMeta } from "emdash";
const seo = getSeoMeta(post, {
defaultTitle: `${post.data.title}: A practical guide`,
defaultDescription: `Read ${post.data.title} on My Blog.`,
});
Comments
Built-in comments system:
---
import { Comments, CommentForm } from "emdash/ui/comments";
---
<Comments collection="posts" contentId={post.data.id} threaded />
<CommentForm collection="posts" contentId={post.data.id} />
Comments are enabled per-collection in the seed: "commentsEnabled": true.
Page Contributions (Plugin Head/Body Injection)
Plugins can inject content into the <head> and <body> of pages. To support this, use the page contribution components:
---
import { EmDashHead, EmDashBodyStart, EmDashBodyEnd } from "emdash/ui";
import { createPublicPageContext } from "emdash/page";
const pageCtx = createPublicPageContext({
Astro,
kind: content ? "content" : "custom",
pageType: "article",
title: fullTitle,
pageTitle: post.data.title,
description,
canonical,
image,
content: { collection: "posts", id: post.data.id, slug },
});
---
<html>
<head>
<!-- your meta tags -->
<EmDashHead page={pageCtx} />
</head>
<body>
<EmDashBodyStart page={pageCtx} />
<!-- your content -->
<EmDashBodyEnd page={pageCtx} />
</body>
</html>
This enables plugins (analytics, tracking pixels, structured data, etc.) to contribute to any page.
Bylines
Bylines are author profiles, independent of user accounts. They support guest authors and multi-author attribution with role labels.
Eagerly loaded on entries
Bylines are automatically attached to every entry by the query layer:
{/* Primary author */}
{post.data.byline && (
<span>{post.data.byline.displayName}</span>
)}
{/* All credits (includes roleLabel for co-authors, guest essays, etc.) */}
{post.data.bylines?.map(credit => (
<span>
{credit.byline.displayName}
{credit.roleLabel && <em> ({credit.roleLabel})</em>}
</span>
))}
entry.data.byline-- primaryBylineSummaryornullentry.data.bylines-- array ofContentBylineCredit(each has.byline,.roleLabel,.source)
Standalone query functions
import { getByline, getBylineBySlug } from "emdash";
// Look up a specific byline
const byline = await getBylineBySlug("jane-doe");
BylineSummary shape
interface BylineSummary {
id: string;
slug: string;
displayName: string;
bio: string | null;
avatarMediaId: string | null;
websiteUrl: string | null;
isGuest: boolean;
}
ContentBylineCredit shape
interface ContentBylineCredit {
byline: BylineSummary;
sortOrder: number;
roleLabel: string | null; // e.g., "Guest essay", "Photographer"
source?: "explicit" | "inferred"; // "inferred" = fallback from author_id
}
Dark Mode Pattern
Cookie-based theme switching (no flash on load):
<!-- In <head>, before styles load -->
<script is:inline>
(function () {
var c = document.cookie;
var i = c.indexOf("theme=");
var theme = i >= 0 ? c.slice(i + 6).split(";")[0] : null;
if (theme === "dark" || theme === "light") {
document.documentElement.classList.add(theme);
} else if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
document.documentElement.classList.add("dark");
}
})();
</script>
Then use CSS variables that change based on .dark class:
:root {
--color-bg: #ffffff;
--color-text: #1a1a1a;
}
:root.dark {
--color-bg: #0d0d0d;
--color-text: #ededed;
}
Layout Pattern
A typical base layout:
---
import { getMenu, getEmDashCollection } from "emdash";
import { WidgetArea, EmDashHead, EmDashBodyStart, EmDashBodyEnd } from "emdash/ui";
import { createPublicPageContext } from "emdash/page";
import LiveSearch from "emdash/ui/search";
interface Props {
title: string;
description?: string | null;
image?: string | null;
content?: { collection: string; id: string; slug?: string | null };
}
const { title, pageTitle, description, image, content } = Astro.props;
const menu = await getMenu("primary");
const pageCtx = createPublicPageContext({
Astro,
kind: content ? "content" : "custom",
pageType: "website",
title,
pageTitle: pageTitle ?? title,
description,
image,
content,
});
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
{description && <meta name="description" content={description} />}
<EmDashHead page={pageCtx} />
</head>
<body>
<EmDashBodyStart page={pageCtx} />
<header>
<nav>
<a href="/">My Site</a>
<LiveSearch placeholder="Search..." collections={["posts", "pages"]} />
{menu?.items.map(item => (
<a href={item.url}>{item.label}</a>
))}
</nav>
</header>
<main>
<slot />
</main>
<footer>
<WidgetArea name="footer" />
</footer>
<EmDashBodyEnd page={pageCtx} />
</body>
</html>