EmDash 테마는 완전한 Astro 사이트입니다 — 페이지, 레이아웃, 컴포넌트, 스타일 — 콘텐츠 모델을 부트스트랩하기 위한 시드 파일도 포함됩니다. 디자인을 다른 사람과 공유하거나 에이전시의 사이트 생성을 표준화하기 위해 테마를 만드세요.
핵심 개념
- 테마는 작동하는 Astro 프로젝트입니다. 테마 API나 추상화 레이어가 없습니다. 사이트를 구축하고 템플릿으로 배포합니다. 시드 파일은 EmDash에 첫 실행 시 어떤 컬렉션, 필드, 메뉴, 리디렉션, 택소노미를 생성할지 알려줄 뿐입니다.
- EmDash는 WordPress보다 콘텐츠 모델에 대한 더 많은 제어를 제공합니다. 테마는 이를 활용합니다 — 시드 파일은 각 컬렉션에 필요한 필드를 정확히 선언합니다. 표준 posts와 pages 컬렉션을 기반으로 디자인에 필요한 필드와 택소노미를 추가하세요. 완전히 새로운 콘텐츠 타입을 만드는 대신에요.
- 테마 콘텐츠 페이지는 서버 렌더링이어야 합니다. 테마에서는 관리 UI를 통해 런타임에 콘텐츠가 변경되므로, EmDash 콘텐츠를 표시하는 페이지는 프리렌더링되면 안 됩니다. 테마 콘텐츠 라우트에서
getStaticPaths()를 사용하지 마세요. (빌드 타임 데이터 소스로 EmDash를 사용하는 정적 사이트 빌드는getStaticPaths를 사용할 수 있지만, 테마는 항상 SSR입니다.) - 하드코딩된 콘텐츠 없음. 사이트 제목, 태그라인, 내비게이션 및 기타 동적 콘텐츠는 템플릿 문자열이 아닌 API 호출을 통해 CMS에서 가져옵니다.
프로젝트 구조
다음 구조로 테마를 만드세요:
my-emdash-theme/
├── package.json # 테마 메타데이터
├── astro.config.mjs # Astro + EmDash 구성
├── src/
│ ├── live.config.ts # Live Collections 설정
│ ├── pages/
│ │ ├── index.astro # 홈페이지
│ │ ├── [...slug].astro # 페이지 (catch-all)
│ │ ├── posts/
│ │ │ ├── index.astro # 포스트 아카이브
│ │ │ └── [slug].astro # 단일 포스트
│ │ ├── categories/
│ │ │ └── [slug].astro # 카테고리 아카이브
│ │ ├── tags/
│ │ │ └── [slug].astro # 태그 아카이브
│ │ ├── search.astro # 검색 페이지
│ │ └── 404.astro # 404 페이지
│ ├── layouts/
│ │ └── Base.astro # 기본 레이아웃
│ └── components/ # 컴포넌트
├── .emdash/
│ ├── seed.json # 스키마와 샘플 콘텐츠
│ └── uploads/ # 선택적 로컬 미디어 파일
└── public/ # 정적 에셋
페이지는 루트에 catch-all 라우트([...slug].astro)로 위치하므로, about 슬러그를 가진 페이지는 /about에 렌더링됩니다. 포스트, 카테고리, 태그는 각각의 디렉토리를 가집니다. .emdash/ 디렉토리에는 시드 파일과 샘플 콘텐츠에서 사용되는 로컬 미디어 파일이 포함됩니다.
package.json 구성
package.json에 emdash 필드를 추가합니다:
{
"name": "@your-org/emdash-theme-blog",
"version": "1.0.0",
"description": "A minimal blog theme for EmDash",
"keywords": ["astro-template", "emdash", "blog"],
"emdash": {
"label": "Minimal Blog",
"description": "A clean, minimal blog with posts, pages, and categories",
"seed": ".emdash/seed.json",
"preview": "https://your-theme-demo.pages.dev"
}
}
| 필드 | 설명 |
|---|---|
emdash.label | 테마 선택기에 표시되는 이름 |
emdash.description | 테마에 대한 간략한 설명 |
emdash.seed | 시드 파일 경로 |
emdash.preview | 라이브 데모 URL (선택 사항) |
기본 콘텐츠 모델
대부분의 테마는 두 가지 컬렉션 타입이 필요합니다: posts와 pages. 포스트는 발췌문과 대표 이미지가 있는 타임스탬프 항목으로 피드와 아카이브에 나타납니다. 페이지는 최상위 URL의 독립 콘텐츠입니다.
이것이 권장 출발점입니다. 테마에 필요한 대로 더 많은 컬렉션, 택소노미, 필드를 추가하되 여기서 시작하세요.
시드 파일
시드 파일은 EmDash에 첫 실행 시 무엇을 생성할지 알려줍니다. .emdash/seed.json을 생성합니다:
{
"$schema": "https://emdashcms.com/seed.schema.json",
"version": "1",
"meta": {
"name": "Minimal Blog",
"description": "A clean blog with posts and pages",
"author": "Your Name"
},
"settings": {
"title": "My Blog",
"tagline": "Thoughts and ideas",
"postsPerPage": 10
},
"collections": [
{
"slug": "posts",
"label": "Posts",
"labelSingular": "Post",
"supports": ["drafts", "revisions"],
"fields": [
{ "slug": "title", "label": "Title", "type": "string", "required": true },
{ "slug": "content", "label": "Content", "type": "portableText" },
{ "slug": "excerpt", "label": "Excerpt", "type": "text" },
{ "slug": "featured_image", "label": "Featured Image", "type": "image" }
]
},
{
"slug": "pages",
"label": "Pages",
"labelSingular": "Page",
"supports": ["drafts", "revisions"],
"fields": [
{ "slug": "title", "label": "Title", "type": "string", "required": true },
{ "slug": "content", "label": "Content", "type": "portableText" }
]
}
],
"taxonomies": [
{
"name": "category",
"label": "Categories",
"labelSingular": "Category",
"hierarchical": true,
"collections": ["posts"],
"terms": [
{ "slug": "news", "label": "News" },
{ "slug": "tutorials", "label": "Tutorials" }
]
}
],
"menus": [
{
"name": "primary",
"label": "Primary Navigation",
"items": [
{ "type": "custom", "label": "Home", "url": "/" },
{ "type": "custom", "label": "Blog", "url": "/posts" }
]
}
],
"redirects": [
{ "source": "/category/news", "destination": "/categories/news" },
{ "source": "/old-about", "destination": "/about" }
]
}
포스트는 목록과 피드에 나타나므로 excerpt와 featured_image를 가집니다. 페이지는 독립 콘텐츠이므로 필요하지 않습니다. 테마에 필요한 대로 어느 컬렉션에든 필드를 추가하세요.
섹션, 위젯 영역, 미디어 참조를 포함한 전체 명세는 시드 파일 형식을 참조하세요.
페이지 구축
EmDash 콘텐츠를 표시하는 모든 페이지는 서버 렌더링됩니다. Astro.params를 사용하여 URL에서 슬러그를 가져오고 요청 시점에 콘텐츠를 쿼리합니다.
홈페이지
---
import { getEmDashCollection, getSiteSettings } from "emdash";
import Base from "../layouts/Base.astro";
const settings = await getSiteSettings();
const { entries: posts } = await getEmDashCollection("posts", {
where: { status: "published" },
orderBy: { publishedAt: "desc" },
limit: settings.postsPerPage ?? 10,
});
---
<Base title="Home">
<h1>Latest Posts</h1>
{posts.map((post) => (
<article>
<h2><a href={`/posts/${post.slug}`}>{post.data.title}</a></h2>
<p>{post.data.excerpt}</p>
</article>
))}
</Base>
단일 포스트
---
import { getEmDashEntry, getEntryTerms } from "emdash";
import { PortableText } from "emdash/ui";
import Base from "../../layouts/Base.astro";
const { slug } = Astro.params;
const { entry: post } = await getEmDashEntry("posts", slug!);
if (!post) {
return Astro.redirect("/404");
}
const categories = await getEntryTerms("posts", post.id, "categories");
---
<Base title={post.data.title}>
<article>
<h1>{post.data.title}</h1>
<PortableText value={post.data.content} />
<div class="post-meta">
{categories.map((cat) => (
<a href={`/categories/${cat.slug}`}>{cat.label}</a>
))}
</div>
</article>
</Base>
페이지
페이지는 루트에 catch-all 라우트를 사용하여 슬러그가 최상위 URL에 직접 매핑됩니다 — about 슬러그를 가진 페이지는 /about에 렌더링됩니다:
---
import { getEmDashEntry } from "emdash";
import { PortableText } from "emdash/ui";
import Base from "../layouts/Base.astro";
const { slug } = Astro.params;
const { entry: page } = await getEmDashEntry("pages", slug!);
if (!page) {
return Astro.redirect("/404");
}
---
<Base title={page.data.title}>
<article>
<h1>{page.data.title}</h1>
<PortableText value={page.data.content} />
</article>
</Base>
catch-all 라우트이므로 더 구체적인 라우트가 없는 URL만 매칭합니다. /posts/hello-world는 이 파일이 아닌 posts/[slug].astro에 도달합니다.
카테고리 아카이브
---
import { getTerm, getEntriesByTerm } from "emdash";
import Base from "../../layouts/Base.astro";
const { slug } = Astro.params;
const category = await getTerm("categories", slug!);
const posts = await getEntriesByTerm("posts", "categories", slug!);
if (!category) {
return Astro.redirect("/404");
}
---
<Base title={category.label}>
<h1>{category.label}</h1>
{posts.map((post) => (
<article>
<h2><a href={`/posts/${post.slug}`}>{post.data.title}</a></h2>
</article>
))}
</Base>
이미지 사용
이미지 필드는 문자열이 아닌 src와 alt 속성을 가진 객체입니다. 최적화된 이미지 렌더링을 위해 emdash/ui의 Image 컴포넌트를 사용하세요:
---
import { Image } from "emdash/ui";
const { post } = Astro.props;
---
<article>
{post.data.featured_image?.src && (
<Image
image={post.data.featured_image}
alt={post.data.featured_image.alt || post.data.title}
width={800}
height={450}
/>
)}
<h2><a href={`/posts/${post.slug}`}>{post.data.title}</a></h2>
<p>{post.data.excerpt}</p>
</article>
메뉴 사용
레이아웃에서 관리자 정의 메뉴를 쿼리합니다. 내비게이션 링크를 하드코딩하지 마세요:
---
import { getMenu, getSiteSettings } from "emdash";
const settings = await getSiteSettings();
const primaryMenu = await getMenu("primary");
---
<html>
<head>
<title>{Astro.props.title} | {settings.title}</title>
</head>
<body>
<header>
{settings.logo ? (
<img src={settings.logo.url} alt={settings.title} />
) : (
<span>{settings.title}</span>
)}
<nav>
{primaryMenu?.items.map((item) => (
<a href={item.url}>{item.label}</a>
))}
</nav>
</header>
<main>
<slot />
</main>
</body>
</html>
페이지 템플릿
테마는 종종 여러 페이지 레이아웃이 필요합니다 — 기본 레이아웃, 전체 너비 레이아웃, 랜딩 페이지 레이아웃. EmDash에서는 페이지 컬렉션에 template 선택 필드를 추가하고 catch-all 라우트에서 레이아웃 컴포넌트에 매핑합니다.
시드 파일의 페이지 컬렉션에 필드를 추가합니다:
{
"slug": "template",
"label": "Page Template",
"type": "string",
"widget": "select",
"options": {
"choices": [
{ "value": "default", "label": "Default" },
{ "value": "full-width", "label": "Full Width" },
{ "value": "landing", "label": "Landing Page" }
]
},
"defaultValue": "default"
}
그런 다음 catch-all 라우트에서 값을 레이아웃 컴포넌트에 매핑합니다:
---
import { getEmDashEntry } from "emdash";
import PageDefault from "../layouts/PageDefault.astro";
import PageFullWidth from "../layouts/PageFullWidth.astro";
import PageLanding from "../layouts/PageLanding.astro";
const { slug } = Astro.params;
const { entry: page } = await getEmDashEntry("pages", slug!);
if (!page) {
return Astro.redirect("/404");
}
const layouts = {
"default": PageDefault,
"full-width": PageFullWidth,
"landing": PageLanding,
};
const Layout = layouts[page.data.template as keyof typeof layouts] ?? PageDefault;
---
<Layout page={page} />
편집자는 페이지를 편집할 때 관리 UI의 드롭다운에서 템플릿을 선택합니다.
섹션 추가
섹션은 편집자가 /section 슬래시 명령을 사용하여 모든 Portable Text 필드에 삽입할 수 있는 재사용 가능한 콘텐츠 블록입니다. 테마에 일반적인 콘텐츠 패턴(히어로 배너, CTA, 기능 그리드)이 있다면 시드 파일에서 섹션으로 정의합니다:
{
"sections": [
{
"slug": "hero-centered",
"title": "Centered Hero",
"description": "Full-width hero with centered heading and CTA",
"keywords": ["hero", "banner", "header", "landing"],
"content": [
{
"_type": "block",
"style": "h1",
"children": [{ "_type": "span", "text": "Welcome to Our Site" }]
},
{
"_type": "block",
"children": [
{ "_type": "span", "text": "Your compelling tagline goes here." }
]
}
]
},
{
"slug": "newsletter-cta",
"title": "Newsletter Signup",
"keywords": ["newsletter", "subscribe", "email"],
"content": [
{
"_type": "block",
"style": "h3",
"children": [{ "_type": "span", "text": "Subscribe to our newsletter" }]
},
{
"_type": "block",
"children": [
{
"_type": "span",
"text": "Get the latest updates delivered to your inbox."
}
]
}
]
}
]
}
시드 파일에서 생성된 섹션은 source: "theme"으로 표시됩니다. 편집자는 자체 섹션(source: "user")을 만들 수도 있지만, 테마 제공 섹션은 관리 UI에서 삭제할 수 없습니다.
샘플 콘텐츠 추가
시드 파일에 샘플 콘텐츠를 포함하여 테마의 디자인을 시연합니다:
{
"content": {
"posts": [
{
"id": "hello-world",
"slug": "hello-world",
"status": "published",
"data": {
"title": "Hello World",
"content": [
{
"_type": "block",
"style": "normal",
"children": [{ "_type": "span", "text": "Welcome to your new blog!" }]
}
],
"excerpt": "Your first post on EmDash."
},
"taxonomies": {
"category": ["news"]
}
}
]
}
}
미디어 포함
$media 구문을 사용하여 샘플 콘텐츠에서 이미지를 참조합니다.
원격 이미지의 경우:
{
"data": {
"featured_image": {
"$media": {
"url": "https://images.unsplash.com/photo-xxx",
"alt": "A descriptive alt text",
"filename": "hero.jpg"
}
}
}
}
로컬 이미지의 경우, .emdash/uploads/에 파일을 배치하고 참조합니다:
{
"data": {
"featured_image": {
"$media": {
"file": "hero.jpg",
"alt": "A descriptive alt text"
}
}
}
}
시딩 중에 미디어 파일은 다운로드(또는 로컬에서 읽기)되어 스토리지에 업로드됩니다.
검색
테마에 검색 페이지가 포함되어 있다면 LiveSearch 컴포넌트를 사용하여 즉시 결과를 표시합니다:
---
import LiveSearch from "emdash/ui/search";
import Base from "../layouts/Base.astro";
---
<Base title="Search">
<h1>Search</h1>
<LiveSearch
placeholder="Search posts and pages..."
collections={["posts", "pages"]}
/>
</Base>
LiveSearch는 접두사 매칭, Porter 스테밍, 강조된 결과 스니펫을 갖춘 디바운스된 즉시 검색을 제공합니다. 검색은 관리 UI에서 컬렉션별로 활성화해야 합니다 (콘텐츠 타입 > 편집 > 기능 > 검색).
테마 테스트
-
테마에서 테스트 프로젝트를 생성합니다:
npm create astro@latest -- --template ./path/to/my-theme -
종속성을 설치하고 개발 서버를 시작합니다:
cd test-site npm install npm run dev -
http://localhost:4321/_emdash/admin에서 설정 마법사를 완료합니다 -
컬렉션, 메뉴, 리디렉션, 콘텐츠가 올바르게 생성되었는지 확인합니다
-
모든 페이지 템플릿이 올바르게 렌더링되는지 테스트합니다
-
관리를 통해 새 콘텐츠를 생성하여 모든 필드가 작동하는지 확인합니다
테마 게시
배포를 위해 npm에 게시합니다:
npm publish --access public
사용자는 다음과 같이 테마를 설치할 수 있습니다:
npm create astro@latest -- --template @your-org/emdash-theme-blog
GitHub 호스팅 테마의 경우:
npm create astro@latest -- --template github:your-org/emdash-theme-blog
사용자 정의 Portable Text 블록
테마는 특수한 콘텐츠를 위한 사용자 정의 Portable Text 블록 타입을 정의할 수 있습니다. 마케팅 페이지, 랜딩 페이지 또는 표준 리치 텍스트 이상의 구조화된 컴포넌트가 필요한 콘텐츠에 유용합니다.
시드 콘텐츠에서 사용자 정의 블록 정의
시드 파일의 Portable Text 콘텐츠에서 네임스페이스된 _type을 사용합니다:
{
"content": {
"pages": [
{
"id": "home",
"slug": "home",
"status": "published",
"data": {
"title": "Home",
"content": [
{
"_type": "marketing.hero",
"headline": "Build something amazing",
"subheadline": "The all-in-one platform for modern teams.",
"primaryCta": { "label": "Get Started", "url": "/signup" }
},
{
"_type": "marketing.features",
"_key": "features",
"headline": "Everything you need",
"features": [
{
"icon": "zap",
"title": "Lightning fast",
"description": "Built for speed."
}
]
}
]
}
}
]
}
}
블록 컴포넌트 생성
각 사용자 정의 블록 타입에 대한 Astro 컴포넌트를 생성합니다:
---
interface Props {
value: {
headline: string;
subheadline?: string;
primaryCta?: { label: string; url: string };
};
}
const { value } = Astro.props;
---
<section class="hero">
<h1>{value.headline}</h1>
{value.subheadline && <p>{value.subheadline}</p>}
{value.primaryCta && (
<a href={value.primaryCta.url} class="btn">
{value.primaryCta.label}
</a>
)}
</section>
사용자 정의 블록 렌더링
PortableText 컴포넌트에 사용자 정의 블록 컴포넌트를 전달합니다:
---
import { PortableText } from "emdash/ui";
import Hero from "./blocks/Hero.astro";
import Features from "./blocks/Features.astro";
interface Props {
value: unknown[];
}
const { value } = Astro.props;
const marketingTypes = {
"marketing.hero": Hero,
"marketing.features": Features,
};
---
<PortableText value={value} components={{ types: marketingTypes }} />
그런 다음 페이지에서 사용합니다:
---
import { getEmDashEntry } from "emdash";
import MarketingBlocks from "../components/MarketingBlocks.astro";
const { entry: page } = await getEmDashEntry("pages", "home");
---
<MarketingBlocks value={page.data.content} />
내비게이션용 앵커 ID
링크 가능해야 하는 블록에 _key를 추가합니다:
{
"_type": "marketing.features",
"_key": "features",
"headline": "Features"
}
그런 다음 컴포넌트에서 앵커로 사용합니다:
<section id={value._key}>
<!-- content -->
</section>
이렇게 하면 /#features와 같은 내비게이션 링크가 가능해집니다.
테마 체크리스트
게시 전에 테마에 다음이 포함되어 있는지 확인하세요:
-
emdash필드가 있는package.json(라벨, 설명, 시드 경로) - 유효한 스키마가 있는
.emdash/seed.json - 페이지에서 참조하는 모든 컬렉션이 시드에 존재
- 레이아웃에서 사용하는 메뉴가 시드에 정의됨
- 테마의 디자인을 시연하는 샘플 콘텐츠
- 데이터베이스와 스토리지 구성이 있는
astro.config.mjs - EmDash 로더가 있는
src/live.config.ts - 콘텐츠 페이지에
getStaticPaths()없음 - 하드코딩된 사이트 제목, 태그라인, 내비게이션 없음
- 이미지 필드를 문자열이 아닌 객체(
image.src)로 접근 - 설정 지침이 있는 README
- 비표준 Portable Text 타입을 위한 사용자 정의 블록 컴포넌트
다음 단계
- 시드 파일 형식 — 시드 파일의 전체 참조
- 테마 개요 — EmDash에서 테마가 작동하는 방식
- WordPress 테마 포팅 — 기존 WordPress 테마 변환