Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/src/content/docs/concepts/collections.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Create collections through the admin panel under **Content Types**. Each collect
| `description` | Optional description for editors |
| `icon` | Lucide icon name for the admin sidebar |
| `supports` | Features like drafts, revisions, preview, scheduling, search, seo |
| `urlPattern` | URL template for public routing (e.g., `/blog/{slug}`) |

<Aside type="note">
Some collection slugs are reserved: `content`, `media`, `users`, `revisions`, `taxonomies`,
Expand Down Expand Up @@ -50,6 +51,43 @@ The following collection enables all four features:
}
```

## URL patterns

The `urlPattern` property defines the public URL shape for entries in a collection. It uses `{slug}` as a placeholder for the entry's slug.

```ts
{
slug: "posts",
label: "Blog Posts",
urlPattern: "/blog/{slug}"
}
```

When set, this pattern is used in:

- **Menu links** — menu items referencing entries in the collection generate URLs using the pattern.
- **Preview buttons** — the admin "View" and "Preview" links use the pattern to build public URLs.

Without a `urlPattern`, URLs fallback to `/{collection}/{slug}` (e.g., `/posts/hello-world`).

### Pattern syntax

Patterns use the `{slug}` placeholder, which is replaced with the entry's slug. The admin UI requires `{slug}` to be present.

| Pattern | Example URL |
| ------- | ----------- |
| `/{slug}` | `/about` |
| `/blog/{slug}` | `/blog/hello-world` |
| `/articles/{slug}` | `/articles/my-post` |

### Auto-redirects

When you change an entry's slug, EmDash creates an automatic 301 redirect from the old URL to the new one. The redirect uses the collection's `urlPattern` to compute both the old and new URLs.

<Aside type="tip">
Re-run <code>emdash types</code> after modifying URL patterns — while the generated types don't include the pattern itself, the pattern affects how you structure your Astro routes.
</Aside>

## Field types

EmDash supports 16 field types that map to SQLite column types.
Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/themes/seed-files.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ Each collection definition creates a content type in the database:
| `description` | `string` | No | Admin UI description |
| `icon` | `string` | No | Lucide icon name |
| `supports` | `array` | No | Features: `"drafts"`, `"revisions"` |
| `urlPattern` | `string` | No | URL template for public routing (e.g. `/blog/{slug}`) |
| `fields` | `array` | Yes | Field definitions |

### Field Properties
Expand Down
1 change: 1 addition & 0 deletions templates/marketing-cloudflare/seed/seed.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"slug": "pages",
"label": "Pages",
"labelSingular": "Page",
"urlPattern": "/{slug}",
"supports": ["drafts", "revisions", "seo"],
"fields": [
{
Expand Down
18 changes: 11 additions & 7 deletions templates/marketing-cloudflare/src/layouts/Base.astro
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,15 @@ import "../styles/theme.css";

interface Props {
title?: string;
description?: string;
image?: string;
pageTitle?: string | null;
description?: string | null;
image?: string | null;
canonical?: string | null;
/** Pass content reference for plugin page contributions on content pages */
content?: { collection: string; id: string; slug?: string | null };
}

const { title, description, image } = Astro.props;
const { title, pageTitle, description, image, canonical, content } = Astro.props;
const settings = await getSiteSettings();
const siteTitle = settings?.title || "Acme";
const fullTitle = title ? `${title} — ${siteTitle}` : siteTitle;
Expand All @@ -35,14 +39,14 @@ const footerColumns = [

const pageCtx = createPublicPageContext({
Astro,
kind: "custom",
kind: content ? "content" : "custom",
pageType: "website",
title: fullTitle,
pageTitle: title ?? siteTitle,
pageTitle: pageTitle ?? title ?? siteTitle,
description: description || siteDescription,
canonical: Astro.url.href,
canonical,
image,
seo: { ogImage: image },
content,
siteName: siteTitle,
});
---
Expand Down
38 changes: 38 additions & 0 deletions templates/marketing-cloudflare/src/pages/[slug].astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
import { getEmDashEntry, getSeoMeta, decodeSlug, getSiteSettings } from "emdash";
import Base from "../layouts/Base.astro";
import MarketingBlocks from "../components/MarketingBlocks.astro";

const slug = decodeSlug(Astro.params.slug);

if (!slug) {
return Astro.redirect("/404");
}

const { entry: page, cacheHint } = await getEmDashEntry("pages", slug);

if (!page) {
return Astro.redirect("/404");
}

if (Astro.cache?.enabled) Astro.cache.set(cacheHint);

const settings = await getSiteSettings();
const siteTitle = settings?.title || "Acme";

const seo = getSeoMeta(page, {
siteTitle,
siteUrl: Astro.url.origin,
path: `/${slug}`,
});

const pageContent = page.data.content;
---

<Base
title={seo.title}
description={seo.description}
Comment on lines +33 to +34
content={{ collection: "pages", id: page.data.id, slug }}
>
{pageContent && <MarketingBlocks value={pageContent} />}
</Base>
1 change: 1 addition & 0 deletions templates/marketing/seed/seed.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"slug": "pages",
"label": "Pages",
"labelSingular": "Page",
"urlPattern": "/{slug}",
"supports": ["drafts", "revisions", "seo"],
"fields": [
{
Expand Down
18 changes: 11 additions & 7 deletions templates/marketing/src/layouts/Base.astro
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,15 @@ import "../styles/theme.css";

interface Props {
title?: string;
description?: string;
image?: string;
pageTitle?: string | null;
description?: string | null;
image?: string | null;
canonical?: string | null;
/** Pass content reference for plugin page contributions on content pages */
content?: { collection: string; id: string; slug?: string | null };
}

const { title, description, image } = Astro.props;
const { title, pageTitle, description, image, canonical, content } = Astro.props;
const settings = await getSiteSettings();
const siteTitle = settings?.title || "Acme";
const fullTitle = title ? `${title} — ${siteTitle}` : siteTitle;
Expand All @@ -35,14 +39,14 @@ const footerColumns = [

const pageCtx = createPublicPageContext({
Astro,
kind: "custom",
kind: content ? "content" : "custom",
pageType: "website",
title: fullTitle,
pageTitle: title ?? siteTitle,
pageTitle: pageTitle ?? title ?? siteTitle,
description: description || siteDescription,
canonical: Astro.url.href,
canonical,
image,
seo: { ogImage: image },
content,
siteName: siteTitle,
});
---
Expand Down
38 changes: 38 additions & 0 deletions templates/marketing/src/pages/[slug].astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
import { getEmDashEntry, getSeoMeta, decodeSlug, getSiteSettings } from "emdash";
import Base from "../layouts/Base.astro";
import MarketingBlocks from "../components/MarketingBlocks.astro";

const slug = decodeSlug(Astro.params.slug);

if (!slug) {
return Astro.redirect("/404");
}

const { entry: page, cacheHint } = await getEmDashEntry("pages", slug);

if (!page) {
return Astro.redirect("/404");
}

if (Astro.cache?.enabled) Astro.cache.set(cacheHint);

const settings = await getSiteSettings();
const siteTitle = settings?.title || "Acme";

const seo = getSeoMeta(page, {
siteTitle,
siteUrl: Astro.url.origin,
path: `/${slug}`,
});

const pageContent = page.data.content;
---

<Base
title={seo.title}
description={seo.description}
Comment on lines +33 to +34
content={{ collection: "pages", id: page.data.id, slug }}
>
{pageContent && <MarketingBlocks value={pageContent} />}
</Base>
2 changes: 1 addition & 1 deletion templates/portfolio-cloudflare/emdash-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export interface Project {
slug: string | null;
status: string;
title: string;
featured_image: { id: string; src?: string; alt?: string; width?: number; height?: number };
featured_image: { id: string; src?: string; alt?: string; width?: number; height?: number; provider?: string; previewUrl?: string; meta?: Record<string, unknown> };
client?: string;
year?: string;
summary?: string;
Expand Down
2 changes: 2 additions & 0 deletions templates/portfolio-cloudflare/seed/seed.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"slug": "projects",
"label": "Projects",
"labelSingular": "Project",
"urlPattern": "/work/{slug}",
"supports": ["drafts", "revisions", "search", "seo"],
"fields": [
{
Expand Down Expand Up @@ -68,6 +69,7 @@
"slug": "pages",
"label": "Pages",
"labelSingular": "Page",
"urlPattern": "/{slug}",
"supports": ["drafts", "revisions", "search"],
"fields": [
{
Expand Down
20 changes: 12 additions & 8 deletions templates/portfolio-cloudflare/src/layouts/Base.astro
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,17 @@ import { Font } from "astro:assets";
import "../styles/theme.css";

interface Props {
title?: string;
description?: string;
image?: string;
title?: string | null;
pageTitle?: string | null;
description?: string | null;
image?: string | null;
canonical?: string | null;
type?: "website" | "article";
/** Pass content reference for plugin page contributions on content pages */
content?: { collection: string; id: string; slug?: string | null };
}

const { title, description, image, type = "website" } = Astro.props;
const { title, pageTitle, description, image, canonical, type = "website", content } = Astro.props;
const settings = await getSiteSettings();
const siteTitle = settings?.title || "Studio";
const fullTitle = title ? `${title} — ${siteTitle}` : siteTitle;
Expand All @@ -24,14 +28,14 @@ const menu = await getMenu("primary");

const pageCtx = createPublicPageContext({
Astro,
kind: "custom",
kind: content ? "content" : "custom",
pageType: type,
title: fullTitle,
pageTitle: title ?? siteTitle,
pageTitle: pageTitle ?? title ?? siteTitle,
description: description || siteDescription,
canonical: Astro.url.href,
canonical,
image,
seo: { ogImage: image },
content,
siteName: siteTitle,
});
---
Expand Down
100 changes: 100 additions & 0 deletions templates/portfolio-cloudflare/src/pages/[slug].astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
---
import { getEmDashEntry, getSeoMeta, decodeSlug, getSiteSettings } from "emdash";
import { PortableText } from "emdash/ui";
import Base from "../layouts/Base.astro";

const slug = decodeSlug(Astro.params.slug);

if (!slug) {
return Astro.redirect("/404");
}

const { entry: page, cacheHint } = await getEmDashEntry("pages", slug);

if (!page) {
return Astro.redirect("/404");
}

if (Astro.cache?.enabled) Astro.cache.set(cacheHint);

const settings = await getSiteSettings();
const siteTitle = settings?.title || "Studio";

const seo = getSeoMeta(page, {
siteTitle,
siteUrl: Astro.url.origin,
path: `/${slug}`,
});
---

<Base
title={seo.title}
description={seo.description}
Comment on lines +31 to +32
content={{ collection: "pages", id: page.data.id, slug }}
>
<div class="page-content">
<header class="page-header">
<h1 class="page-title" {...page.edit.title}>{page.data.title}</h1>
</header>

<div class="page-body" {...page.edit.content}>
{
page.data.content ? (
<PortableText value={page.data.content} />
) : (
<p>Content coming soon.</p>
)
}
</div>
</div>
</Base>

<style>
.page-content {
max-width: var(--wide-width);
margin: 0 auto;
padding: var(--spacing-2xl) var(--spacing-lg) var(--spacing-4xl);
}

.page-header {
margin-bottom: var(--spacing-2xl);
}

.page-title {
font-family: var(--font-serif);
font-size: var(--font-size-4xl);
font-weight: 500;
line-height: 1.1;
}

.page-body {
font-size: var(--font-size-base);
line-height: 1.7;
}

.page-body :global(p) {
margin-bottom: 1.5em;
}

.page-body :global(h2) {
font-family: var(--font-serif);
font-size: var(--font-size-2xl);
font-weight: 500;
margin-top: 2.5em;
margin-bottom: 0.75em;
}

.page-body :global(h3) {
font-family: var(--font-serif);
font-size: var(--font-size-xl);
font-weight: 500;
margin-top: 2em;
margin-bottom: 0.5em;
}

@media (max-width: 768px) {
.page-title {
font-size: var(--font-size-3xl);
}
}
</style>
Loading
Loading