테마 만들기

이 페이지

EmDash 테마는 다른 개발자가 create-astro로 스캐폴드할 수 있는 Astro 프로젝트입니다. 프로젝트를 완전한 사이트로 빌드·테스트한 뒤, 라우트와 컴포넌트가 기대하는 콘텐츠 모델을 만드는 seed를 포함하세요.

현재 템플릿에서 시작하기

의도한 사이트와 배포 대상에 가장 가까운 기존 템플릿을 선택합니다. Node와 Cloudflare 변형은 데이터베이스, 스토리지, 어댑터, 미들웨어 구성을 함께 유지합니다.

블로그 템플릿은 콘텐츠 사이트의 유용한 기반입니다:

npm create astro@latest -- --template @emdash-cms/template-blog

결과 테마가 Cloudflare Workers, D1, R2를 대상으로 할 때는 @emdash-cms/template-blog-cloudflare를 사용합니다.

템플릿 구조 유지하기

현재 블로그 템플릿은 다음 관련 경로를 사용합니다:

astro.config.mjs
emdash-env.d.ts
package.json
seed/
└── seed.json
src/
├── components/
│   └── PostCard.astro
├── layouts/
│   └── Base.astro
├── live.config.ts
├── pages/
│   ├── index.astro
│   ├── category/[slug].astro
│   ├── pages/[slug].astro
│   ├── posts/index.astro
│   ├── posts/[slug].astro
│   ├── search.astro
│   └── tag/[slug].astro
└── styles/

starter, portfolio, marketing 템플릿은 다른 라우트를 사용합니다. 모든 테마에 catch-all 페이지 라우트가 있다고 가정하지 말고, 선택한 기반에서 실제 파일을 복사하세요.

seed 가리키기

현재 템플릿은 package.json에 seed 경로를 선언합니다:

{
  "name": "@example/emdash-theme-publication",
  "private": true,
  "type": "module",
  "emdash": {
    "seed": "seed/seed.json"
  }
}

EmDash는 .emdash/seed.json과 관례적 fallback seed/seed.json도 찾습니다. 템플릿을 배포할 때는 패키지 필드로 의도한 파일을 명시하세요.

콘텐츠 모델 정의하기

블로그 템플릿에서 시작할 때는 관련 없는 모델로 바꾸지 말고 기존 seed/seed.json을 편집하세요. 다음 축소 seed는 이 가이드 예제에서 쓰는 컬렉션과 구조 데이터를 유지합니다:

{
  "$schema": "https://emdashcms.com/seed.schema.json",
  "version": "1",
  "meta": {
    "name": "Publication",
    "description": "A publication with posts"
  },
  "settings": {
    "title": "Publication",
    "tagline": "Latest articles"
  },
  "collections": [
    {
      "slug": "posts",
      "label": "Posts",
      "labelSingular": "Post",
      "supports": ["drafts", "revisions", "search", "seo"],
      "fields": [
        {
          "slug": "title",
          "label": "Title",
          "type": "string",
          "required": true,
          "searchable": true
        },
        {
          "slug": "excerpt",
          "label": "Excerpt",
          "type": "text"
        },
        {
          "slug": "featured_image",
          "label": "Featured image",
          "type": "image"
        },
        {
          "slug": "content",
          "label": "Content",
          "type": "portableText",
          "searchable": true
        }
      ]
    },
    {
      "slug": "pages",
      "label": "Pages",
      "labelSingular": "Page",
      "supports": ["drafts", "revisions", "search"],
      "fields": [
        {
          "slug": "title",
          "label": "Title",
          "type": "string",
          "required": true,
          "searchable": true
        },
        {
          "slug": "content",
          "label": "Content",
          "type": "portableText",
          "searchable": true
        },
        {
          "slug": "template",
          "label": "Page template",
          "type": "select",
          "defaultValue": "default",
          "validation": {
            "options": ["default", "full-width", "landing"]
          }
        }
      ]
    }
  ],
  "menus": [
    {
      "name": "primary",
      "label": "Primary navigation",
      "items": [
        { "type": "custom", "label": "Home", "url": "/" },
        { "type": "custom", "label": "Posts", "url": "/posts" }
      ]
    }
  ],
  "widgetAreas": [
    {
      "name": "sidebar",
      "label": "Sidebar",
      "widgets": []
    }
  ],
  "content": {
    "posts": [
      {
        "id": "post-welcome",
        "slug": "welcome",
        "status": "published",
        "data": {
          "title": "Welcome",
          "excerpt": "The first article",
          "content": []
        }
      }
    ]
  }
}

콘텐츠 항목 id는 참조에 쓰이는 seed 로컬 식별자입니다. 라우팅 가능한 항목의 데이터베이스 ID가 될 필요는 없습니다. slug가 쿼리 API에서 entry.id로 노출되는 라우트 식별자가 됩니다.

분류, byline, 메뉴 참조, 미디어, 리다이렉트, 위젯 영역, section, 현지화, 충돌 동작을 추가하기 전에 seed 파일을 읽으세요.

서버 렌더링 라우트 만들기

현재 EmDash 템플릿은 output: "server"를 사용합니다. 라우트는 요청마다 라이브 콘텐츠를 조회합니다. 템플릿이 EmDash를 빌드 시점 소스로만 의도적으로 쓰는 경우가 아니면 테마 콘텐츠 라우트에 getStaticPaths()를 추가하지 마세요.

다음 아카이브는 저장된 published_at 필드로 데이터베이스에서 정렬합니다:

---
import { getEmDashCollection } from "emdash";
import Base from "../../layouts/Base.astro";

const { entries: posts, error, cacheHint } = await getEmDashCollection("posts", {
  orderBy: { published_at: "desc" },
});

if (error) return new Response("Could not load posts", { status: 500 });
if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
---

<Base title="Posts">
  {posts.map((post) => (
    <article>
      <h2><a href={`/posts/${post.id}`}>{post.data.title}</a></h2>
      {post.data.excerpt && <p>{post.data.excerpt}</p>}
    </article>
  ))}
</Base>

orderBy는 필드-방향 객체입니다. sort, sortBy, JavaScript 콜백이 아니라 orderBy: { published_at: "desc" }를 사용하세요.

다음 라우트는 게시물 하나를 해석하고 렌더링합니다:

---
import { decodeSlug, getEmDashEntry } from "emdash";
import { PortableText } from "emdash/ui";
import Base from "../../layouts/Base.astro";

const slug = decodeSlug(Astro.params.slug);
if (!slug) return Astro.redirect("/404");

const { entry: post, error, cacheHint } = await getEmDashEntry("posts", slug);
if (error) return new Response("Could not load the post", { status: 500 });
if (!post) return Astro.redirect("/404");
if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
---

<Base title={post.data.title} content={{ collection: "posts", id: post.data.id, slug }}>
  <article>
    <h1 {...post.edit.title}>{post.data.title}</h1>
    <PortableText value={post.data.content} />
  </article>
</Base>

라우트 URL에는 post.id를, 저장된 콘텐츠 ID가 필요한 헬퍼에는 post.data.id를 사용하세요.

사이트 관리 내비게이션 조회하기

CMS가 관리하는 값은 해당 API에서 가져와야 합니다. 현재 템플릿은 레이아웃에서 getSiteSettings(), getMenu(), <WidgetArea />를 사용합니다:

---
import { getMenu, getSiteSettings } from "emdash";
import { WidgetArea } from "emdash/ui";

const [settings, primary] = await Promise.all([
  getSiteSettings(),
  getMenu("primary"),
]);
---

<header>
  <a href="/">{settings.title}</a>
  <nav>
    {primary?.items.map((item) => <a href={item.url}>{item.label}</a>)}
  </nav>
</header>

<main><slot /></main>
<aside><WidgetArea name="sidebar" /></aside>

정적 디자인 문구는 Astro 파일에 둬도 됩니다. 관리자가 편집할 값은 설정, 콘텐츠, 메뉴, 위젯에 표현해야 합니다.

이미지 필드 렌더링하기

이미지 필드는 URL 문자열이 아니라 미디어 값입니다. 로컬 스토리지와 이미지 제공자가 일관되게 해석되도록 전체 값을 Image 컴포넌트에 전달하세요:

---
import { Image } from "emdash/ui";

const { post } = Astro.props;
---

<article>
  {post.data.featured_image && (
    <Image
      image={post.data.featured_image}
      alt={post.data.title}
      width={800}
      height={450}
    />
  )}
  <h2><a href={`/posts/${post.id}`}>{post.data.title}</a></h2>
</article>

덮어쓰지 않으면 컴포넌트는 필드 자체 alt 텍스트를 사용합니다. above the fold로 예상되는 이미지에만 priority를 쓰고, 나머지는 lazy-loaded로 둡니다.

페이지 레이아웃 선택 제공하기

위 starter seed에는 편집자가 여러 페이지 레이아웃이 필요한 사이트용 select 필드가 있습니다. 다른 seed에 기능을 추가할 때는 알려진 컴포넌트에 매핑되는 안정적인 값을 사용하세요:

{
  "slug": "template",
  "label": "Page template",
  "type": "select",
  "defaultValue": "default",
  "validation": {
    "options": ["default", "full-width", "landing"]
  }
}

라우트는 명시적 컴포넌트 맵에서 선택할 수 있습니다:

---
import { decodeSlug, getEmDashEntry } from "emdash";
import PageDefault from "../../layouts/PageDefault.astro";
import PageFullWidth from "../../layouts/PageFullWidth.astro";
import PageLanding from "../../layouts/PageLanding.astro";

const slug = decodeSlug(Astro.params.slug);
if (!slug) return Astro.redirect("/404");

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} />

명시적 맵은 저장된 필드 값이 임의 모듈 경로가 되는 것을 막습니다.

검색 추가하기

결과에 나올 각 컬렉션에서 search를 켜고 관련 필드를 searchable로 표시하세요. 현재 템플릿은 준비된 검색 라우트에 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>

Astro i18n 사이트에서 LiveSearch는 Astro.currentLocale을 사용합니다. 페이지가 의도적으로 모든 로케일을 검색할 때만 locale={null}을 전달하세요.

재사용 가능한 section seed하기

section은 편집자에게 재사용 가능한 Portable Text 시작점을 제공합니다. CTA 같은 반복 콘텐츠 패턴이 있는 디자인에 추가하세요:

{
  "version": "1",
  "sections": [
    {
      "slug": "newsletter-signup",
      "title": "Newsletter signup",
      "description": "Heading and copy for the newsletter form",
      "keywords": ["newsletter", "email"],
      "content": [
        {
          "_type": "block",
          "_key": "newsletter-heading",
          "style": "h2",
          "children": [
            { "_type": "span", "_key": "newsletter-heading-text", "text": "Get new articles by email" }
          ]
        }
      ]
    }
  ]
}

설정 마법사에서는 seed된 항목, byline, 분류 용어를 생략할 수 있습니다. section과 나머지 구조 모델은 여전히 적용됩니다.

구조화된 페이지 block 추가하기

페이지 자체가 유형화된 섹션의 순서 있는 구성일 때 blocks 필드를 사용하세요. 리치 텍스트 문서 안에 속하고 단락 흐름에 참여하는 객체에는 사용자 정의 Portable Text block을 사용하세요.

marketing 템플릿은 다섯 가지 retained block 유형을 seed하고 Pages content 필드에서 허용합니다. 래퍼는 생성된 각 _type을 Astro 컴포넌트에 매핑합니다:

---
import { Blocks, defineBlockComponents } from "emdash/ui";
import type { PageContentBlock } from "../../emdash-env";
import FAQ from "./blocks/FAQ.astro";
import Features from "./blocks/Features.astro";
import Hero from "./blocks/Hero.astro";
import Pricing from "./blocks/Pricing.astro";
import Testimonials from "./blocks/Testimonials.astro";

const components = defineBlockComponents<PageContentBlock>({
  marketing_hero: Hero,
  marketing_features: Features,
  marketing_testimonials: Testimonials,
  marketing_pricing: Pricing,
  marketing_faq: FAQ,
});
---

<Blocks value={Astro.props.value} components={components} />

생성된 PageContentBlock 유니온은 허용된 block 유형과 retained 버전마다 컴포넌트 맵을 맞춥니다. seed된 각 block 값에는 _type, _version, 안정적인 _key가 포함됩니다.

단락 사이 객체(기사에 임베드된 다이어그램 등)에는 사용자 정의 Portable Text block을 쓰고, PortableText 컴포넌트의 components.type 옵션으로 매핑하세요. seed에 저장 값을 넣을 수 있지만, 해당 Portable Text 객체를 만들고 편집하는 사용자 정의 에디터는 네이티브 플러그인이 등록해야 합니다.

block에 재사용 가능한 사용자 정의 에디터와 패키징된 렌더링 컴포넌트가 필요하면 네이티브 플러그인을 사용하세요. 샌드박스 플러그인은 Astro 렌더링 컴포넌트를 사이트 빌드에 포함할 수 없습니다.

템플릿 테스트하기

  1. create-astro로 깨끗한 디렉터리에 템플릿을 스캐폴드합니다.

  2. 의존성을 설치하고 사이트 빌드·타입 검사 명령을 실행합니다.

  3. 빈 데이터베이스로 시작해 /_emdash/admin/setup을 완료합니다.

  4. 샘플 콘텐츠 포함·제외 모두로 설정을 테스트합니다.

  5. seed 콘텐츠, 새로 만든 콘텐츠, 선택 이미지 없음, 빈 컬렉션 상태로 모든 라우트를 엽니다.

  6. 관리자에서 사이트 설정, 메뉴, 분류 할당, Portable Text, block을 편집합니다. block은 허용된 각 유형을 추가·재정렬·복제·제거하고 공개 라우트가 변경 값을 쓰는지 확인합니다.

  7. 일회용 환경에 배포별 설정을 적용하고 미디어 스토리지, 미리보기, 서버 렌더링을 검증합니다.

템플릿 게시하기

GitHub 저장소는 Astro github: 템플릿 구문으로 직접 사용할 수 있습니다:

npm create astro@latest -- --template github:example/emdash-theme-publication

게시 전 로컬 DB와 업로드를 제거하고, 비밀을 저장소 밖에 두며, 깨끗한 checkout에서 패키지 경로를 확인하고, 템플릿이 설정하는 배포 대상을 문서화하세요.

체크리스트

  • astro.config.mjs가 서버 출력과 의도한 배포 어댑터를 사용한다.
  • src/live.config.ts가 emdashLoader()를 등록한다.
  • package.json#emdash.seed가 존재하는 파일을 가리킨다.
  • 조회하는 모든 컬렉션과 필드가 seed에 선언되어 있다.
  • 쿼리 예제가 orderBy와 올바른 항목 식별자를 사용한다.
  • CMS 관리 사이트 값이 영구 템플릿 상수로 중복되지 않는다.
  • 깨끗한 설정이 샘플 콘텐츠 유무 모두에서 동작한다.
  • 템플릿 빌드와 타입 검사가 지원 대상에서 통과한다.