Files
kunthawat 2d1be52177 Emdash source with visual editor image upload fix
Fixes:
1. media.ts: wrap placeholder generation in try-catch
2. toolbar.ts: check r.ok, display error message in popover
2026-05-03 10:44:54 +07:00

11 KiB

Site Features

Site Settings

import { getSiteSettings, 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");

Available keys: title, tagline, logo, favicon, social, timezone, dateFormat.

Use these instead of hard-coding site name, logo, etc.

Navigation Menus

import { getMenu, getMenus } from "emdash";

// Fetch a named menu
const menu = await getMenu("primary");

// List all menus
const menus = await getMenus();

Rendering a menu

---
import { getMenu } from "emdash";
const primaryMenu = await getMenu("primary");
---
<nav>
	{primaryMenu?.items.map(item => (
		<a href={item.url} target={item.target}>{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, 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");

// 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 = await getEntryTerms("posts", post.data.id, "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>

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,
});
// { results: SearchResult[], total, nextCursor? }

Each result has: collection, id, title, slug, snippet (HTML with <mark> highlights), 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

Generate SEO meta from content entries:

import { getSeoMeta } from "emdash";

const seo = getSeoMeta(post, {
	siteTitle: "My Blog",
	siteUrl: Astro.url.origin,
	path: `/posts/${slug}`,
	defaultOgImage: featuredImageUrl, // Optional fallback
});

// Returns: { title, description, canonical, ogImage, robots }

Use in your layout's <head>:

<title>{seo.title}</title>
<meta name="description" content={seo.description} />
<link rel="canonical" href={seo.canonical} />
<meta property="og:image" content={seo.ogImage} />
{seo.robots && <meta name="robots" content={seo.robots} />}

Comments

Built-in comments system:

---
import { Comments, CommentForm } from "emdash/ui";
---
<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>