이 페이지는 EmDash 자체를 개발하는 사람들을 위한 것이며, EmDash로 사이트를 구축하는 사람들을 위한 것이 아닙니다. 데이터베이스 레이아웃, Astro 통합, 요청 경로, 관리 애플리케이션, 미디어 플로우, 임포트 시스템을 설명합니다. 사이트를 구축하고 있다면 대신 아키텍처와 콘텐츠 모델을 읽어보세요.
Astro 통합
EmDash는 emdash 패키지에서 Astro 통합으로 실행됩니다. 빌드 시:
-
Astro의
injectRouteAPI로 관리 애플리케이션과 REST API 라우트를 주입합니다. 사용자 프로젝트에는 아무것도 복사되지 않습니다. 주요 라우트 패밀리는:경로 패턴 목적 /_emdash/admin/[...path]관리 패널 SPA /_emdash/api/manifest관리 매니페스트 (컬렉션, 플러그인) /_emdash/api/content/[collection]/...콘텐츠 항목 작업 /_emdash/api/media/...미디어 라이브러리 작업 /_emdash/api/schema/...스키마 관리 /_emdash/api/settings/...사이트 설정 /_emdash/api/menus/...내비게이션 메뉴 /_emdash/api/taxonomies/...카테고리, 태그, 커스텀 택소노미 /_emdash/api/plugins/[pluginId]/[...path]플러그인 정의 API 라우트 라우트 인젝터가 인증, 댓글, 검색, 임포트, 위젯 및 기타 라우트 패밀리를 포함한 완전한 목록입니다.
-
번들러가 설정 및 확장 코드를 해결할 수 있도록 가상 모듈을 생성합니다:
모듈 목적 virtual:emdash/config데이터베이스, 스토리지, 사이트 설정 virtual:emdash/dialect데이터베이스 다이얼렉트 팩토리 virtual:emdash/admin-registry플러그인 관리 인터페이스의 정적 임포트 virtual:emdash/plugins설정된 플러그인 구현 virtual:emdash/media-providers설정된 외부 미디어 프로바이더 virtual-modules.ts가 나머지 런타임 헬퍼와 생성된 모듈 내용을 정의합니다. -
Live Content Collections 로더를 제공하고 런타임 미들웨어를 등록합니다. 요청 시 미들웨어는 설정된 데이터베이스 및 스토리지 연결을 열고 라우트가 사용하기 전에 보류 중인 마이그레이션을 적용합니다.
데이터베이스 우선 스키마
스키마 정의는 정적 설정 파일이 아닌 데이터베이스에 있습니다. _emdash_collections는 컬렉션당 하나의 행을 저장합니다. 핵심 열은 컬렉션과 런타임 및 관리가 노출하는 기능을 설명합니다:
| 열 | 목적 |
|---|---|
id, slug | 안정적인 컬렉션 식별 |
label, label_singular, description, icon | 편집자에게 표시되는 이름과 안내 |
supports, has_seo, comments_enabled, edit_locking | 선택적 컬렉션 기능 |
title_field, date_field, admin_config, hidden, sort_order | 관리 목록 및 내비게이션 동작 |
url_pattern, routable | 공개 URL 및 슬러그 동작 |
source | 컬렉션 생성 방법 |
source 값은 manual, seed, template:<name>, import:<name>, discovered 등의 출처를 기록합니다. 추가 설정은 등록된 마이그레이션에서 가져오므로, database/types.ts와 마이그레이션이 현재 열 목록입니다.
_emdash_fields는 각 컬렉션에 연결된 필드를 저장합니다:
| 열 | 목적 |
|---|---|
id, collection_id, slug | 필드 식별 및 소유 컬렉션 |
label, type, column_type | 편집기 레이블, EmDash 필드 타입, SQL 스토리지 타입 |
required, unique, default_value, validation | 콘텐츠 제약 조건 및 기본값 |
widget, options, sort_order | 편집기 컨트롤 및 표시 순서 |
searchable, indexed, translatable | 검색, 쿼리, 지역화 동작 |
collection_id는 _emdash_collections.id를 참조하고, 각 필드 슬러그는 해당 컬렉션 내에서 고유합니다.
컬렉션별 콘텐츠 테이블
각 컬렉션은 ec_ 접두사가 붙은 자체 테이블을 가집니다. title과 price 필드가 있는 products 컬렉션은 다음 형태의 테이블을 생성합니다:
CREATE TABLE ec_products (
-- 시스템 열, 모든 콘텐츠 테이블에 존재
id TEXT PRIMARY KEY,
slug TEXT,
status TEXT DEFAULT 'draft',
author_id TEXT,
primary_byline_id TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
published_at TEXT,
scheduled_at TEXT,
deleted_at TEXT,
version INTEGER DEFAULT 1,
live_revision_id TEXT,
draft_revision_id TEXT,
locale TEXT NOT NULL DEFAULT 'en',
translation_group TEXT,
-- 콘텐츠 열, 필드 정의에서 생성
title TEXT NOT NULL,
price REAL,
UNIQUE (slug, locale)
);
실제 열은 각 필드에 데이터베이스 타입을 부여하고, 인덱스와 외래 키를 허용하며, 콘텐츠 JSON 블롭을 디코딩하지 않고도 데이터베이스 도구가 스키마를 검사할 수 있게 합니다. 유니크 제약 조건은 번역이 슬러그를 공유하면서 각 로케일 내에서 슬러그를 고유하게 유지할 수 있게 합니다. 동일 항목의 모든 로케일 변형은 translation_group 값을 공유하여, EmDash가 서로의 번역인 행을 찾을 수 있게 합니다.
주요 데이터 관심사는 분리된 채로 유지됩니다:
| 관심사 | 위치 | 테이블 |
|---|---|---|
| 스키마 | 시스템 테이블 | _emdash_collections, _emdash_fields |
| 콘텐츠 | 컬렉션별 테이블 | ec_posts, ec_products, … |
| 미디어 | 별도 테이블 + 스토리지 | media 테이블 + 설정된 스토리지 |
| 설정 | 옵션 테이블 | site: 접두사가 있는 options |
런타임 스키마 변경
관리 UI를 통해 필드를 추가하면 다음 단계가 실행됩니다:
_emdash_fields에 필드 정의를 삽입합니다.- 컬렉션의
ec_*테이블에 해당 열을 추가하고, 필드가 인덱싱으로 설정된 경우 인덱스를 생성합니다. - 생성된 개발 타입을 새로 고쳐 새 필드가 편집기 도구에 나타나도록 합니다.
콘텐츠 유효성 검사는 현재 필드 정의를 읽고 콘텐츠가 생성되거나 업데이트될 때 Zod 스키마를 구축합니다. 필드의 기본 SQL 타입, required 또는 unique 제약 조건, 또는 지역화 동작을 변경하면 수동 콘텐츠 마이그레이션이 필요할 수 있습니다. SchemaRegistry는 테이블을 암묵적으로 재구축하는 대신 지원되지 않는 인플레이스 변경을 거부합니다.
런타임 유효성 검사
EmDash는 컬렉션의 현재 필드에서 Zod 스키마를 도출합니다. 생성기는 타입 및 제약 조건 세부 사항을 generateFieldSchema()에 위임합니다:
export function generateZodSchema(
collection: CollectionWithFields,
): z.ZodObject<Record<string, ZodType>> {
const shape: Record<string, ZodType> = {};
for (const field of collection.fields) {
shape[field.slug] = generateFieldSchema(field);
}
return z.object(shape);
}
콘텐츠 핸들러는 알 수 없는 필드도 거부하고, 필수 문자열 값을 확인하며, 다른 컬렉션에 대한 참조를 검증합니다.
데이터 레이어
EmDash는 SQLite, libSQL, Cloudflare D1, PostgreSQL에 걸쳐 타입이 지정된 SQL을 위해 Kysely를 사용합니다. 사이트 설정이 데이터베이스 어댑터를 선택하고, 통합은 virtual:emdash/dialect를 통해 다이얼렉트 팩토리를 노출합니다.
Live Content Collections 로더
콘텐츠는 Astro의 Live Content Collections를 통해 런타임에 제공됩니다. emdashLoader()는 Astro의 LiveLoader 인터페이스를 구현하고 단일 _emdash 컬렉션으로 등록됩니다:
import { defineLiveCollection } from "astro:content";
import { emdashLoader } from "emdash/runtime";
export const collections = {
_emdash: defineLiveCollection({ loader: emdashLoader() }),
};
단일 _emdash 컬렉션은 모든 EmDash 컬렉션을 래핑합니다. getEmDashCollection("posts")는 posts 타입 필터를 제공하고, 로더는 이를 ec_posts 테이블에 매핑합니다.
요청 경로
Astro 페이지에서의 콘텐츠 요청은 다음 경로를 따릅니다:
- 페이지가
getEmDashCollection()또는getEmDashEntry()를 호출합니다. - 쿼리 래퍼가 내부
_emdash컬렉션과 요청된 EmDash 컬렉션 타입으로 Astro의getLiveCollection()또는getLiveEntry()를 호출합니다. emdashLoader()가 Kysely를 통해 관련ec_*테이블을 쿼리하고, 게시, 로케일, 필터, 정렬, 페이지네이션 규칙을 적용합니다.- 쿼리 래퍼가 행을 Astro 항목에 매핑하고 바이라인과 택소노미 용어를 로드합니다.
- Astro 컴포넌트가 반환된 항목을 렌더링합니다.
미리보기 및 편집 모드 상태는 요청 컨텍스트를 통해 전달되므로, 미들웨어가 요청을 검증한 후 동일한 쿼리 함수가 초안 콘텐츠를 반환할 수 있습니다.
관리 API 요청은 별도의 경로를 따릅니다:
- 미들웨어가 요청을 인증하고 해결된 사용자를
Astro.locals에 저장합니다. - API 라우트가 요청을 파싱하고 해당 작업에 필요한 권한을 확인합니다.
- 라우트가 비즈니스 로직을 핸들러 또는 리포지토리에 위임합니다.
- 핸들러가 해당 작업이 훅을 노출하는 경우 데이터베이스 작업 주위에서 플러그인 라이프사이클 훅을 실행합니다.
- 라우트가 관리 애플리케이션에 표준 JSON 성공 또는 오류 응답을 반환합니다.
관리 패널 내부 구조
관리 패널은 React 싱글 페이지 애플리케이션입니다. Astro가 셸을 제공하고 인증 미들웨어가 관리 라우트를 보호합니다. 애플리케이션 내부에서 TanStack Router가 내비게이션을 처리하고, TanStack Query가 서버 상태를 로드하고, TanStack Table이 데이터 그리드를 렌더링하고, React Hook Form과 Zod가 폼을 관리하고, TipTap이 Portable Text를 편집하고, Kumo가 디자인 시스템을 제공합니다.
세션 인증의 경우 미들웨어는 인증되지 않은 브라우저 요청을 로그인 페이지로 리다이렉트하고 인증되지 않은 API 요청에 대해 JSON 오류를 반환합니다. 활성 사용자를 로드한 후 라우트를 위해 Astro.locals에 해당 사용자를 배치합니다:
const sessionUser = await resolveSessionUser(session);
if (!sessionUser?.id) {
if (isApiRoute) {
return apiError("NOT_AUTHENTICATED", "Not authenticated", 401);
}
const loginUrl = new URL("/_emdash/admin/login", getPublicOrigin(url, emdash?.config));
loginUrl.searchParams.set("redirect", url.pathname);
return context.redirect(loginUrl.toString());
}
이 분기 후 미들웨어는 사용자를 로드하고, 누락되거나 비활성화된 계정을 거부하고, 활성 사용자를 Astro.locals에 배치하고 라우트로 계속 진행합니다.
매니페스트 기반 UI
관리 패널은 컬렉션 스키마나 플러그인 기여를 하드코딩하지 않습니다. GET /_emdash/api/manifest를 가져오며, 이는 현재 컬렉션, 필드, 플러그인, 택소노미, 인증 모드 및 기타 설정된 기능을 설명합니다. 요약된 매니페스트는 다음과 같습니다:
{
"collections": {
"posts": {
"label": "Blog Posts",
"labelSingular": "Post",
"supports": ["drafts", "revisions", "preview"],
"fields": {
"title": { "kind": "string", "label": "Title", "required": true }
}
}
},
"plugins": {
"audit-log": { "version": "0.2.1", "enabled": true }
},
"taxonomies": [
{ "name": "category", "label": "Categories", "hierarchical": true }
],
"version": "0.37.0"
}
관리 패널은 매니페스트를 사용하여 컬렉션 내비게이션과 필드 편집기를 구축합니다. 엔드포인트가 라이브 스키마를 읽기 때문에, 컬렉션과 필드 변경은 관리 애플리케이션을 재빌드하지 않고도 나타납니다.
플러그인 관리 UI
설정된 플러그인 관리 진입점은 virtual:emdash/admin-registry에 수집됩니다. 생성된 모듈은 정적 임포트를 사용하여 번들러가 React 컴포넌트를 포함할 수 있게 합니다:
import * as pluginAdmin0 from "@emdash-cms/plugin-seo/admin";
export const pluginAdmins = { seo: pluginAdmin0 };
리치 텍스트 변환
Portable Text 필드는 ProseMirror 기반의 TipTap을 사용합니다. EmDash는 편집기가 로드될 때 Portable Text를 ProseMirror로 변환하고, 항목이 저장될 때 다시 Portable Text로 변환합니다. 플러그인이나 임포트의 알 수 없는 블록은 폐기되지 않고 읽기 전용 플레이스홀더로 보존됩니다.
서명된 업로드
미디어 업로드는 스토리지 어댑터가 지원하는 경우 스토리지 직접 서명된 URL을 사용하고, 그렇지 않으면 동일 출처 스트리밍 엔드포인트를 사용합니다:
- 클라이언트가
POST /_emdash/api/media/upload-url에서 업로드 대상을 요청합니다. EmDash가 보류 중인 미디어 항목을 생성합니다. - 클라이언트가 반환된 대상으로 업로드합니다. S3 호환 어댑터는 애플리케이션 본문 크기 제한을 우회하는 서명된 URL을 반환할 수 있고, 네이티브 R2 바인딩과 로컬 스토리지는 EmDash 스트리밍 엔드포인트를 반환합니다.
- 클라이언트가
POST /_emdash/api/media/:id/confirm으로 업로드를 확인합니다. - EmDash가 저장된 파일을 검증하고 미디어 항목을 준비 완료로 표시합니다.
콘텐츠 임포터 확장
WordPress 임포터는 플러거블 ImportSource 인터페이스를 사용합니다. 소스는 URL을 프로브하고, 현재 스키마에 대해 사용 가능한 콘텐츠를 분석하며, 정규화된 콘텐츠 항목을 스트리밍할 수 있습니다:
interface ImportSource {
id: string;
name: string;
description: string;
icon: "upload" | "globe" | "wordpress" | "plug";
requiresFile?: boolean;
canProbe?: boolean;
probe?(url: string): Promise<SourceProbeResult | null>;
analyze(input: SourceInput, context: ImportContext): Promise<ImportAnalysis>;
fetchContent(input: SourceInput, options: FetchOptions): AsyncGenerator<NormalizedItem>;
fetchMedia?(url: string, input: SourceInput): Promise<Blob>;
}
WXR 소스는 WordPress 내보내기 파일을 임포트합니다. 커넥터 소스는 EmDash WordPress 플러그인이 있는 사이트에서 직접 임포트합니다. 별도의 REST 소스는 공개 WordPress 사이트를 감지하지만, 직접 REST 임포트가 구현되지 않았기 때문에 사용자를 WXR 내보내기로 안내합니다. 임포터가 동일한 정규화된 분석 및 콘텐츠 항목 형태를 생성할 수 있을 때 다른 소스를 등록하세요.