테마 만들기

이 페이지

EmDash 테마는 완전한 Astro 사이트입니다 — 페이지, 레이아웃, 컴포넌트, 스타일 — 콘텐츠 모델을 초기화하기 위한 seed 파일도 포함합니다. 디자인을 다른 사람과 공유하거나 에이전시의 사이트 생성을 표준화하기 위해 만들어보세요.

핵심 개념

  • 테마는 작동하는 Astro 프로젝트입니다. 테마 API나 추상화 레이어가 없습니다. 테마는 템플릿으로 패키징된 사이트입니다. seed 파일은 첫 실행 시 어떤 컬렉션, 필드, 메뉴, 리다이렉트, 택소노미를 생성할지 EmDash에 알려줍니다.
  • seed 파일은 콘텐츠 모델을 선언합니다. 각 컬렉션에 필요한 필드를 정확히 나열합니다. 완전히 새로운 콘텐츠 타입을 만들기보다 표준 postspages 컬렉션을 기반으로 디자인 요구사항에 맞게 필드와 택소노미를 추가하세요.
  • 테마의 콘텐츠 페이지는 서버 사이드 렌더링이 필요합니다. 테마에서 콘텐츠는 관리 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/ 디렉토리에는 seed 파일과 예제 콘텐츠에서 사용되는 로컬 미디어 파일이 포함됩니다.

package.json 설정

package.jsonemdash 필드를 추가합니다:

{
	"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.seedseed 파일 경로
emdash.preview라이브 데모 URL (선택사항)

기본 콘텐츠 모델

대부분의 테마에는 두 가지 컬렉션 타입이 필요합니다: postspages. posts는 발췌문과 대표 이미지가 있는 타임스탬프 항목으로 피드와 아카이브에 표시됩니다. pages는 최상위 URL의 독립적인 콘텐츠입니다.

이것이 권장되는 시작점입니다. 테마에 필요한 만큼 컬렉션, 택소노미, 필드를 추가하되 여기서 시작하세요.

Seed 파일

seed 파일은 첫 실행 시 무엇을 생성할지 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" }
	]
}

posts는 리스트와 피드에 나타나기 때문에 excerptfeatured_image를 갖습니다. pages는 독립적인 콘텐츠이므로 이것들이 필요 없습니다. 테마 요구사항에 따라 어떤 컬렉션에든 필드를 추가하세요.

섹션, 위젯 영역, 미디어 참조를 포함한 전체 사양은 Seed 파일 포맷을 참조하세요.

페이지 구축

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>

이미지 사용

이미지 필드는 문자열이 아닌 srcalt 속성을 가진 객체입니다. 최적화된 이미지 렌더링을 위해 emdash/uiImage 컴포넌트를 사용하세요:

---
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}
      priority
    />
  )}
  <h2><a href={`/posts/${post.slug}`}>{post.data.title}</a></h2>
  <p>{post.data.excerpt}</p>
</article>

priority는 페이지의 폴드 위에 보일 가능성이 높은 이미지(히어로나 첫 번째 카드 이미지 등)에만 사용하세요. 이미지를 loading="eager"fetchpriority="high"로 렌더링합니다. loading은 브라우저가 로딩을 지연할 수 있는지 제어하고, fetchpriority는 브라우저가 요청을 발견한 후의 중요도를 나타냅니다.

메뉴 사용

레이아웃에서 관리자가 정의한 메뉴를 쿼리합니다. 네비게이션 링크를 절대 하드코딩하지 마세요:

---
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 라우트에서 레이아웃 컴포넌트에 매핑합니다.

seed 파일의 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, 기능 그리드)이 있다면 seed 파일에서 섹션으로 정의하세요:

{
	"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."
						}
					]
				}
			]
		}
	]
}

seed 파일에서 생성된 섹션은 source: "theme"으로 표시됩니다. 편집자도 자체 섹션(source: "user"로 표시)을 만들 수 있지만, 테마가 제공한 섹션은 관리 UI에서 삭제할 수 없습니다.

예제 콘텐츠 추가

테마 디자인을 시연하기 위해 seed 파일에 예제 콘텐츠를 포함하세요:

{
	"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).

테마 테스트

  1. 테마에서 테스트 프로젝트를 생성합니다:

    npm create astro@latest -- --template ./path/to/my-theme
  2. 종속성을 설치하고 개발 서버를 시작합니다:

    cd test-site
    npm install
    npm run dev
  3. http://localhost:4321/_emdash/admin에서 설정 마법사를 완료합니다

  4. 컬렉션, 메뉴, 리다이렉트, 콘텐츠가 올바르게 생성되었는지 확인합니다

  5. 모든 페이지 템플릿이 올바르게 렌더링되는지 테스트합니다

  6. 관리자를 통해 새 콘텐츠를 생성하여 모든 필드가 작동하는지 확인합니다

테마 배포

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 블록 타입을 정의할 수 있습니다. 이는 마케팅 페이지, 랜딩 페이지 또는 표준 리치 텍스트를 넘어선 구조화된 컴포넌트가 필요한 콘텐츠에 유용합니다.

seed 콘텐츠에서 커스텀 블록 정의

seed 파일의 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}>
  <!-- 콘텐츠 -->
</section>

이를 통해 /#features와 같은 네비게이션 링크가 가능합니다.

테마 체크리스트

배포 전에 테마에 다음이 포함되어 있는지 확인하세요:

  • package.jsonemdash 필드 (label, description, seed 경로)
  • .emdash/seed.json에 유효한 스키마
  • 페이지에서 참조되는 모든 컬렉션이 seed에 존재
  • 레이아웃에서 사용되는 메뉴가 seed에 정의됨
  • 예제 콘텐츠가 테마 디자인을 시연
  • astro.config.mjs에 데이터베이스 및 스토리지 설정
  • src/live.config.ts에 EmDash 로더
  • 콘텐츠 페이지에 getStaticPaths() 없음
  • 하드코딩된 사이트 제목, 태그라인, 네비게이션 없음
  • 이미지 필드가 문자열이 아닌 객체로 접근됨 (image.src)
  • 설정 지침이 포함된 README
  • 비표준 Portable Text 타입용 커스텀 블록 컴포넌트

다음 단계