建立主題

本頁內容

EmDash 主題是一個 Astro 專案,其他開發者可以用 create-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 範本使用不同的路由。請從所選的基礎範本複製實際檔案,而不要假設每個主題都有一個通用的頁面路由。

指向 seed

目前的範本在 package.json 中宣告其 seed 路徑:

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

EmDash 也會探索 .emdash/seed.json 以及慣例的 seed/seed.json 後備路徑。散布範本時請使用 package 欄位,讓目標檔案一目了然。

定義內容模型

從部落格範本開始時,請編輯其現有的 seed/seed.json,而不要用毫不相干的模型取代它。下列精簡的 seed 保留了本指南範例所用的 collection 與結構資料:

{
  "$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 公開的路由識別碼。

在加入分類法、署名、選單參照、媒體、重新導向、widget 區域、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 是欄位對應方向的物件。請使用 orderBy: { published_at: "desc" },而不是 sort、sortBy 或 JavaScript 回呼函式。

下列路由會解析並渲染單篇文章:

---
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.rewrite("/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.rewrite("/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 檔案中。凡是預期由管理員編輯的值,都必須以設定、內容、選單或 widget 表示。

渲染圖片欄位

圖片欄位是媒體值,而不是 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>

除非你覆寫,否則該元件會使用欄位本身的替代文字。只對預期位於首屏的圖片保留 priority;其他圖片仍維持延遲載入。

提供頁面版面選項

上面的 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.rewrite("/404");

const { entry: page } = await getEmDashEntry("pages", slug);
if (!page) return Astro.rewrite("/404");

const layouts = {
  default: PageDefault,
  "full-width": PageFullWidth,
  landing: PageLanding,
};
const Layout = layouts[page.data.template as keyof typeof layouts] ?? PageDefault;
---

<Layout {page} />

明確的對應可以避免把儲存的欄位值變成任意的模組路徑。

新增搜尋

請在每個應出現在結果中的 collection 上啟用 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 時,才傳入 locale={null}。

Seed 可重複使用的 section

Section 為編輯提供可重複使用的 Portable Text 起點。當設計中包含重複出現的內容模式(例如行動呼籲)時,請加入它們:

{
  "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 的條目、署名與分類法術語。Section 以及其餘的結構模型仍會被套用。

新增結構化頁面 block

當頁面本身是由具類型的 section 依序組成時,請使用 blocks 欄位。當物件屬於富文字文件內部並參與其段落流時,請使用自訂 Portable Text block。

marketing 範本 seed 了五種保留的 block 類型,並允許它們出現在 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 類型及保留的版本保持一致。每個 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 內容、新建立內容、沒有選用圖片與空 collection 等情況下開啟每條路由。

  6. 在管理後台編輯網站設定、選單、分類法指派、Portable Text 與 block。對 block,新增、重新排序、複製並刪除每種允許的類型。確認公開路由使用變更後的值。

  7. 在拋棄式環境中套用部署相關設定,並驗證媒體儲存、預覽與伺服器端渲染。

發布範本

GitHub 儲存庫可以直接搭配 Astro 的 github: 範本語法使用:

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

發布前請刪除本機資料庫與上傳檔案、避免將密鑰放入儲存庫、從乾淨的 checkout 驗證套件路徑,並記載範本所設定的部署目標。

檢查清單

  • astro.config.mjs 使用伺服器端輸出與預期的部署配接器。
  • src/live.config.ts 註冊 emdashLoader()。
  • package.json#emdash.seed 指向存在的檔案。
  • 每個被查詢的 collection 與欄位都在 seed 中宣告。
  • 查詢範例使用 orderBy 與正確的條目識別碼。
  • CMS 管理的網站值沒有被當作永久的範本常數重複。
  • 乾淨的設定在有或沒有範例內容時都能運作。
  • 範本建置與型別檢查在支援的目標上通過。