아키텍처 (내부)

이 페이지

이 페이지는 EmDash 자체를 작업하는 사람들을 위한 것이며, EmDash로 사이트를 구축하는 사람들을 위한 것이 아닙니다. 내부 메커니즘 — 테이블 레이아웃, Astro 통합, 요청 경로, 코드 생성을 문서화합니다. EmDash를 사용하는 데는 이 내용이 필요하지 않습니다. 사이트를 구축하고 있다면 대신 아키텍처콘텐츠 모델을 읽어주세요.

Astro 통합

EmDash는 emdash 패키지의 Astro 통합으로 실행됩니다. 빌드 시:

  • Astro의 injectRoute API로 관리자 SPA와 REST API 라우트를 주입합니다. 사용자 프로젝트에는 아무것도 복사되지 않습니다. 주입되는 경로는:

    경로 패턴목적
    /_emdash/admin/[...path]관리 패널 SPA
    /_emdash/api/manifest관리 매니페스트 (컬렉션, 플러그인)
    /_emdash/api/content/[collection]콘텐츠 항목 CRUD
    /_emdash/api/media/*미디어 라이브러리 작업
    /_emdash/api/schema/*스키마 관리
    /_emdash/api/settings사이트 설정
    /_emdash/api/menus/*내비게이션 메뉴
    /_emdash/api/taxonomies/*카테고리, 태그, 사용자 정의 택소노미
  • 번들러가 구성 및 플러그인 코드를 해결하고 트리 셰이크할 수 있도록 가상 모듈을 생성합니다:

    모듈목적
    virtual:emdash/config데이터베이스 및 스토리지 구성
    virtual:emdash/dialect데이터베이스 다이얼렉트 팩토리
    virtual:emdash/plugin-admins플러그인 관리 UI의 정적 임포트
  • Live Collections 로더를 제공하고, 마이그레이션을 관리하며, 스토리지 연결을 엽니다.

데이터베이스 우선 스키마

스키마 정의는 코드가 아닌 데이터베이스에 존재합니다. 두 개의 시스템 테이블이 구조를 추적합니다.

_emdash_collections는 컬렉션당 하나의 행을 보유합니다:

CREATE TABLE _emdash_collections (
  id TEXT PRIMARY KEY,
  slug TEXT UNIQUE NOT NULL,        -- "posts", "products"
  label TEXT NOT NULL,              -- "Blog Posts"
  label_singular TEXT,              -- "Post"
  description TEXT,
  icon TEXT,
  supports JSON,                    -- ["drafts", "revisions", "preview"]
  source TEXT,                      -- 생성 방법
  created_at TEXT DEFAULT CURRENT_TIMESTAMP,
  updated_at TEXT
);

source 컬럼은 출처를 기록합니다: manual (관리 UI), template:<name> (시드 파일), import:wordpress (임포터), 또는 discovered (기존 테이블에서 자동 감지).

_emdash_fields는 필드당 하나의 행을 보유하며, 컬렉션에 연결됩니다:

CREATE TABLE _emdash_fields (
  id TEXT PRIMARY KEY,
  collection_id TEXT REFERENCES _emdash_collections(id),
  slug TEXT NOT NULL,               -- 컬럼 이름
  label TEXT NOT NULL,
  type TEXT NOT NULL,               -- 필드 유형
  column_type TEXT NOT NULL,        -- TEXT, REAL, INTEGER, JSON
  required INTEGER DEFAULT 0,
  unique_field INTEGER DEFAULT 0,
  default_value TEXT,
  validation JSON,
  widget TEXT,
  options JSON,
  sort_order INTEGER,
  created_at TEXT DEFAULT CURRENT_TIMESTAMP,
  UNIQUE(collection_id, slug)
);

컬렉션별 콘텐츠 테이블

각 컬렉션은 ec_ 접두사가 붙은 자체 테이블을 가집니다. titleprice 필드가 있는 products 컬렉션은 다음을 생성합니다:

CREATE TABLE ec_products (
  -- 시스템 컬럼, 항상 존재
  id TEXT PRIMARY KEY,
  slug TEXT UNIQUE,
  status TEXT DEFAULT 'draft',
  author_id TEXT,
  created_at TEXT DEFAULT (datetime('now')),
  updated_at TEXT DEFAULT (datetime('now')),
  published_at TEXT,
  deleted_at TEXT,                  -- 소프트 삭제
  version INTEGER DEFAULT 1,        -- 낙관적 잠금

  -- 콘텐츠 컬럼, 필드 정의에서
  title TEXT NOT NULL,
  price REAL
);

실제 컬럼 (JSON blob이 있는 하나의 테이블 대신)은 적절한 인덱싱, 작동하는 외래 키, 데이터베이스 도구가 검사할 수 있는 스키마, 필드별 JSON 파싱 불필요를 제공합니다.

관심사가 분리된 상태를 유지합니다:

관심사위치테이블
스키마시스템 테이블_emdash_collections, _emdash_fields
콘텐츠컬렉션별 테이블ec_posts, ec_products, …
미디어별도 테이블 + 스토리지media 테이블 + R2/S3
설정옵션 테이블site: 접두사가 있는 options

런타임 스키마 변경

관리 UI를 통해 필드를 추가하면 세 단계가 실행됩니다:

  1. _emdash_fields에 레코드를 삽입한다.
  2. ALTER TABLE ec_<collection> ADD COLUMN <name> <TYPE>을 실행한다.
  3. 유효성 검사에 사용되는 Zod 스키마를 재생성한다.

SQLite는 런타임에서 컬럼 추가, 이름 변경, 삭제 (삭제에는 SQLite 3.35+ 필요)를 지원합니다. 컬럼의 유형을 직접 변경하는 것은 지원되지 않으므로, EmDash는 테이블을 투명하게 재구축합니다: 새 테이블 생성, 행 복사, 이전 테이블 삭제, 새 테이블 이름 변경.

런타임 유효성 검사

EmDash는 시작 시 필드 정의에서 Zod 스키마를 구축하고 모든 생성 및 업데이트를 이에 대해 검증합니다:

function buildSchema(fields: Field[]): ZodSchema {
	const shape: Record<string, ZodType> = {};
	for (const field of fields) {
		let zodType = fieldTypeToZod(field.type);
		if (field.required) zodType = zodType.required();
		if (field.validation?.min !== undefined) zodType = zodType.min(field.validation.min);
		shape[field.slug] = zodType;
	}
	return z.object(shape);
}

데이터 레이어

EmDash는 지원되는 모든 데이터베이스 (SQLite, libSQL, Cloudflare D1, PostgreSQL)에 걸쳐 타입 안전한 SQL을 위해 Kysely를 사용합니다. 다이얼렉트는 사이트가 통합에 전달하는 구성에서 virtual:emdash/dialect에 의해 선택됩니다.

Live Collections 로더

콘텐츠는 Astro의 Live Collections를 통해 런타임에 제공됩니다. emdashLoader()는 Astro의 LiveLoader 인터페이스를 구현하고 단일 _emdash 컬렉션으로 등록됩니다:

import { defineLiveCollection } from "astro:content";
import { emdashLoader } from "emdash/runtime";

export const collections = {
	_emdash: defineLiveCollection({ loader: emdashLoader() }),
};

단일 _emdash 컬렉션이 모든 콘텐츠 유형을 래핑하며, getEmDashCollection("posts")가 호출되면 로더가 유형별로 필터링합니다.

요청 경로

페이지에서의 콘텐츠 요청:

  1. Astro가 요청을 수신하고 페이지 컴포넌트를 실행한다.
  2. getEmDashCollection()이 Astro의 getLiveCollection()을 호출한다.
  3. emdashLoader가 Kysely를 통해 관련 ec_* 테이블을 쿼리한다.
  4. 행이 Astro의 항목 형식 (id, slug, data)으로 매핑된다.
  5. 컴포넌트가 렌더링된다.

관리자 요청:

  1. 미들웨어가 세션 토큰을 검증한다.
  2. API 라우트가 리포지토리를 통해 CRUD를 실행한다.
  3. 라이프사이클 훅이 발생한다 (예: content:beforeSave).
  4. Kysely가 SQL을 실행한다.
  5. 라우트가 관리 SPA에 JSON을 반환한다.

관리 패널 내부

관리 패널은 하나의 React 아일랜드입니다. Astro가 셸을 제공하고 미들웨어에서 인증을 강제합니다. 내부의 모든 것은 클라이언트 측이며, TanStack Router, TanStack Query, TanStack Table, React Hook Form + Zod, TipTap, Kumo (Cloudflare의 Base UI + Tailwind 디자인 시스템)로 구축되었습니다.

셸 라우트가 미들웨어에서 접근을 제어합니다:

export async function onRequest({ request, locals }, next) {
	const session = await getSession(request);
	if (request.url.includes("/_emdash/admin")) {
		if (!session?.user) return redirect("/_emdash/admin/login");
		locals.user = session.user;
	}
	return next();
}

매니페스트 기반 UI

관리 패널은 컬렉션이나 플러그인에 대해 아무것도 하드코딩하지 않습니다. GET /_emdash/api/manifest를 가져오며, 이는 요청 사용자가 접근할 수 있는 컬렉션, 플러그인, 택소노미를 역할별로 필터링하여 반환합니다:

{
	"collections": [
		{
			"slug": "posts",
			"label": "Blog Posts",
			"icon": "file-text",
			"supports": ["drafts", "revisions", "preview"],
			"fields": [{ "slug": "title", "type": "string", "required": true }]
		}
	],
	"plugins": [{ "id": "audit-log", "label": "Audit Log" }],
	"taxonomies": [{ "name": "category", "label": "Categories", "hierarchical": true }],
	"version": "abc123"
}

내비게이션, 폼, 필드 에디터가 이 매니페스트에서 생성되므로 스키마 및 플러그인 변경 사항이 관리 패널 리빌드 없이 나타나며, Zod 스키마는 서버 측에 유지됩니다.

플러그인 관리 UI

플러그인 관리 진입점은 번들러가 해결하고 트리 셰이크할 수 있도록 정적 임포트의 생성된 가상 모듈에 수집됩니다:

import * as pluginAdmin0 from "@emdash-cms/plugin-seo/admin";

export const pluginAdmins = { seo: pluginAdmin0 };

리치 텍스트 변환

Portable Text 필드는 TipTap (ProseMirror)에서 편집됩니다. 콘텐츠는 로드 및 저장 경계에서 portableTextToProsemirror()prosemirrorToPortableText()에 의해 변환됩니다. 플러그인이나 임포트의 알 수 없는 블록은 읽기 전용 플레이스홀더로 보존됩니다.

서명된 업로드

미디어 업로드는 어댑터가 지원하는 경우 스토리지 직접 서명 URL을 사용하고 그렇지 않으면 동일 출처 스트리밍 엔드포인트를 사용합니다:

  1. 클라이언트가 업로드 URL을 요청한다 (POST /api/media/upload-url).
  2. 클라이언트가 반환된 대상에 업로드한다. S3 호환 어댑터는 Worker 본문 크기 제한을 우회하는 서명 URL을 반환할 수 있으며, 네이티브 R2 바인딩과 로컬 스토리지는 EmDash 스트리밍 엔드포인트를 반환한다.
  3. 클라이언트가 확인한다 (POST /api/media/:id/confirm).
  4. 서버가 메타데이터 (크기, MIME 유형)를 추출한다.

콘텐츠 임포터 확장

WordPress 임포터는 플러그 가능한 ImportSource 인터페이스로 구축되어 있습니다. 사용자 정의 소스는 probe, analyze, fetch를 구현합니다:

interface ImportSource {
	probe(input: ImportInput): Promise<ProbeResult>;
	analyze(input: ImportInput): Promise<AnalysisResult>;
	fetchContent(input: ImportInput): AsyncIterable<NormalizedEntry>;
}

probe는 입력을 검증하고 발견한 것을 보고하며, analyze는 소스 게시물 유형을 EmDash 컬렉션에 매핑하고 스키마 갭을 표시하며, fetchContent는 관리 패널이 사용하는 것과 동일한 리포지토리를 통해 임포트 파이프라인이 작성하는 정규화된 항목을 스트리밍합니다. 내장 소스는 WordPress WXR, WordPress.com, WordPress REST API를 다룹니다. 다른 시스템에서 가져오려면 사용자 정의 소스를 등록하세요.