테마 만들기

이 페이지

EmDash 테마는 콘텐츠 모델을 부트스트랩하기 위한 시드 파일도 포함하는 완전한 Astro 사이트(페이지, 레이아웃, 컴포넌트, 스타일)입니다. 다른 사람과 디자인을 공유하거나 대행사의 사이트 생성을 표준화하기 위해 하나를 구축하세요.

주요 개념

  • 테마는 작동하는 Astro 프로젝트입니다. 테마 API나 추상화 레이어가 없습니다. 테마는 템플릿으로 제공되는 사이트입니다. 시드 파일은 첫 실행 시 생성할 컬렉션, 필드, 메뉴, 리디렉션 및 분류법을 EmDash에 알려줍니다.
  • 시드 파일은 콘텐츠 모델을 선언합니다. 각 컬렉션에 필요한 필드를 정확히 나열합니다. 완전히 새로운 콘텐츠 유형을 만들기보다는 표준 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/ 디렉토리에는 시드 파일과 샘플 콘텐츠에 사용되는 로컬 미디어 파일이 포함됩니다.

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

기본 콘텐츠 모델

대부분의 테마는 두 가지 컬렉션 유형이 필요합니다: postspages. 포스트는 피드와 아카이브에 나타나는 발췌문과 추천 이미지가 있는 타임스탬프된 항목입니다. 페이지는 최상위 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" }
	]
}

포스트는 목록과 피드에 나타나므로 excerptfeatured_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>

이미지 사용

이미지 필드는 문자열이 아니라 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}
    />
  )}
  <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).

테마 테스트

  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. 관리자 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 유형에 대한 사용자 정의 블록 컴포넌트

다음 단계