EmDash 테마는 콘텐츠 모델을 부트스트랩하기 위한 시드 파일도 포함하는 완전한 Astro 사이트(페이지, 레이아웃, 컴포넌트, 스타일)입니다. 다른 사람과 디자인을 공유하거나 대행사의 사이트 생성을 표준화하기 위해 하나를 구축하세요.
주요 개념
- 테마는 작동하는 Astro 프로젝트입니다. 테마 API나 추상화 레이어가 없습니다. 테마는 템플릿으로 제공되는 사이트입니다. 시드 파일은 첫 실행 시 생성할 컬렉션, 필드, 메뉴, 리디렉션 및 분류법을 EmDash에 알려줍니다.
- 시드 파일은 콘텐츠 모델을 선언합니다. 각 컬렉션에 필요한 필드를 정확히 나열합니다. 완전히 새로운 콘텐츠 유형을 만들기보다는 표준 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 # 찾을 수 없음
│ ├── 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>
Pages
페이지는 루트에서 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에서는 pages 컬렉션에 template 선택 필드를 추가하고 catch-all 라우트에서 레이아웃 컴포넌트에 매핑합니다.
시드 파일의 pages 컬렉션에 필드를 추가하세요:
{
"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 구문을 사용하여 샘플 콘텐츠에서 이미지를 참조하세요. 원격 이미지는 URL로 참조됩니다:
{
"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에서 컬렉션별로 활성화해야 합니다(Content Types > Edit > Features > Search).
테마 테스트
-
테마에서 테스트 프로젝트를 만드세요:
npm create astro@latest -- --template ./path/to/my-theme -
종속성을 설치하고 개발 서버를 시작하세요:
cd test-site npm install npm run dev -
http://localhost:4321/_emdash/admin에서 설정 마법사를 완료하세요 -
컬렉션, 메뉴, 리디렉션 및 콘텐츠가 올바르게 생성되었는지 확인하세요
-
모든 페이지 템플릿이 제대로 렌더링되는지 테스트하세요
-
관리자 UI를 통해 새 콘텐츠를 만들어 모든 필드가 작동하는지 확인하세요
테마 게시
배포를 위해 npm에 게시하세요:
npm publish --access public
그러면 사용자가 테마를 설치할 수 있습니다:
npm create astro@latest -- --template @your-org/emdash-theme-blog
GitHub에서 호스팅되는 테마는 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"
}
블록 컴포넌트에서 _key 값을 앵커로 사용하세요:
<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 테마 변환