หน้าย่อย (nested pages)
สิ่งที่จะได้เรียนรู้
ทำหน้าซ้อนในหน้า (เกี่ยวกับเรา → ทีมงาน → สมชาย) ให้แต่ละหน้ามีที่อยู่เต็ม (/about/team) และ breadcrumbs ของตัวเอง และให้ค่าเหล่านี้ถูกต้องเสมอเมื่อหน้าที่อยู่ด้านบนย้ายที่หรือเปลี่ยน slug
อ่านก่อนหน้านี้: Plugin

เว็บส่วนใหญ่แบ่งเป็นหมวด เช่น หน้าเกี่ยวกับเราที่มีหน้าทีมงานและประวัติอยู่ข้างใต้ หรือเอกสารที่มีบทและหัวข้อย่อย @easy-cms/plugin-nested-docs ให้แต่ละเอกสารมี หน้าแม่ ใน collection เดียวกัน และดูแลค่าสองค่าให้เป็นปัจจุบันเสมอ
- เส้นทาง (path): ที่อยู่เต็มของหน้า ประกอบจาก slug ของหน้าด้านบน เช่น
/about/team - Breadcrumbs: เส้นทางจากหน้าระดับบนสุดลงมาถึงหน้านั้น แต่ละขั้นมีชื่อและ path เช่น เกี่ยวกับเรา (
/about) › ทีมงาน (/about/team)
npm install @easy-cms/plugin-nested-docspnpm add @easy-cms/plugin-nested-docsyarn add @easy-cms/plugin-nested-docsbun add @easy-cms/plugin-nested-docsimport { nestedDocsPlugin } from '@easy-cms/plugin-nested-docs'
export default defineConfig({
// …
collections: [
{
slug: 'pages',
useAsTitle: 'title',
drafts: true,
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'slug', type: 'slug', from: 'title' },
{ name: 'body', type: 'richText' },
],
},
],
plugins: [nestedDocsPlugin({ collections: ['pages'] })],
})Plugin เพิ่มสาม field ให้แต่ละ collection คือ parent, path และ breadcrumbs จากนั้นสร้าง migration (easy-cms migrate:create nested-pages ดู Migration)
ใน admin

- รายการเป็นต้นไม้ หน้าระดับบนสุดขึ้นก่อน กดลูกศรข้างหน้าเพื่อดูหน้าย่อย เมื่อค้นหาหรือกรอง รายการจะแสดงแบบธรรมดา
- หน้าแม่ อยู่ในแผงด้านข้าง และเลือกได้เฉพาะหน้าที่เลือกได้จริง ไม่มีหน้าตัวเองหรือหน้าที่อยู่ใต้ตัวเอง ซึ่งจะทำให้วนลูป
- แผงด้านข้างแสดง path และ ตำแหน่งของหน้านี้ รวมถึงจำนวนหน้าย่อยพร้อมลิงก์ไปดู
slug ห้ามซ้ำกันเฉพาะหน้าที่มีหน้าแม่เดียวกัน /about/team กับ /careers/team จึงใช้ slug team ได้ทั้งคู่
แสดงหน้าจากที่อยู่
ที่อยู่ของหน้าบนเว็บเป็นแบบไหนก็ได้ เช่น /about/team หรือ /p/about/team เพื่อแยกจาก route อื่น ตัด prefix ของคุณออกแล้วหาส่วนที่เหลือด้วย findByPath
import { findByPath } from '@easy-cms/plugin-nested-docs'
export default defineEventHandler(async (event) => {
const cms = await useEasyCMS()
const page = await findByPath(cms, 'pages', String(getQuery(event).path))
if (!page) throw createError({ statusCode: 404 })
return page
})import { getEasyCMS } from '@easy-cms/next'
import { findByPath } from '@easy-cms/plugin-nested-docs'
import { notFound } from 'next/navigation'
import config from '@/easy-cms.config'
export default async function Page({ params }: { params: Promise<{ path: string[] }> }) {
const cms = await getEasyCMS(config)
const page = await findByPath(cms, 'pages', `/${(await params).path.join('/')}`)
if (!page) notFound()
return <h1>{String(page.title)}</h1>
}// frontend ใดก็ได้: path เป็น field ธรรมดา
const { docs } = await fetch(
`/api/cms/pages?where[path][equals]=${encodeURIComponent('/about/team')}&limit=1`,
).then((r) => r.json())findByPath รับ option เดียวกับ find (locale, draft, depth, user และ overrideAccess) และ decode ที่อยู่ให้ /%E0%B8%97%E0%B8%B5%E0%B8%A1 จึงหาเจอหน้า /ทีม
Breadcrumbs
แต่ละหน้ามีเส้นทางของตัวเองใน breadcrumbs เรียงจากระดับบนสุดและจบที่หน้านั้น
<nav aria-label="Breadcrumb">
<ol>
<li v-for="(crumb, i) in page.breadcrumbs" :key="crumb.id">
<NuxtLink v-if="i < page.breadcrumbs.length - 1" :to="`/p${crumb.url}`">{{ crumb.label }}</NuxtLink>
<span v-else aria-current="page">{{ crumb.label }}</span>
</li>
</ol>
</nav>แต่ละขั้นคือ { doc, label, url }: id ของหน้า ชื่อ (useAsTitle) และ path
เมนู
getTree ให้หน้าที่เผยแพร่แล้วเป็นต้นไม้ สำหรับทำเมนูหรือ sidebar ของเอกสาร
import { getTree } from '@easy-cms/plugin-nested-docs'
const menu = await getTree(cms, 'pages', { depth: 2, locale: 'en' })
// [{ id, title, slug, path, children: [{ id, title, slug, path, children: [] }] }, …]หน้าเรียงตามลำดับของรายการ: admin.list.sort ถ้าตั้งไว้ (เช่น order ซึ่งเป็น field ตัวเลขที่คุณเพิ่มเอง) ไม่อย่างนั้นเรียงตามชื่อ หน้าที่อยู่ใต้หน้าที่ยังไม่เผยแพร่จะไม่แสดงไปด้วย
frontend ที่อยู่คนละเซิร์ฟเวอร์ได้ข้อมูลเดียวกันจาก GET /api/cms/tree/pages?depth=2&locale=en ตามสิทธิ์อ่านของ collection
เมื่อหน้าย้ายที่
เมื่อหน้าได้ slug หรือหน้าแม่ใหม่ plugin จะบันทึกหน้าที่อยู่ข้างใต้ด้วย path และ breadcrumbs ใหม่ทีละระดับ
- Draft ไม่ทำให้อะไรย้าย: path เปลี่ยนเมื่อหน้า เผยแพร่ หน้าย่อยที่มี draft ค้างอยู่ยังเก็บ draft ไว้เหมือนเดิม ส่วนเวอร์ชันที่เผยแพร่อยู่จะได้ path ใหม่
- การลบ หน้าที่ยังมีหน้าย่อยจะไม่ได้รับอนุญาต ต้องย้ายหรือลบหน้าย่อยก่อน ถ้าตั้ง
onDeleteParent: 'orphan'หน้าย่อยจะขึ้นไปเป็นระดับบนสุดแทน (และที่อยู่จะเปลี่ยน) - ที่อยู่เดิม: เมื่อใช้คู่กับ plugin redirects และฟังก์ชันที่อยู่เดียวกัน ทุกหน้าที่ย้าย รวมถึงหน้าที่อยู่ใต้หน้าที่ย้าย จะได้ redirect จากที่อยู่เดิมโดยอัตโนมัติ
const pageURL = (doc) => (doc.path ? `/p${doc.path}` : null)
plugins: [
nestedDocsPlugin({ collections: ['pages'] }),
redirectsPlugin({ collections: ['pages'], url: ({ doc }) => pageURL(doc) }),
seoPlugin({ collections: ['pages'], generateURL: ({ doc }) => pageURL(doc) }),
]ถ้าต้องการให้ผลการค้นหาแสดงตำแหน่งของหน้า ส่ง breadcrumbs ให้ seoMeta ของ plugin SEO ซึ่งจะเพิ่ม BreadcrumbList JSON-LD ให้
const seo = seoMeta(page, {
url: (p) => pageURL(p),
breadcrumbs: page.breadcrumbs.map((b) => ({ name: b.label, url: `/p${b.url}` })),
})หลายภาษา
เมื่อ slug เป็น localized แต่ละภาษาจะมี path และ breadcrumbs ของตัวเอง: /about/team ในภาษาอังกฤษ และ /เกี่ยวกับ/ทีม ในภาษาไทย ส่วนหน้าแม่ใช้ร่วมกันทุกภาษา เพราะเว็บควรมีหมวดเหมือนกันทุกภาษา ส่ง locale ให้ findByPath และ getTree
หน้าที่มีอยู่ก่อนติดตั้ง plugin
เมื่อเพิ่ม plugin ให้ collection ที่มีหน้าอยู่แล้ว path ของหน้าเหล่านั้นจะว่างจนกว่าจะบันทึก คำนวณทั้งหมดในครั้งเดียวด้วย
npx easy-cms nested:rebuildpnpm exec easy-cms nested:rebuildyarn easy-cms nested:rebuildbunx easy-cms nested:rebuildคำสั่งนี้ยังซ่อมหน้าหลังเกิดข้อผิดพลาด และย้ายหน้าที่หน้าแม่ถูกลบไปอยู่ระดับบนสุด ในโค้ดใช้ rebuildNestedDocs(cms, 'pages') ได้เหมือนกัน ถ้า collection มี field parent ที่เป็น relationship ไปหาตัวเองอยู่แล้ว plugin จะใช้ field นั้นต่อ
Option
| Option | ค่าเริ่มต้น | |
|---|---|---|
collections | — | collection ที่เอกสารมีหน้าแม่ใน collection เดียวกันได้ |
slugField | slug | field ที่แต่ละหน้าต่อท้าย path ของหน้าแม่ (field แบบ slug หรือ text) |
titleField | useAsTitle | field ที่แสดงใน breadcrumbs |
fields | parent, breadcrumbs, path | { parent, breadcrumbs, path }: ชื่อ field ที่เพิ่ม |
maxDepth | 10 | จำนวนระดับของหน้า รวมระดับบนสุด |
onDeleteParent | restrict | restrict หรือ orphan: เมื่อลบหน้าที่มีหน้าย่อย |
ความสามารถของ core ที่ใช้
Plugin นี้สร้างจากความสามารถของ Easy CMS ที่ collection ของคุณใช้เองได้ด้วย
admin: { list: { tree: 'parent' } }: รายการแบบต้นไม้ตาม relationship ไปหา collection เดียวกัน ส่วนlist.sortกำหนดลำดับเริ่มต้นของรายการ ดู การตั้งค่าfilterOptionsของ relationship: เอกสารใดบ้างที่เลือกได้ ดู FielduniqueWithinของ slug: ห้ามซ้ำเฉพาะเอกสารที่มีค่าของอีก field เหมือนกันupdate(…, { live: true })ใน Local API: ดูแลค่าของเวอร์ชันที่เผยแพร่อยู่ โดยไม่แตะ draft ที่ค้างไว้และไม่เพิ่ม versioncommandsใน config: คำสั่งeasy-cms <name>จาก plugin ดู CLI