Astro 개발자를 위한 EmDash

이 페이지

EmDash는 Astro 전용으로 만든 CMS이며, Astro 어댑터가 붙은 일반 헤드리스 CMS가 아닙니다. 데이터베이스-backed 콘텐츠, 다듬어진 관리 UI, WordPress 스타일 기능(메뉴, 위젯, 분류체계)으로 Astro 사이트를 확장하면서도 기대하는 개발 경험을 유지합니다.

Astro에 대해 아는 내용은 그대로 적용됩니다. EmDash는 사이트를 보강할 뿐, 워크플로를 대체하지 않습니다.

EmDash가 더하는 것

파일 기반 Astro 사이트에 부족한 콘텐츠 관리 기능을 EmDash가 제공합니다.

기능설명
Admin UI/_emdash/admin의 전체 WYSIWYG 편집 UI
Database storageSQLite, libSQL 또는 Cloudflare D1에 콘텐츠 저장
Media library이미지와 파일 업로드·정리·제공
Navigation menus드래그 앤 드롭과 중첩이 있는 메뉴 관리
Widget areas동적 사이드바와 푸터 영역
Site settings전역 설정(제목, 로고, 소셜 링크)
Taxonomies카테고리, 태그, 사용자 정의 분류체계
Preview system초안용 서명된 미리보기 URL
Revisions콘텐츠 버전 기록

Astro Collections vs EmDash

Astro의 astro:content 컬렉션은 파일 기반이며 빌드 시점에 해석됩니다. EmDash 컬렉션은 데이터베이스-backed이며 런타임에 해석됩니다.

Astro CollectionsEmDash Collections
Storagesrc/content/의 Markdown/MDX 파일SQLite/D1 데이터베이스
Editing코드 편집기Admin UI
Content format프론트매터가 있는 MarkdownPortable Text(구조화된 JSON)
Updates재빌드 필요즉시(SSR)
Schemacontent.config.ts의 Zod관리 UI에서 정의, DB에 저장
Best for개발자가 관리하는 콘텐츠편집자가 관리하는 콘텐츠

함께 사용하기

Astro 컬렉션과 EmDash는 공존할 수 있습니다. 개발자 콘텐츠(문서, 변경 로그)는 Astro, 편집 콘텐츠(블로그 글, 페이지)는 EmDash에 둡니다.

---
import { getCollection } from "astro:content";
import { getEmDashCollection } from "emdash";

// Developer-managed docs from files
const docs = await getCollection("docs");

// Editor-managed posts from database
const { entries: posts } = await getEmDashCollection("posts", {
  status: "published",
  limit: 5,
});
---

구성

EmDash에는 구성 파일이 두 개 필요합니다.

Astro 통합

import { defineConfig } from "astro/config";
import emdash, { local } from "emdash/astro";
import { sqlite } from "emdash/db";

export default defineConfig({
	output: "server", // Required for EmDash
	integrations: [
		emdash({
			database: sqlite({ url: "file:./data.db" }),
			storage: local({
				directory: "./uploads",
				baseUrl: "/_emdash/api/media/file",
			}),
		}),
	],
});

Live Collections Loader

import { defineLiveCollection } from "astro:content";
import { emdashLoader } from "emdash/runtime";

export const collections = {
	_emdash: defineLiveCollection({
		loader: emdashLoader(),
	}),
};

이렇게 EmDash를 라이브 콘텐츠 소스로 등록합니다. _emdash 컬렉션은 내부적으로 콘텐츠 타입(posts, pages, products)으로 라우팅합니다.

콘텐츠 조회

EmDash는 Astro의 live content collections 패턴을 따르는 쿼리 함수를 제공하며 { entries, error } 또는 { entry, error }를 반환합니다.

EmDash

import { getEmDashCollection, getEmDashEntry } from "emdash";

// Get all published posts - returns { entries, error }
const { entries: posts } = await getEmDashCollection("posts", {
status: "published",
});

// Get a single post by slug - returns { entry, error, isPreview }
const { entry: post } = await getEmDashEntry("posts", "my-post");

Astro

import { getCollection, getEntry } from "astro:content";

// Get all blog entries
const posts = await getCollection("blog");

// Get a single entry by slug
const post = await getEntry("blog", "my-post");

필터 옵션

getEmDashCollection은 Astro의 getCollection에 없는 필터를 지원합니다.

const { entries: posts } = await getEmDashCollection("posts", {
	status: "published", // draft | published | archived
	limit: 10, // max results
	where: { category: "news" }, // taxonomy filter
});

콘텐츠 렌더링

EmDash는 리치 텍스트를 구조화된 JSON 형식인 Portable Text로 저장합니다. PortableText 컴포넌트로 렌더링합니다.

EmDash

---
import { getEmDashEntry } from "emdash";
import { PortableText } from "emdash/ui";

const { slug } = Astro.params;
const { entry: post } = await getEmDashEntry("posts", slug);

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

---

<article>
  <h1>{post.data.title}</h1>
  <PortableText value={post.data.content} />
</article>

Astro

---
import { getEntry, render } from "astro:content";

const { slug } = Astro.params;
const post = await getEntry("blog", slug);
const { Content } = await render(post);

---

<article>
  <h1>{post.data.title}</h1>
  <Content />
</article>

동적 기능

EmDash는 Astro 콘텐츠 레이어에 없는 WordPress 스타일 기능을 위한 API를 제공합니다.

내비게이션 메뉴

---
import { getMenu } from "emdash";

const primaryMenu = await getMenu("primary");
---

{primaryMenu && (
  <nav>
    <ul>
      {primaryMenu.items.map(item => (
        <li>
          <a href={item.url}>{item.label}</a>
          {item.children.length > 0 && (
            <ul>
              {item.children.map(child => (
                <li><a href={child.url}>{child.label}</a></li>
              ))}
            </ul>
          )}
        </li>
      ))}
    </ul>
  </nav>
)}

위젯 영역

---
import { getWidgetArea } from "emdash";
import { PortableText } from "emdash/ui";

const sidebar = await getWidgetArea("sidebar");
---

{sidebar && sidebar.widgets.length > 0 && (
  <aside>
    {sidebar.widgets.map(widget => (
      <div class="widget">
        {widget.title && <h3>{widget.title}</h3>}
        {widget.type === "content" && widget.content && (
          <PortableText value={widget.content} />
        )}
      </div>
    ))}
  </aside>
)}

사이트 설정

---
import { getSiteSettings, getSiteSetting } from "emdash";

const settings = await getSiteSettings();
// Or fetch individual values:
const title = await getSiteSetting("title");
---

<header>
  {settings.logo ? (
    <img src={settings.logo.url} alt={settings.title} />
  ) : (
    <span>{settings.title}</span>
  )}
  {settings.tagline && <p>{settings.tagline}</p>}
</header>

플러그인

훅, 스토리지, 설정, 관리 UI를 추가하는 플러그인으로 EmDash를 확장합니다.

import emdash from "emdash/astro";
import seoPlugin from "@emdash-cms/plugin-seo";

export default defineConfig({
	integrations: [
		emdash({
			// ...
			plugins: [seoPlugin({ generateSitemap: true })],
		}),
	],
});

definePlugin으로 사용자 정의 플러그인을 만듭니다.

import { definePlugin } from "emdash";

export default definePlugin({
	id: "analytics",
	version: "1.0.0",
	capabilities: ["read:content"],

	hooks: {
		"content:afterSave": async (event, ctx) => {
			ctx.log.info("Content saved", { id: event.content.id });
		},
	},

	admin: {
		settingsSchema: {
			trackingId: { type: "string", label: "Tracking ID" },
		},
	},
});

서버 렌더링

EmDash 사이트는 SSR 모드로 실행됩니다. 콘텐츠 변경은 리빌드 없이 바로 반영됩니다.

getStaticPaths가 있는 정적 페이지는 빌드 시점에 콘텐츠를 가져옵니다.

---
import { getEmDashCollection, getEmDashEntry } from "emdash";

export async function getStaticPaths() {
  const { entries: posts } = await getEmDashCollection("posts", {
    status: "published",
  });

  return posts.map((post) => ({
    params: { slug: post.data.slug },
  }));
}

const { slug } = Astro.params;
const { entry: post } = await getEmDashEntry("posts", slug);
---

동적 페이지는 prerender = false로 요청마다 콘텐츠를 가져옵니다.

---
export const prerender = false;

import { getEmDashEntry } from "emdash";

const { slug } = Astro.params;
const { entry: post, error } = await getEmDashEntry("posts", slug);

if (error) {
  return new Response("Server error", { status: 500 });
}

if (!post) {
  return new Response(null, { status: 404 });
}
---

다음 단계