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,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] Same regression as the non-Cloudflare marketing template: removing the default Astro.url.href means existing pages and the new [slug].astro route lose canonical links and og:url meta tags because no caller passes the canonical prop.

Restore the fallback:

Suggested change
canonical,
canonical: canonical ?? Astro.url.href,

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 }}
>
Comment on lines +32 to +36
{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,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] Removing the default canonical: Astro.url.href causes a regression: all existing pages (contact.astro, pricing.astro, index.astro) and the new [slug].astro dynamic pages will no longer emit <link rel="canonical"> or og:url meta tags, because none of them pass a canonical prop to Base. Previously every page had a canonical URL derived from Astro.url.href.

Restore the fallback so existing pages keep working while still allowing overrides via the new prop:

Suggested change
canonical,
canonical: canonical ?? Astro.url.href,

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 }}
>
Comment on lines +32 to +36
{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,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] Same regression as the non-Cloudflare portfolio template: removing the default Astro.url.href drops canonical links and og:url tags for all pages that don't explicitly pass a canonical prop.

Restore the fallback:

Suggested change
canonical,
canonical: canonical ?? Astro.url.href,

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 }}
>
Comment on lines +30 to +34
<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