Files
captoys/potluck/.agents/skills/building-emdash-site/references/site-features.md
T
dckc 4b4ef8fea6 capsite: potluck, a fresh EmDash site (cloudflare:blog out of the box)
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
2026-10-07 19:57:43 -05:00

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>
))}
---
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);
}
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:

  1. In admin: Edit Content Type -> check "Search" in Features
  2. Mark fields as "searchable": true in the seed file
  3. 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 use getSeoMeta() for the title. Prerendered pages, pages without <EmDashHead>, hand-rolled query paths, and multi-entry collection results do not receive this overlay. Use getSeoMeta() 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 -- primary BylineSummary or null
  • entry.data.bylines -- array of ContentBylineCredit (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>