Plugins › หน้าย่อย
เอกสาร/Plugins/หน้าย่อย

หน้าย่อย (nested pages)

สิ่งที่จะได้เรียนรู้

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

อ่านก่อนหน้านี้: Plugin

หน้าต่าง ๆ แสดงเป็นต้นไม้ใน admin โดยเปิดหน้าย่อยของ เกี่ยวกับเรา ไว้หน้าต่าง ๆ แสดงเป็นต้นไม้ใน admin โดยเปิดหน้าย่อยของ เกี่ยวกับเรา ไว้

เว็บส่วนใหญ่แบ่งเป็นหมวด เช่น หน้าเกี่ยวกับเราที่มีหน้าทีมงานและประวัติอยู่ข้างใต้ หรือเอกสารที่มีบทและหัวข้อย่อย @easy-cms/plugin-nested-docs ให้แต่ละเอกสารมี หน้าแม่ ใน collection เดียวกัน และดูแลค่าสองค่าให้เป็นปัจจุบันเสมอ

  • เส้นทาง (path): ที่อยู่เต็มของหน้า ประกอบจาก slug ของหน้าด้านบน เช่น /about/team
  • Breadcrumbs: เส้นทางจากหน้าระดับบนสุดลงมาถึงหน้านั้น แต่ละขั้นมีชื่อและ path เช่น เกี่ยวกับเรา (/about) › ทีมงาน (/about/team)
npm
npm install @easy-cms/plugin-nested-docs
pnpm
pnpm add @easy-cms/plugin-nested-docs
yarn
yarn add @easy-cms/plugin-nested-docs
bun
bun add @easy-cms/plugin-nested-docs
import { 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 และตำแหน่งของหน้าแผงด้านข้างของหน้า: หน้าแม่ path และตำแหน่งของหน้า

  • รายการเป็นต้นไม้ หน้าระดับบนสุดขึ้นก่อน กดลูกศรข้างหน้าเพื่อดูหน้าย่อย เมื่อค้นหาหรือกรอง รายการจะแสดงแบบธรรมดา
  • หน้าแม่ อยู่ในแผงด้านข้าง และเลือกได้เฉพาะหน้าที่เลือกได้จริง ไม่มีหน้าตัวเองหรือหน้าที่อยู่ใต้ตัวเอง ซึ่งจะทำให้วนลูป
  • แผงด้านข้างแสดง path และ ตำแหน่งของหน้านี้ รวมถึงจำนวนหน้าย่อยพร้อมลิงก์ไปดู

slug ห้ามซ้ำกันเฉพาะหน้าที่มีหน้าแม่เดียวกัน /about/team กับ /careers/team จึงใช้ slug team ได้ทั้งคู่

แสดงหน้าจากที่อยู่

ที่อยู่ของหน้าบนเว็บเป็นแบบไหนก็ได้ เช่น /about/team หรือ /p/about/team เพื่อแยกจาก route อื่น ตัด prefix ของคุณออกแล้วหาส่วนที่เหลือด้วย findByPath

Nuxt: server/api/page.get.ts
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>
}
REST
// 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 เรียงจากระดับบนสุดและจบที่หน้านั้น

<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 ของหน้าเหล่านั้นจะว่างจนกว่าจะบันทึก คำนวณทั้งหมดในครั้งเดียวด้วย

npm
npx easy-cms nested:rebuild
pnpm
pnpm exec easy-cms nested:rebuild
yarn
yarn easy-cms nested:rebuild
bun
bunx easy-cms nested:rebuild

คำสั่งนี้ยังซ่อมหน้าหลังเกิดข้อผิดพลาด และย้ายหน้าที่หน้าแม่ถูกลบไปอยู่ระดับบนสุด ในโค้ดใช้ rebuildNestedDocs(cms, 'pages') ได้เหมือนกัน ถ้า collection มี field parent ที่เป็น relationship ไปหาตัวเองอยู่แล้ว plugin จะใช้ field นั้นต่อ

Option

Optionค่าเริ่มต้น
collections—collection ที่เอกสารมีหน้าแม่ใน collection เดียวกันได้
slugFieldslugfield ที่แต่ละหน้าต่อท้าย path ของหน้าแม่ (field แบบ slug หรือ text)
titleFielduseAsTitlefield ที่แสดงใน breadcrumbs
fieldsparent, breadcrumbs, path{ parent, breadcrumbs, path }: ชื่อ field ที่เพิ่ม
maxDepth10จำนวนระดับของหน้า รวมระดับบนสุด
onDeleteParentrestrictrestrict หรือ orphan: เมื่อลบหน้าที่มีหน้าย่อย

ความสามารถของ core ที่ใช้

Plugin นี้สร้างจากความสามารถของ Easy CMS ที่ collection ของคุณใช้เองได้ด้วย

  • admin: { list: { tree: 'parent' } }: รายการแบบต้นไม้ตาม relationship ไปหา collection เดียวกัน ส่วน list.sort กำหนดลำดับเริ่มต้นของรายการ ดู การตั้งค่า
  • filterOptions ของ relationship: เอกสารใดบ้างที่เลือกได้ ดู Field
  • uniqueWithin ของ slug: ห้ามซ้ำเฉพาะเอกสารที่มีค่าของอีก field เหมือนกัน
  • update(…, { live: true }) ใน Local API: ดูแลค่าของเวอร์ชันที่เผยแพร่อยู่ โดยไม่แตะ draft ที่ค้างไว้และไม่เพิ่ม version
  • commands ใน config: คำสั่ง easy-cms <name> จาก plugin ดู CLI

ในหลาย tenant

เมื่อใช้ plugin multi-tenant path ห้ามซ้ำแค่ภายใน tenant ส่ง context ของ request ให้ findByPath() และ getTree()

ขั้นต่อไป

  • Redirects: ให้ที่อยู่เดิมยังใช้ได้
  • SEO: metadata และ sitemap ของหน้า