Let categories have child categories for nested pages. (#6)

* Let categories have child categories for nested pages.

* fixes
This commit is contained in:
corsacca 2026-05-21 06:00:54 -05:00 committed by GitHub
parent 594bed8f4c
commit 6f918a9d85
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 1612 additions and 501 deletions

View file

@ -34,4 +34,23 @@
.current-link {
color: var(--color-brand-primary);
}
// Recursive nav (categories + pages). Each row carries its own
// depth class so we indent visually without manual nested <ul>s.
// Depth 0 = the root category heading; depth 1+ are children.
.nav-tree {
.nav-row {
margin-block: var(--spacing-3xs);
}
.nav-depth-1 { padding-inline-start: var(--spacing-md); }
.nav-depth-2 { padding-inline-start: calc(var(--spacing-md) * 2); }
.nav-depth-3 { padding-inline-start: calc(var(--spacing-md) * 3); }
.nav-depth-4 { padding-inline-start: calc(var(--spacing-md) * 4); }
// Sub-category headings (depth ≥ 1) get a slightly heavier
// weight to visually separate them from page links underneath.
.nav-category .category-link {
font-weight: 600;
}
}
}

View file

@ -12,18 +12,45 @@
interface ChildPage {
slug: string
url: string
title: string
excerpt: string | null
featured_image: string | null
menu_order: number
}
interface ChildCategory {
slug: string
url: string
title: string
excerpt: string | null
featured_image: string | null
menu_order: number
}
interface NavPageNode {
kind: 'page'
url: string
title: string
menu_order: number
}
interface NavCategoryNode {
kind: 'category'
url: string
title: string
menu_order: number
items: Array<NavPageNode | NavCategoryNode>
}
type NavNode = NavPageNode | NavCategoryNode
type PageTheme = 'default' | 'green'
interface PageResponse {
url: string
slug: string
category_id: string | null
category_slug: string | null
category_url: string | null
menu_order: number
theme: PageTheme
custom_css: string | null
@ -37,8 +64,11 @@ interface PageResponse {
og_image: string | null
body_html: string
body_is_empty: boolean
menu_parent: { slug: string; title: string } | null
is_category_landing: boolean
menu_parent: { slug: string; url: string; title: string } | null
children: ChildPage[]
child_categories: ChildCategory[]
nav_tree: NavCategoryNode | null
}
import { h, render, getCurrentInstance } from 'vue'
@ -82,8 +112,14 @@ if (!data.value && !error.value) {
// <head>
useHead(() => {
if (!data.value) return {}
// A category index represents the category itself, so title it by the
// category name (menu_parent is the active category on a landing)
// rather than the resolved default page's title.
const mp = data.value.menu_parent
return {
title: data.value.meta_title || data.value.title,
title: mp && data.value.child_categories.length > 0
? mp.title
: (data.value.meta_title || data.value.title),
meta: [
...(data.value.meta_description ? [{ name: 'description', content: data.value.meta_description }] : []),
...(data.value.og_image ? [{ property: 'og:image', content: data.value.og_image }] : [])
@ -102,7 +138,7 @@ useHead(() => {
// cascade regardless of where main.scss ends up in <head>.
style: data.value.custom_css
? [{
key: `page-custom-css-${data.value.slug}`,
key: `page-custom-css-${data.value.url}`,
tagPosition: 'bodyClose' as const,
innerHTML: data.value.custom_css
}]
@ -110,19 +146,48 @@ useHead(() => {
}
})
const hasSidebar = computed(() => (data.value?.children.length ?? 0) > 0)
// A page renders its children as cards (instead of sidebar + body) when
// it's the category's default landing AND its own body is empty. Useful
// for pure index pages like /resources.
const isCategoryDefault = computed(() =>
Boolean(data.value && data.value.category_slug && data.value.menu_order === 0)
)
// Flatten the recursive nav tree into a list of rows for the sidebar.
// Each row has a `depth` for visual indent. The root category itself
// renders as a bold heading; everything below indents one step further.
interface NavRow {
depth: number
kind: 'page' | 'category'
url: string
title: string
}
function flattenNav(node: NavCategoryNode, depth: number, out: NavRow[]) {
out.push({ depth, kind: 'category', url: node.url, title: node.title })
for (const item of node.items) {
if (item.kind === 'page') {
out.push({ depth: depth + 1, kind: 'page', url: item.url, title: item.title })
} else {
flattenNav(item, depth + 1, out)
}
}
}
const navRows = computed<NavRow[]>(() => {
const tree = data.value?.nav_tree
if (!tree) return []
const out: NavRow[] = []
flattenNav(tree, 0, out)
return out
})
const hasSidebar = computed(() => navRows.value.length > 0 || (data.value?.children.length ?? 0) > 0)
// A category that has subcategories renders as an index: a card grid of
// its subcategories followed by its own pages (set server-side only on
// the bare category URL). Categories with just pages fall through to the
// "open the first page" logic below.
const showCategoryGrid = computed(() => (data.value?.child_categories.length ?? 0) > 0)
const showChildGrid = computed(() =>
Boolean(
data.value
&& isCategoryDefault.value
&& data.value.is_category_landing
&& data.value.body_is_empty
&& data.value.children.length > 0
&& !showCategoryGrid.value
)
)
@ -226,18 +291,46 @@ onBeforeUnmount(unmountSlots)
<div v-if="data" class="container page-content">
<div :class="hasSidebar ? 'with-sidebar' : ''">
<aside v-if="hasSidebar" class="sidebar">
<nav class="stack" aria-label="Child pages navigation">
<nav class="stack" aria-label="Section navigation">
<div>
<ul class="stack | max-width-xs" role="list">
<!-- Recursive nav (categories + pages). Falls back to the
flat siblings list when no tree is available (e.g.
uncategorized pages). -->
<ul v-if="navRows.length" class="stack | max-width-xs nav-tree" role="list">
<li
v-for="row in navRows"
:key="`${row.kind}-${row.url}`"
:class="['nav-row', `nav-depth-${row.depth}`, `nav-${row.kind}`]"
>
<span v-if="row.kind === 'category' && row.depth === 0" class="font-size-lg category-heading">
{{ row.title }}
</span>
<NuxtLink
v-else-if="row.kind === 'category'"
:class="['category-link', { 'current-link': data.url === row.url }]"
:to="localePath(`/${row.url}`)"
>
{{ row.title }}
</NuxtLink>
<NuxtLink
v-else
:class="{ 'current-link': data.url === row.url }"
:to="localePath(`/${row.url}`)"
>
{{ row.title }}
</NuxtLink>
</li>
</ul>
<ul v-else class="stack | max-width-xs" role="list">
<li v-if="data.menu_parent">
<span class="font-size-lg category-heading">
{{ data.menu_parent.title }}
</span>
</li>
<li v-for="child in data.children" :key="child.slug">
<li v-for="child in data.children" :key="child.url">
<NuxtLink
:class="{ 'current-link': data.slug === child.slug }"
:to="localePath(`/${child.slug}`)"
:class="{ 'current-link': data.url === child.url }"
:to="localePath(`/${child.url}`)"
>
{{ child.title }}
</NuxtLink>
@ -247,7 +340,7 @@ onBeforeUnmount(unmountSlots)
</nav>
</aside>
<article :id="`page-${data.slug}`" class="page">
<article :id="`page-${data.slug || data.category_slug}`" class="page">
<!-- WP page.php emits `page-featured-image` only when a
post_thumbnail is explicitly set. Matches live behavior. -->
<div v-if="data.featured_image" class="page-featured-image">
@ -258,19 +351,66 @@ onBeforeUnmount(unmountSlots)
the top so visitors landing on a sub-page (e.g. /resources/
overview) get a clear heading. The document <title> is also
set via useHead() above. -->
<h1 v-if="data.menu_parent" class="page-title">{{ data.title }}</h1>
<div v-if="!showChildGrid" class="page-body" v-html="data.body_html" />
<h1 v-if="showCategoryGrid && data.menu_parent" class="page-title">{{ data.menu_parent.title }}</h1>
<h1 v-else-if="data.menu_parent && data.title" class="page-title">{{ data.title }}</h1>
<div v-if="!showChildGrid && !showCategoryGrid" class="page-body" v-html="data.body_html" />
<div v-if="showChildGrid" class="grid">
<div v-for="child in data.children" :key="child.slug" class="card" data-variant="secondary">
<div v-if="showCategoryGrid" class="grid">
<!-- Subcategories first … -->
<div
v-for="cat in data.child_categories"
:key="cat.url"
class="card"
data-variant="secondary"
>
<div v-if="cat.featured_image" class="child-thumbnail">
<NuxtLink :to="localePath(`/${cat.url}`)">
<img :src="cat.featured_image" :alt="cat.title">
</NuxtLink>
</div>
<div class="stack">
<h3>
<NuxtLink class="color-white" :to="localePath(`/${cat.url}`)">
{{ cat.title }}
</NuxtLink>
</h3>
<div v-if="cat.excerpt" class="child-excerpt" v-html="cat.excerpt" />
</div>
</div>
<!-- … then this category's own pages. -->
<div
v-for="child in data.children"
:key="child.url"
class="card"
data-variant="secondary"
>
<div v-if="child.featured_image" class="child-thumbnail">
<NuxtLink :to="localePath(`/${child.slug}`)">
<NuxtLink :to="localePath(`/${child.url}`)">
<img :src="child.featured_image" :alt="child.title">
</NuxtLink>
</div>
<div class="stack">
<h3>
<NuxtLink class="color-white" :to="localePath(`/${child.slug}`)">
<NuxtLink class="color-white" :to="localePath(`/${child.url}`)">
{{ child.title }}
</NuxtLink>
</h3>
<div v-if="child.excerpt" class="child-excerpt" v-html="child.excerpt" />
</div>
</div>
</div>
<div v-else-if="showChildGrid" class="grid">
<div v-for="child in data.children" :key="child.url" class="card" data-variant="secondary">
<div v-if="child.featured_image" class="child-thumbnail">
<NuxtLink :to="localePath(`/${child.url}`)">
<img :src="child.featured_image" :alt="child.title">
</NuxtLink>
</div>
<div class="stack">
<h3>
<NuxtLink class="color-white" :to="localePath(`/${child.url}`)">
{{ child.title }}
</NuxtLink>
</h3>

View file

@ -28,6 +28,10 @@ interface Page {
interface CategoryRow {
id: string
slug: string
url: string
parent_path: string | null
parent_label: string | null
parent_id: string | null
menu_order: number
translations: Array<{ locale: string; name: string }>
page_count: number
@ -51,6 +55,8 @@ interface Translation {
interface PageDetail {
page: Page
translations: Translation[]
url: string
category_path: string | null
}
const route = useRoute()
@ -59,16 +65,16 @@ const toast = useToast()
const pageId = computed(() => String(route.params.id))
// Live public URL for the "View page" link in the Publish card. Uses the
// committed slug (data) not the in-flight slug ref, so the link always
// points to an existing route even if the author is editing the slug.
// English is the default locale, so `prefix_except_default` means no prefix.
// Live public URL for the "View page" link in the Publish card. Uses
// the server-computed full URL (data.url) so it stays correct as the
// page moves between categories. English is the default locale, so
// `prefix_except_default` means no prefix.
const publicUrl = computed(() => {
const committedSlug = data.value?.page.slug
if (!committedSlug) return null
const committedUrl = data.value?.url
if (!committedUrl) return null
return activeLocale.value === 'en'
? `/${committedSlug}`
: `/${activeLocale.value}/${committedSlug}`
? `/${committedUrl}`
: `/${activeLocale.value}/${committedUrl}`
})
const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] }
@ -82,13 +88,15 @@ const { data: categoriesData } = await useFetch<{ rows: CategoryRow[] }>(
const categories = computed(() => categoriesData.value?.rows ?? [])
function categoryLabel(cat: CategoryRow): string {
const en = cat.translations.find(t => t.locale === 'en')?.name
return en ?? cat.slug
const en = cat.translations.find(t => t.locale === 'en')?.name ?? cat.slug
return cat.parent_label ? `${cat.parent_label} / ${en}` : en
}
const categoryItems = computed(() => [
{ label: '— Uncategorized —', value: null as string | null },
...categories.value.map(c => ({ label: categoryLabel(c), value: c.id as string | null }))
...[...categories.value]
.sort((a, b) => a.url.localeCompare(b.url))
.map(c => ({ label: categoryLabel(c), value: c.id as string | null }))
])
// Metadata editable in the top bar
@ -115,6 +123,16 @@ const selectedCategory = computed(() =>
const isCategoryChanged = computed(() => categoryId.value !== originalCategoryId.value)
// Live preview of the full URL — re-derived from the (in-flight) leaf
// slug + selected category so the editor sees the new URL before they
// save. Used in the Slug field's helper text.
const previewFullUrl = computed(() => {
const leaf = slug.value.trim().replace(/^\/+|\/+$/g, '')
if (!leaf) return ''
const prefix = selectedCategory.value?.url
return prefix ? `${prefix}/${leaf}` : leaf
})
const THEME_OPTIONS: Array<{ label: string; value: PageTheme }> = [
{ label: 'Default', value: 'default' },
{ label: 'Green background', value: 'green' }
@ -646,7 +664,7 @@ function formatVersionTime(iso: string): string {
<div class="space-y-4">
<UFormField
label="Category"
:description="isCategoryChanged ? 'Changing the category rewrites the slug prefix — the old URL will 404.' : 'Group this page under a category.'"
:description="isCategoryChanged ? 'Moving to a different category will change this page’s URL — the old URL will 404.' : 'Group this page under a category.'"
>
<USelect
v-model="categoryId"
@ -657,9 +675,13 @@ function formatVersionTime(iso: string): string {
</UFormField>
<UFormField
label="Slug"
:description="selectedCategory ? `Full URL: /${slug}. Must start with ${selectedCategory.slug}/` : 'URL path (e.g. privacy)'"
:description="previewFullUrl ? `Full URL: /${previewFullUrl}` : 'Leaf segment only — lowercase letters, digits, dashes.'"
>
<UInput v-model="slug" />
<div v-if="selectedCategory" class="flex items-center gap-1 min-w-0">
<span class="shrink-0 text-(--ui-text-muted) font-mono text-sm truncate">{{ selectedCategory.url }}/</span>
<UInput v-model="slug" class="flex-1 min-w-0" />
</div>
<UInput v-else v-model="slug" class="w-full" />
</UFormField>
<UFormField label="Page theme" description="Applied to <body>.">
<USelect v-model="theme" :items="THEME_OPTIONS" class="w-full" />

View file

@ -17,22 +17,45 @@ definePageMeta({
interface CategoryPage {
id: string
slug: string
url: string
menu_order: number
title_en: string | null
published_count: number
translation_count: number
}
interface ChildCategory {
id: string
slug: string
url: string
menu_order: number
}
interface CategoryDetail {
category: {
id: string
slug: string
parent_id: string | null
menu_order: number
created: string
updated: string
}
url: string
parent_path: string | null
parent_label: string | null
translations: Array<{ locale: string; name: string; updated: string }>
pages: CategoryPage[]
child_categories: ChildCategory[]
}
interface CategoryRow {
id: string
slug: string
url: string
parent_id: string | null
parent_path: string | null
parent_label: string | null
translations: Array<{ locale: string; name: string }>
}
const route = useRoute()
@ -46,6 +69,8 @@ const { data, pending, refresh } = await useFetch<CategoryDetail>(
const slug = ref('')
const originalSlug = ref('')
const parentId = ref<string | null>(null)
const originalParentId = ref<string | null>(null)
const names = reactive<Record<string, string>>({})
for (const l of ENABLED_LANGUAGES) names[l.code] = ''
@ -59,6 +84,8 @@ watch(data, (value) => {
if (!value) return
slug.value = value.category.slug
originalSlug.value = value.category.slug
parentId.value = value.category.parent_id
originalParentId.value = value.category.parent_id
for (const l of ENABLED_LANGUAGES) {
const t = value.translations.find(x => x.locale === l.code)
names[l.code] = t?.name ?? ''
@ -71,7 +98,51 @@ const activeLocale = ref<string>('en')
const saving = ref(false)
const savingOrder = ref(false)
const { data: allCategoriesData } = await useFetch<{ rows: CategoryRow[] }>(
'/api/admin/categories',
{ default: () => ({ rows: [] }) }
)
function categoryLabel(cat: CategoryRow): string {
const en = cat.translations.find(t => t.locale === 'en')?.name ?? cat.slug
return cat.parent_label ? `${cat.parent_label} / ${en}` : en
}
// Block self/descendants from the parent picker so the user can't
// accidentally request a cycle (the server would reject anyway, but
// not surfacing the option avoids a confusing failure).
const descendantIds = computed(() => {
const all = allCategoriesData.value?.rows ?? []
const childrenByParent = new Map<string | null, CategoryRow[]>()
for (const c of all) {
const key = c.parent_id ?? null
const list = childrenByParent.get(key) ?? []
list.push(c)
childrenByParent.set(key, list)
}
const out = new Set<string>([categoryId.value])
const stack = [categoryId.value]
while (stack.length) {
const id = stack.pop()!
for (const c of childrenByParent.get(id) ?? []) {
out.add(c.id)
stack.push(c.id)
}
}
return out
})
const parentItems = computed(() => [
{ label: '— Top level —', value: null as string | null },
...[...(allCategoriesData.value?.rows ?? [])]
.filter(c => !descendantIds.value.has(c.id))
.sort((a, b) => a.url.localeCompare(b.url))
.map(c => ({ label: categoryLabel(c), value: c.id as string | null }))
])
const slugWillRename = computed(() => slug.value.trim() !== originalSlug.value)
const parentWillChange = computed(() => parentId.value !== originalParentId.value)
const willChangeUrls = computed(() => slugWillRename.value || parentWillChange.value)
async function saveDetails() {
const slugValue = slug.value.trim().replace(/^\/+|\/+$/g, '')
@ -92,7 +163,7 @@ async function saveDetails() {
}))
await $fetch(`/api/admin/categories/${categoryId.value}`, {
method: 'PATCH',
body: { slug: slugValue, translations }
body: { slug: slugValue, parent_id: parentId.value, translations }
})
toast.add({ title: 'Category saved', color: 'success' })
await refresh()
@ -169,8 +240,10 @@ async function deleteCategory() {
variant="outline"
color="error"
icon="i-lucide-trash-2"
:disabled="(data?.pages.length ?? 0) > 0"
:title="(data?.pages.length ?? 0) > 0 ? 'Move or delete member pages first' : 'Delete category'"
:disabled="(data?.pages.length ?? 0) > 0 || (data?.child_categories.length ?? 0) > 0"
:title="(data?.pages.length ?? 0) > 0
? 'Move or delete member pages first'
: ((data?.child_categories.length ?? 0) > 0 ? 'Move or delete child categories first' : 'Delete category')"
@click="deleteModalOpen = true"
>
Delete
@ -188,9 +261,20 @@ async function deleteCategory() {
</template>
<div class="space-y-4">
<UFormField
label="Parent category"
:description="parentWillChange ? 'Moving this category to a different parent will change every URL beneath it. Old URLs will 404.' : 'Leave as Top level for a root category.'"
>
<USelect
v-model="parentId"
:items="parentItems"
value-key="value"
class="w-full"
/>
</UFormField>
<UFormField
label="Slug"
:description="slugWillRename ? 'Changing the slug will rewrite every member page URL. Old URLs will 404.' : 'URL prefix for member pages.'"
:description="willChangeUrls ? 'Saving will rewrite every URL beneath this category. Old URLs will 404.' : `Current URL: /${data.url}`"
>
<UInput v-model="slug" />
</UFormField>
@ -230,6 +314,32 @@ async function deleteCategory() {
</div>
</UCard>
<UCard v-if="data.child_categories?.length">
<template #header>
<span class="text-sm font-semibold">Child categories ({{ data.child_categories.length }})</span>
</template>
<ul class="divide-y divide-(--ui-border)">
<li
v-for="child in data.child_categories"
:key="child.id"
class="flex items-center gap-3 py-2 px-2"
>
<div class="flex-1 min-w-0">
<div class="text-sm truncate">{{ child.slug }}</div>
<div class="text-xs text-(--ui-text-muted) font-mono truncate">/{{ child.url }}</div>
</div>
<UButton
size="xs"
variant="outline"
icon="i-lucide-pencil"
:to="`/admin/pages/categories/${child.id}`"
>
Edit
</UButton>
</li>
</ul>
</UCard>
<UCard>
<template #header>
<div class="flex items-center justify-between">
@ -272,10 +382,10 @@ async function deleteCategory() {
<div class="text-sm truncate">
{{ page.title_en || 'Untitled' }}
<UBadge v-if="idx === 0" size="xs" variant="subtle" color="info" class="ml-2">
Default for /{{ data.category.slug }}
Default for /{{ data.url }}
</UBadge>
</div>
<div class="text-xs text-(--ui-text-muted) font-mono truncate">{{ page.slug }}</div>
<div class="text-xs text-(--ui-text-muted) font-mono truncate">/{{ page.url }}</div>
</div>
<UBadge
size="xs"

View file

@ -11,6 +11,10 @@ definePageMeta({
interface CategoryRow {
id: string
slug: string
url: string
parent_id: string | null
parent_path: string | null
parent_label: string | null
menu_order: number
translations: Array<{ locale: string; name: string; updated: string }>
page_count: number
@ -30,6 +34,35 @@ function englishName(row: CategoryRow): string {
return row.translations.find(t => t.locale === 'en')?.name ?? row.slug
}
// Group categories by parent so the table renders a tree (root rows
// followed by their children, indented). Walks from each root and
// emits descendants depth-first.
const sortedRows = computed<Array<CategoryRow & { depth: number }>>(() => {
const all = data.value?.rows ?? []
if (all.length === 0) return []
const byParent = new Map<string | null, CategoryRow[]>()
for (const row of all) {
const key = row.parent_id ?? null
const list = byParent.get(key) ?? []
list.push(row)
byParent.set(key, list)
}
for (const list of byParent.values()) {
list.sort((a, b) =>
a.menu_order - b.menu_order || a.slug.localeCompare(b.slug)
)
}
const out: Array<CategoryRow & { depth: number }> = []
function walk(parentId: string | null, depth: number) {
for (const row of byParent.get(parentId) ?? []) {
out.push({ ...row, depth })
walk(row.id, depth + 1)
}
}
walk(null, 0)
return out
})
// Delete flow — blocked server-side if any pages still belong to the
// category, so we surface the 409 message directly.
const deleteModalOpen = ref(false)
@ -93,7 +126,7 @@ async function confirmDelete() {
<thead class="bg-(--ui-bg-elevated) text-(--ui-text-muted)">
<tr>
<th class="text-left px-3 py-2 font-medium">Name (EN)</th>
<th class="text-left px-3 py-2 font-medium">Slug</th>
<th class="text-left px-3 py-2 font-medium">URL</th>
<th class="text-left px-3 py-2 font-medium">Translations</th>
<th class="text-left px-3 py-2 font-medium">Pages</th>
<th class="text-right px-3 py-2 font-medium">Actions</th>
@ -101,13 +134,18 @@ async function confirmDelete() {
</thead>
<tbody>
<tr
v-for="row in data.rows"
v-for="row in sortedRows"
:key="row.id"
class="border-t border-(--ui-border) hover:bg-(--ui-bg-elevated) cursor-pointer"
@click="router.push(`/admin/pages/categories/${row.id}`)"
>
<td class="px-3 py-2">{{ englishName(row) }}</td>
<td class="px-3 py-2 font-mono">{{ row.slug }}</td>
<td class="px-3 py-2">
<span :style="{ paddingLeft: `${row.depth * 1.25}rem` }">
<span v-if="row.depth > 0" class="text-(--ui-text-muted) mr-1">↳</span>
{{ englishName(row) }}
</span>
</td>
<td class="px-3 py-2 font-mono text-xs">/{{ row.url }}</td>
<td class="px-3 py-2">
<div class="flex flex-wrap gap-1">
<UBadge

View file

@ -1,8 +1,7 @@
<script setup lang="ts">
// Admin: create a new category. Collect the slug and a per-locale name
// for each enabled language. Server insists on a non-empty English
// name; the other locales can be filled in here or later on the edit
// screen.
// Admin: create a new category. Collect the slug, an optional parent
// category, and a per-locale name for each enabled language. Server
// insists on a non-empty English name.
import { ENABLED_LANGUAGES } from '~~/config/languages'
@ -11,16 +10,53 @@ definePageMeta({
middleware: ['auth', 'admin']
})
interface CategoryRow {
id: string
slug: string
url: string
parent_id: string | null
parent_path: string | null
parent_label: string | null
translations: Array<{ locale: string; name: string }>
}
const toast = useToast()
const router = useRouter()
const slug = ref('')
const parentId = ref<string | null>(null)
const names = reactive<Record<string, string>>({})
for (const l of ENABLED_LANGUAGES) names[l.code] = ''
const activeLocale = ref<string>('en')
const creating = ref(false)
const { data: categoriesData } = await useFetch<{ rows: CategoryRow[] }>(
'/api/admin/categories',
{ default: () => ({ rows: [] }) }
)
const categories = computed(() => categoriesData.value?.rows ?? [])
function categoryLabel(cat: CategoryRow): string {
const en = cat.translations.find(t => t.locale === 'en')?.name ?? cat.slug
return cat.parent_label ? `${cat.parent_label} / ${en}` : en
}
const parentItems = computed(() => [
{ label: '— Top level —', value: null as string | null },
...[...categories.value]
.sort((a, b) => a.url.localeCompare(b.url))
.map(c => ({ label: categoryLabel(c), value: c.id as string | null }))
])
const previewUrl = computed(() => {
const leaf = slug.value.trim().replace(/^\/+|\/+$/g, '')
if (!leaf) return ''
const parent = parentId.value ? categories.value.find(c => c.id === parentId.value) : null
return parent ? `${parent.url}/${leaf}` : leaf
})
async function submit() {
const slugValue = slug.value.trim().replace(/^\/+|\/+$/g, '')
if (!slugValue) {
@ -41,7 +77,7 @@ async function submit() {
const category = await $fetch<{ id: string }>('/api/admin/categories', {
method: 'POST',
body: { slug: slugValue, translations }
body: { slug: slugValue, parent_id: parentId.value, translations }
})
toast.add({ title: 'Category created', color: 'success' })
router.push(`/admin/pages/categories/${category.id}`)
@ -66,8 +102,21 @@ async function submit() {
<UCard>
<div class="space-y-4">
<UFormField label="Slug" required description="Lowercase letters, digits, and dashes. Used as the URL prefix for every page in this category (e.g. about).">
<UInput v-model="slug" placeholder="about" />
<UFormField label="Parent category" description="Leave as Top level for a root category like /resources.">
<USelect
v-model="parentId"
:items="parentItems"
value-key="value"
class="w-full"
/>
</UFormField>
<UFormField
label="Slug"
required
:description="previewUrl ? `URL will be /${previewUrl}` : 'Lowercase letters, digits, and dashes (no slashes).'"
>
<UInput v-model="slug" placeholder="adoption" />
</UFormField>
<div>
@ -94,7 +143,7 @@ async function submit() {
:label="`${l.flag} ${l.nativeName}`"
:required="l.code === 'en'"
>
<UInput v-model="names[l.code]" :placeholder="l.code === 'en' ? 'About' : 'Leave blank to fill later'" />
<UInput v-model="names[l.code]" :placeholder="l.code === 'en' ? 'Adoption Resources' : 'Leave blank to fill later'" />
</UFormField>
</div>
</div>

View file

@ -11,6 +11,7 @@ definePageMeta({
interface PageRow {
id: string
slug: string
url: string
category_id: string | null
category_slug: string | null
category_name_en: string | null
@ -24,6 +25,10 @@ interface PageRow {
interface CategoryRow {
id: string
slug: string
url: string
parent_path: string | null
parent_label: string | null
parent_id: string | null
menu_order: number
translations: Array<{ locale: string; name: string }>
page_count: number
@ -45,8 +50,8 @@ const { data: categoriesData } = await useFetch<{ rows: CategoryRow[] }>(
const categories = computed(() => categoriesData.value?.rows ?? [])
function categoryLabel(cat: CategoryRow): string {
const en = cat.translations.find(t => t.locale === 'en')?.name
return en ?? cat.slug
const en = cat.translations.find(t => t.locale === 'en')?.name ?? cat.slug
return cat.parent_label ? `${cat.parent_label} / ${en}` : en
}
// Modal state for creating a new page
@ -58,7 +63,7 @@ const creating = ref(false)
const newCategoryPrefix = computed(() => {
if (!newCategoryId.value) return ''
const cat = categories.value.find(c => c.id === newCategoryId.value)
return cat ? `${cat.slug}/` : ''
return cat ? `${cat.url}/` : ''
})
const newFullSlug = computed(() => {
@ -68,7 +73,9 @@ const newFullSlug = computed(() => {
const categoryItems = computed(() => [
{ label: '— Uncategorized —', value: null as string | null },
...categories.value.map(c => ({ label: categoryLabel(c), value: c.id as string | null }))
...[...categories.value]
.sort((a, b) => a.url.localeCompare(b.url))
.map(c => ({ label: categoryLabel(c), value: c.id as string | null }))
])
function resetCreateForm() {
@ -79,8 +86,8 @@ function resetCreateForm() {
async function createPage() {
creating.value = true
try {
const slug = newFullSlug.value
if (!slug) {
const leaf = newSlugLeaf.value.trim().replace(/^\/+|\/+$/g, '')
if (!leaf) {
toast.add({ title: 'Slug is required', color: 'error' })
creating.value = false
return
@ -88,7 +95,7 @@ async function createPage() {
const page = await $fetch<{ id: string }>('/api/admin/pages', {
method: 'POST',
body: {
slug,
slug: leaf,
category_id: newCategoryId.value
}
})
@ -226,7 +233,7 @@ const filteredRows = computed<PageRow[]>(() => {
<span v-if="row.title_en">{{ row.title_en }}</span>
<em v-else class="text-(--ui-text-muted)">Untitled</em>
</td>
<td class="px-3 py-2 font-mono">{{ row.slug }}</td>
<td class="px-3 py-2 font-mono text-xs">{{ row.url }}</td>
<td class="px-3 py-2">
<span v-if="row.category_slug">{{ row.category_name_en || row.category_slug }}</span>
<em v-else class="text-(--ui-text-muted)">—</em>
@ -264,12 +271,12 @@ const filteredRows = computed<PageRow[]>(() => {
class="w-full"
/>
</UFormField>
<UFormField label="Slug" required :description="newCategoryPrefix ? `Full URL will be /${newFullSlug}` : 'e.g. privacy, contact'">
<UInput v-model="newSlugLeaf" :placeholder="newCategoryId ? 'vision' : 'privacy'">
<template v-if="newCategoryPrefix" #leading>
<span class="text-(--ui-text-muted) font-mono text-sm">{{ newCategoryPrefix }}</span>
</template>
</UInput>
<UFormField label="Slug" required :description="newSlugLeaf ? `Full URL will be /${newFullSlug}` : 'Leaf segment only — lowercase letters, digits, dashes.'">
<div v-if="newCategoryPrefix" class="flex items-center gap-1 min-w-0">
<span class="shrink-0 text-(--ui-text-muted) font-mono text-sm truncate">{{ newCategoryPrefix }}</span>
<UInput v-model="newSlugLeaf" :placeholder="newCategoryId ? 'vision' : 'privacy'" class="flex-1 min-w-0" />
</div>
<UInput v-else v-model="newSlugLeaf" :placeholder="newCategoryId ? 'vision' : 'privacy'" class="w-full" />
</UFormField>
</div>
</template>

View file

@ -0,0 +1,112 @@
import { Kysely, sql } from 'kysely'
// Switches the CMS to nested categories with leaf-only slugs.
//
// Before: pages.slug stored the full URL path ("resources/overview");
// categories were flat (no parent_id); the slug prefix and category_id
// were independent fields that could drift apart.
//
// After: each category has an optional parent_id forming a tree;
// pages.slug stores only the page's own segment ("overview"); the
// public URL is derived at request time by walking the category chain
// + appending the page slug. Renaming or moving anywhere in the tree
// instantly updates every URL underneath.
//
// Uniqueness:
// - categories(parent_id, slug) unique — top-level slugs unique
// across NULL parents via a partial index.
// - pages(category_id, slug) unique — uncategorized slugs unique via
// a partial index. (Postgres treats NULLs as distinct in normal
// unique indexes.)
export async function up(db: Kysely<any>): Promise<void> {
// ── 1. Add parent_id to categories ─────────────────────────────
await db.schema
.alterTable('categories')
.addColumn('parent_id', 'uuid', col =>
col.references('categories.id').onDelete('restrict')
)
.execute()
await db.schema
.createIndex('categories_parent_id_idx')
.ifNotExists()
.on('categories')
.column('parent_id')
.execute()
// ── 2. Drop the old global slug uniqueness on both tables BEFORE
// rewriting page slugs — different categories will end up
// with the same leaf (e.g. each one's "overview" page).
// Drop the constraint first; the implicit unique index goes with
// it. (Dropping the index directly fails because the constraint
// owns it.) Some Kysely-Postgres versions name the index instead;
// try both forms with IF EXISTS so we don't care which one exists.
await sql`ALTER TABLE categories DROP CONSTRAINT IF EXISTS categories_slug_key`.execute(db)
await db.schema.dropIndex('categories_slug_key').ifExists().execute()
await sql`ALTER TABLE pages DROP CONSTRAINT IF EXISTS pages_slug_key`.execute(db)
await db.schema.dropIndex('pages_slug_key').ifExists().execute()
// ── 3. Split page slugs to leaves. Every page whose slug starts
// with "{category.slug}/" gets its slug rewritten to whatever
// follows the prefix.
await sql`
UPDATE pages p
SET slug = substring(p.slug from length(c.slug) + 2)
FROM categories c
WHERE p.category_id = c.id
AND p.slug LIKE c.slug || '/%'
`.execute(db)
// ── 4. Re-add scoped uniqueness on both tables (partial indexes so
// NULL parent / NULL category still enforce uniqueness across
// the top-level set).
await sql`
CREATE UNIQUE INDEX IF NOT EXISTS categories_parent_slug_uidx
ON categories (parent_id, slug)
WHERE parent_id IS NOT NULL
`.execute(db)
await sql`
CREATE UNIQUE INDEX IF NOT EXISTS categories_root_slug_uidx
ON categories (slug)
WHERE parent_id IS NULL
`.execute(db)
await sql`
CREATE UNIQUE INDEX IF NOT EXISTS pages_category_slug_uidx
ON pages (category_id, slug)
WHERE category_id IS NOT NULL
`.execute(db)
await sql`
CREATE UNIQUE INDEX IF NOT EXISTS pages_root_slug_uidx
ON pages (slug)
WHERE category_id IS NULL
`.execute(db)
}
export async function down(db: Kysely<any>): Promise<void> {
// Re-prefix every page's slug with its category slug so the old
// global-slug-as-path model still resolves URLs.
await sql`
UPDATE pages p
SET slug = c.slug || '/' || p.slug
FROM categories c
WHERE p.category_id = c.id
AND position('/' in p.slug) = 0
AND p.slug <> c.slug
`.execute(db)
await sql`DROP INDEX IF EXISTS pages_category_slug_uidx`.execute(db)
await sql`DROP INDEX IF EXISTS pages_root_slug_uidx`.execute(db)
await sql`DROP INDEX IF EXISTS categories_parent_slug_uidx`.execute(db)
await sql`DROP INDEX IF EXISTS categories_root_slug_uidx`.execute(db)
await sql`ALTER TABLE pages ADD CONSTRAINT pages_slug_key UNIQUE (slug)`.execute(db)
await sql`ALTER TABLE categories ADD CONSTRAINT categories_slug_key UNIQUE (slug)`.execute(db)
await db.schema.dropIndex('categories_parent_id_idx').ifExists().execute()
await db.schema.alterTable('categories').dropColumn('parent_id').execute()
}

View file

@ -14,10 +14,14 @@ export default defineEventHandler(async (event) => {
try {
const result = await deleteCmsCategory(id)
if ('error' in result) {
const message = result.error === 'child_categories_present'
? 'This category still has child categories. Move or delete them first.'
: 'This category still has pages. Move or delete them first.'
throw createError({
statusCode: 409,
statusMessage: 'This category still has pages. Move or delete them first.',
statusMessage: message,
data: {
error: result.error,
attached_page_count: result.attached_page_count,
sample_slugs: result.sample_slugs
}

View file

@ -1,11 +1,13 @@
// Admin: fetch a single category with translations and its ordered
// member pages (each annotated with the English title for the admin
// reorder list).
// Admin: fetch a single category with translations, its computed URL
// path, parent/children, and its ordered member pages (each annotated
// with the English title for the admin reorder list).
import { defineEventHandler, getRouterParam, createError } from 'h3'
import { requirePermission } from '../../../utils/rbac'
import { db } from '../../../utils/database'
import { getCategory, getCategoryPages } from '../../../database/categories'
import { getCategoryPages } from '../../../database/categories'
import { getCmsCategory } from '../../../services/cmsCategories'
import { loadCategoryTree, categoryUrlPath, pageUrlPath } from '../../../database/categoryTree'
import { sql } from 'kysely'
export default defineEventHandler(async (event) => {
@ -13,12 +15,20 @@ export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
if (!id) throw createError({ statusCode: 400, statusMessage: 'id is required' })
const record = await getCategory(id)
const record = await getCmsCategory(id)
if (!record) throw createError({ statusCode: 404, statusMessage: 'Category not found' })
const tree = await loadCategoryTree()
const childCategories = (tree.childrenByParent.get(id) ?? []).map(c => ({
id: c.id,
slug: c.slug,
menu_order: c.menu_order,
url: categoryUrlPath(tree, c.id)
}))
const pages = await getCategoryPages(id)
if (pages.length === 0) {
return { ...record, pages: [] }
return { ...record, child_categories: childCategories, pages: [] }
}
const pageIds = pages.map(p => p.id)
@ -45,9 +55,11 @@ export default defineEventHandler(async (event) => {
return {
...record,
child_categories: childCategories,
pages: pages.map(p => ({
id: p.id,
slug: p.slug,
url: pageUrlPath(tree, p),
menu_order: p.menu_order,
title_en: byId.get(p.id)?.title_en ?? null,
published_count: byId.get(p.id)?.published_count ?? 0,

View file

@ -12,6 +12,7 @@ import { ENABLED_LANGUAGE_CODES } from '../../../../config/languages'
interface Body {
slug?: string
parent_id?: string | null
menu_order?: number
translations?: Record<string, { name?: string }> | Array<{ locale: string; name: string }>
}
@ -45,6 +46,7 @@ export default defineEventHandler(async (event) => {
const result = await updateCmsCategory({
id,
slug: body?.slug != null ? String(body.slug) : undefined,
parent_id: body?.parent_id === undefined ? undefined : (body.parent_id ? String(body.parent_id) : null),
menu_order: body?.menu_order !== undefined ? (Number(body.menu_order) || 0) : undefined,
translations
})
@ -56,7 +58,7 @@ export default defineEventHandler(async (event) => {
userId: authUser.userId,
userAgent: getHeader(event, 'user-agent') || undefined,
metadata: {
changes: (['slug', 'menu_order', 'translations'] as const).filter(k => body?.[k] !== undefined),
changes: (['slug', 'parent_id', 'menu_order', 'translations'] as const).filter(k => body?.[k] !== undefined),
slugsPurged: result.slugsToPurge.length,
source: 'admin-ui'
}

View file

@ -7,6 +7,7 @@
import { defineEventHandler, getRouterParam, readBody, createError } from 'h3'
import { requirePermission } from '../../../../utils/rbac'
import { reorderCategoryPages, getCategoryPages, purgeSlugs } from '../../../../database/categories'
import { loadCategoryTree, pageUrlPath } from '../../../../database/categoryTree'
import { logUpdate } from '../../../../utils/activity-logger'
export default defineEventHandler(async (event) => {
@ -37,7 +38,8 @@ export default defineEventHandler(async (event) => {
}
const pages = await getCategoryPages(id)
await purgeSlugs(pages.map(p => p.slug))
const tree = await loadCategoryTree()
await purgeSlugs(pages.map(p => pageUrlPath(tree, p)))
logUpdate('categories', id, event, { event: 'reorder', pageCount: pageIds.length })
return { ok: true, pageIds }
})

View file

@ -1,13 +1,14 @@
// Admin: list every category with its per-locale names and the number
// of pages currently attached. Powers the category dropdown on the
// page editor and the categories admin list.
// of pages currently attached, plus the computed URL path for each
// (so the admin UI can render a tree with breadcrumbs/parent labels
// without re-walking the chain client-side).
import { defineEventHandler } from 'h3'
import { requirePermission } from '../../../utils/rbac'
import { listCategoriesWithTranslations } from '../../../database/categories'
import { listCmsCategories } from '../../../services/cmsCategories'
export default defineEventHandler(async (event) => {
await requirePermission(event, 'pages.view')
const rows = await listCategoriesWithTranslations()
const rows = await listCmsCategories()
return { rows }
})

View file

@ -10,6 +10,7 @@ import { logEvent } from '../../../utils/activity-logger'
interface Body {
slug?: string
parent_id?: string | null
menu_order?: number
translations?: Record<string, { name?: string }> | Array<{ locale: string; name: string }>
}
@ -42,6 +43,7 @@ export default defineEventHandler(async (event) => {
try {
const category = await createCmsCategory({
slug: body?.slug ?? '',
parent_id: body?.parent_id ? String(body.parent_id) : null,
menu_order: Number.isFinite(body?.menu_order) ? Number(body.menu_order) : 0,
translations
})
@ -51,7 +53,12 @@ export default defineEventHandler(async (event) => {
recordId: category.id,
userId: authUser.userId,
userAgent: getHeader(event, 'user-agent') || undefined,
metadata: { slug: category.slug, translations: translations.length, source: 'admin-ui' }
metadata: {
slug: category.slug,
parent_id: category.parent_id,
translations: translations.length,
source: 'admin-ui'
}
})
return category
} catch (e: any) {

View file

@ -14,16 +14,16 @@ export default defineEventHandler(async (event) => {
if (!id) throw createError({ statusCode: 400, statusMessage: 'id is required' })
try {
const { slug, categoryId } = await deleteCmsPage(id)
const { slug, url, categoryId } = await deleteCmsPage(id)
logEvent({
eventType: 'DELETE',
tableName: 'pages',
recordId: id,
userId: authUser.userId,
userAgent: getHeader(event, 'user-agent') || undefined,
metadata: { slug, source: 'admin-ui' }
metadata: { slug, url, source: 'admin-ui' }
})
await applyPageInvalidations([slug], categoryId ? [categoryId] : [])
await applyPageInvalidations([url], categoryId ? [categoryId] : [])
return { ok: true }
} catch (e: any) {
if (e?.statusCode) {

View file

@ -1,4 +1,5 @@
// Admin: fetch a single page with all its translations (any status).
// Admin: fetch a single page with all its translations (any status),
// plus the computed full public URL.
import { defineEventHandler, getRouterParam, createError } from 'h3'
import { requirePermission } from '../../../utils/rbac'
@ -12,8 +13,10 @@ export default defineEventHandler(async (event) => {
const found = await getCmsPage({ id })
if (!found) throw createError({ statusCode: 404, statusMessage: 'Page not found' })
// Match the legacy admin contract: `{page, translations}`. The
// service additionally returns `category_slug`, but the Vue admin
// doesn't read that field — drop it from the response.
return { page: found.page, translations: found.translations }
return {
page: found.page,
translations: found.translations,
url: found.url,
category_path: found.category_path
}
})

View file

@ -40,7 +40,7 @@ export default defineEventHandler(async (event) => {
source: 'admin-ui'
}
})
await applyTranslationInvalidations(result.pageSlug, result.categoryId, locale)
await applyTranslationInvalidations(result.pageUrl, result.categoryId, locale)
return { ok: true }
} catch (e: any) {
if (e?.statusCode) {

View file

@ -64,7 +64,7 @@ export default defineEventHandler(async (event) => {
metadata: { page_id: id, locale, status: result.translation.status, source: 'admin-ui' }
})
await applyTranslationInvalidations(result.pageSlug, result.categoryId, locale)
await applyTranslationInvalidations(result.pageUrl, result.categoryId, locale)
return {
...result.translation,
sanitization_warnings: result.sanitizationWarnings

View file

@ -1,10 +1,11 @@
// Admin: list all CMS pages with counts of published/draft translations
// plus category slug + English name for the category column.
// Admin: list all CMS pages with counts of published/draft translations,
// the page's category (if any), and the computed full URL path.
import { defineEventHandler } from 'h3'
import { requirePermission } from '../../../utils/rbac'
import { db } from '../../../utils/database'
import { sql } from 'kysely'
import { loadCategoryTree, pageUrlPath } from '../../../database/categoryTree'
export default defineEventHandler(async (event) => {
await requirePermission(event, 'pages.view')
@ -43,5 +44,11 @@ export default defineEventHandler(async (event) => {
.orderBy('pages.slug', 'asc')
.execute()
return { rows }
const tree = await loadCategoryTree()
const decorated = rows.map(r => ({
...r,
url: pageUrlPath(tree, { slug: r.slug, category_id: r.category_id })
}))
return { rows: decorated }
})

View file

@ -1,132 +1,329 @@
// Public CMS page read endpoint. Mirrors the plan:
// - picks the translation for the requested locale
// - falls back to English if that locale isn't published
// - 404s if neither are published
// Public CMS page read endpoint. Resolves a "/"-separated URL path
// against the category tree:
//
// Returns the translation (title, excerpt, featured_image, meta_*,
// status) plus pre-rendered HTML body and the set of sibling pages
// that live in the same category (used for the sidebar nav).
// /resources → top-level category "resources"
// /resources/adoption → child category "adoption" under "resources"
// /resources/overview → page "overview" inside "resources"
// /privacy → uncategorized page "privacy"
//
// When the slug matches a category's slug exactly (e.g. `/about`),
// the endpoint transparently resolves to the category's first page
// (lowest menu_order with a published translation).
// Walks every URL segment against the categories table from the root
// down. Whatever doesn't match a category is treated as a page leaf
// inside the deepest matched category. Bare-category URLs render the
// category's default page (lowest menu_order with a published
// translation).
//
// The response includes a sidebar of sibling pages, sibling
// categories below the active one (so /resources can render a card
// grid of "Adoption / Training / Mobilization"), and the menu parent
// for breadcrumbs.
// defineCachedEventHandler + getCookie are Nitro globals (auto-imported);
// no h3 import needed for them.
import { getRouterParam, getQuery, createError } from 'h3'
import { getPageBySlug } from '../../database/pages'
import {
getCategoryBySlug,
findPageInCategory,
getLocalizedTranslation
} from '../../database/pages'
import {
getCategoryDefaultPage,
getCategoryName,
getCategoryPageTranslations
getCategoryPageTranslations,
categoryIdsWithPublishedPages
} from '../../database/categories'
import { db } from '../../utils/database'
import { renderTiptap } from '../../utils/renderTiptap'
import { ENABLED_LANGUAGE_CODES } from '../../../config/languages'
import {
loadCategoryTree,
walkCategorySegments,
pageUrlPath,
categoryUrlPath,
descendantCategoryIds
} from '../../database/categoryTree'
import type { LocalizedPage } from '../../database/pages'
import type { TreeCategory, CategoryTree } from '../../database/categoryTree'
const ENABLED_LOCALES = new Set(ENABLED_LANGUAGE_CODES)
interface ChildCategorySummary {
slug: string
url: string
title: string
excerpt: string | null
featured_image: string | null
menu_order: number
}
interface NavPageNode {
kind: 'page'
url: string
title: string
menu_order: number
}
interface NavCategoryNode {
kind: 'category'
url: string
title: string
menu_order: number
items: Array<NavPageNode | NavCategoryNode>
}
type NavNode = NavPageNode | NavCategoryNode
// Builds the recursive sidebar tree rooted at `categoryId`. Each
// category becomes a heading whose `items` are its direct pages plus
// each child category's own subtree, ordered by menu_order then slug.
// Returns null when the category has no pages anywhere in its subtree —
// an empty heading isn't worth showing in the menu.
async function buildNavTree(
categoryId: string,
locale: string,
fallbackLocale: string,
tree: CategoryTree,
visited: Set<string> = new Set()
): Promise<NavCategoryNode | null> {
// Mark before any await so concurrent sibling recursions can't
// re-enter this node, and a back-edge in malformed data (a child
// re-parented onto an ancestor) is dropped instead of recursing
// forever.
visited.add(categoryId)
const node = tree.byId.get(categoryId)
const [name, pageRows] = await Promise.all([
getCategoryName(categoryId, locale, fallbackLocale),
getCategoryPageTranslations(categoryId, locale, fallbackLocale)
])
const url = categoryUrlPath(tree, categoryId)
const pageItems: NavPageNode[] = pageRows.map(({ page, translation }) => ({
kind: 'page' as const,
url: pageUrlPath(tree, page),
title: translation.title,
menu_order: page.menu_order
}))
const childCats = (tree.childrenByParent.get(categoryId) ?? []).filter(c => !visited.has(c.id))
const childSections = (await Promise.all(
childCats.map(c => buildNavTree(c.id, locale, fallbackLocale, tree, visited))
)).filter((s): s is NavCategoryNode => s !== null)
// Drop a category with no published pages of its own and no
// page-bearing descendants — it would render as an empty heading.
if (pageItems.length === 0 && childSections.length === 0) return null
// Subcategories first, then this category's own pages — each group
// ordered by menu_order (ties broken by title).
const byOrder = (a: NavNode, b: NavNode) =>
a.menu_order - b.menu_order || a.title.localeCompare(b.title)
const items: NavNode[] = [
...childSections.sort(byOrder),
...pageItems.sort(byOrder)
]
return {
kind: 'category' as const,
url,
title: name ?? node?.slug ?? '',
menu_order: node?.menu_order ?? 0,
items
}
}
async function summarizeChildCategories(
parents: TreeCategory[],
locale: string,
fallbackLocale: string,
basePath: string
): Promise<ChildCategorySummary[]> {
const out: ChildCategorySummary[] = []
for (const child of parents) {
const [name, defaultPage] = await Promise.all([
getCategoryName(child.id, locale, fallbackLocale),
getCategoryDefaultPage(child.id, locale, fallbackLocale)
])
if (!name) continue
out.push({
slug: child.slug,
url: basePath ? `${basePath}/${child.slug}` : child.slug,
title: name,
excerpt: defaultPage?.translation.excerpt ?? null,
featured_image: defaultPage?.translation.featured_image ?? null,
menu_order: child.menu_order
})
}
return out
}
export default defineCachedEventHandler(async (event) => {
const raw = getRouterParam(event, 'slug')
const slugParam = Array.isArray(raw) ? raw.join('/') : (raw ?? '')
const slug = slugParam.replace(/^\/+|\/+$/g, '')
if (!slug) {
const fullPath = slugParam.replace(/^\/+|\/+$/g, '')
if (!fullPath) {
throw createError({ statusCode: 400, statusMessage: 'Missing slug' })
}
const query = getQuery(event)
const requested = typeof query.locale === 'string' ? query.locale : 'en'
const locale = ENABLED_LOCALES.has(requested) ? requested : 'en'
const fallbackLocale = 'en'
// Try the slug as a page first. If no page row matches, see if the
// slug identifies a category, and render its default page.
let result = await getPageBySlug(slug, locale, { fallback: 'en' })
let bareCategorySlug: string | null = null
const segments = fullPath.split('/').filter(Boolean)
const tree = await loadCategoryTree()
const { category: deepestCategory, remaining } = walkCategorySegments(tree, segments)
if (!result) {
const category = await getCategoryBySlug(slug)
if (category) {
const defaultPage = await getCategoryDefaultPage(category.id, locale, 'en')
if (defaultPage) {
result = {
page: defaultPage.page,
translation: defaultPage.translation,
resolvedLocale: defaultPage.translation.locale,
requestedLocale: locale
}
bareCategorySlug = category.slug
// Resolve to either a page (in deepestCategory) or a category landing.
let result: LocalizedPage | null = null
let resolvedCategory: TreeCategory | null = deepestCategory
let bareCategory: TreeCategory | null = null
if (remaining.length === 0 && deepestCategory) {
// Bare category URL like /resources or /resources/adoption.
const def = await getCategoryDefaultPage(deepestCategory.id, locale, fallbackLocale)
if (def) {
result = {
page: def.page,
translation: def.translation,
resolvedLocale: def.translation.locale,
requestedLocale: locale
}
bareCategory = deepestCategory
resolvedCategory = deepestCategory
} else {
// Empty category — still render a landing if it has child
// categories. Use a synthetic empty translation.
const childCats = tree.childrenByParent.get(deepestCategory.id) ?? []
if (childCats.length === 0) {
throw createError({ statusCode: 404, statusMessage: 'Page not found' })
}
bareCategory = deepestCategory
resolvedCategory = deepestCategory
}
} else if (remaining.length === 1) {
// Final segment is a page leaf inside `deepestCategory` (or
// uncategorized when no category matched at all).
const parentCategoryId = deepestCategory?.id ?? null
const page = await findPageInCategory(parentCategoryId, remaining[0]!)
if (page) {
const localized = await getLocalizedTranslation(page.id, locale, { fallback: fallbackLocale })
if (localized) {
result = localized
resolvedCategory = deepestCategory
}
}
}
// Anything deeper than one trailing leaf can't resolve — slugs are
// single segments now.
if (!result) {
if (!result && !bareCategory) {
throw createError({ statusCode: 404, statusMessage: 'Page not found' })
}
const { page, translation, resolvedLocale, requestedLocale } = result
const bodyHtml = renderTiptap(translation.body_json)
const renderedLocale = result?.resolvedLocale ?? fallbackLocale
const requestedLocale = locale
const bodyHtml = result ? renderTiptap(result.translation.body_json) : ''
let menuParent: { slug: string; title: string } | null = null
let categorySlug: string | null = bareCategorySlug
// Build the navigation sidebar: siblings within the resolved category.
let menuParent: { slug: string; url: string; title: string } | null = null
let categorySlug: string | null = null
let categoryUrl: string | null = null
let children: Array<{
slug: string
url: string
title: string
excerpt: string | null
featured_image: string | null
menu_order: number
}> = []
if (page.category_id) {
const [categoryRow, categoryName, siblingRows] = await Promise.all([
db
.selectFrom('categories')
.select('slug')
.where('id', '=', page.category_id)
.executeTakeFirst(),
getCategoryName(page.category_id, resolvedLocale, 'en'),
getCategoryPageTranslations(page.category_id, resolvedLocale, 'en')
const navCategoryId = result?.page.category_id ?? bareCategory?.id ?? null
let navTree: NavCategoryNode | null = null
if (navCategoryId) {
const [categoryName, siblingRows] = await Promise.all([
getCategoryName(navCategoryId, renderedLocale, fallbackLocale),
getCategoryPageTranslations(navCategoryId, renderedLocale, fallbackLocale)
])
if (categoryRow) categorySlug = categoryRow.slug
if (categorySlug && categoryName) {
menuParent = { slug: categorySlug, title: categoryName }
const navCategory = tree.byId.get(navCategoryId) ?? null
categorySlug = navCategory?.slug ?? null
categoryUrl = categoryUrlPath(tree, navCategoryId) || null
if (categoryUrl && categoryName) {
menuParent = { slug: categorySlug ?? '', url: categoryUrl, title: categoryName }
}
children = siblingRows.map(({ page: c, translation: t }) => ({
slug: c.slug,
url: pageUrlPath(tree, c),
title: t.title,
excerpt: t.excerpt,
featured_image: t.featured_image,
menu_order: c.menu_order
}))
// Build the recursive sidebar rooted at the top-level ancestor
// of the current category. Every page in the subtree shares the
// same sidebar — visiting /resources/adoption/foo gives the same
// nav as /resources/training/bar.
let navRootId = navCategoryId
while (true) {
const ancestor = tree.byId.get(navRootId)
if (!ancestor?.parent_id) break
navRootId = ancestor.parent_id
}
navTree = await buildNavTree(navRootId, renderedLocale, fallbackLocale, tree)
}
// Child categories below this one — surfaced so archive landing
// pages can render a card grid of subsections.
let childCategories: ChildCategorySummary[] = []
// The index/grid layout belongs to the bare category URL only
// (`/resources`), not to a page's own URL (`/resources/overview`) —
// even when that page is the category default (menu_order 0). This
// keeps every page reachable as an article when its card is clicked.
const isCategoryLanding = Boolean(bareCategory)
if (isCategoryLanding && resolvedCategory) {
const cats = tree.childrenByParent.get(resolvedCategory.id) ?? []
if (cats.length > 0) {
// Hide subcategories with no published pages anywhere in their
// subtree — matches the menu so empty categories aren't surfaced
// as dead-end cards.
const withPages = await categoryIdsWithPublishedPages(renderedLocale, fallbackLocale)
const nonEmpty = cats.filter(c =>
descendantCategoryIds(tree, c.id).some(id => withPages.has(id))
)
if (nonEmpty.length > 0) {
const basePath = categoryUrlPath(tree, resolvedCategory.id)
childCategories = await summarizeChildCategories(nonEmpty, renderedLocale, fallbackLocale, basePath)
}
}
}
const url = result ? pageUrlPath(tree, result.page) : (bareCategory ? categoryUrlPath(tree, bareCategory.id) : fullPath)
return {
slug: page.slug,
category_id: page.category_id,
url,
slug: result?.page.slug ?? bareCategory?.slug ?? '',
category_id: result?.page.category_id ?? bareCategory?.id ?? null,
category_slug: categorySlug,
menu_order: page.menu_order,
theme: page.theme,
custom_css: page.custom_css,
category_url: categoryUrl,
menu_order: result?.page.menu_order ?? 0,
theme: result?.page.theme ?? 'default',
custom_css: result?.page.custom_css ?? null,
requested_locale: requestedLocale,
resolved_locale: resolvedLocale,
title: translation.title,
excerpt: translation.excerpt,
featured_image: translation.featured_image,
meta_title: translation.meta_title,
meta_description: translation.meta_description,
og_image: translation.og_image,
resolved_locale: renderedLocale,
title: result?.translation.title ?? '',
excerpt: result?.translation.excerpt ?? null,
featured_image: result?.translation.featured_image ?? null,
meta_title: result?.translation.meta_title ?? null,
meta_description: result?.translation.meta_description ?? null,
og_image: result?.translation.og_image ?? null,
body_html: bodyHtml,
body_is_empty: bodyHtml.trim() === '',
is_category_landing: isCategoryLanding,
menu_parent: menuParent,
children
children,
child_categories: childCategories,
nav_tree: navTree
}
}, {
// Keyed by (locale, slug). Purged from mutation endpoints via
// purgeCmsPage() in server/utils/cmsCache.ts. The slug is hex-encoded
// because Nitro's internal escapeKey() strips non-word chars (`:`,
// `/`, `-`), which would otherwise collide nested slugs like
// `about/team` with `about-team`.
// Keyed by (locale, full URL path). The slug is hex-encoded because
// Nitro's escapeKey() strips non-word characters (`:`, `/`, `-`),
// which would otherwise collide nested paths like `about/team` with
// `about-team`.
name: 'cms',
getKey: (event) => {
const raw = getRouterParam(event, 'slug')
@ -139,6 +336,5 @@ export default defineCachedEventHandler(async (event) => {
},
maxAge: 60 * 60,
swr: true,
// Admins editing content must never see — or poison — cached output.
shouldBypassCache: event => !!getCookie(event, 'auth-token')
})

View file

@ -1,8 +1,7 @@
// Query helpers for the `categories` + `category_translations` tables.
// Categories are a first-class replacement for the old
// `pages.parent_slug` hierarchy: purely organizational groups that
// expose a shared URL prefix, a translated sidebar heading, and a
// drag-and-drop ordering for their member pages.
// Categories form a tree (`parent_id`) and provide the URL prefix for
// every page beneath them. Pages store only their own leaf slug; the
// full URL is computed at request time by walking the chain.
import { db } from '../utils/database'
import type {
@ -14,6 +13,12 @@ import type {
import type { Selectable } from 'kysely'
import { sql } from 'kysely'
import { purgeCmsPage } from '../utils/cmsCache'
import {
loadCategoryTree,
categoryUrlPath,
pageUrlPath,
descendantCategoryIds
} from './categoryTree'
export type Category = Selectable<CategoriesTable>
export type CategoryTranslation = Selectable<CategoryTranslationsTable>
@ -91,15 +96,6 @@ export async function getCategory(id: string): Promise<CategoryWithTranslations
return { category, translations }
}
export async function getCategoryBySlug(slug: string): Promise<Category | null> {
const row = await db
.selectFrom('categories')
.selectAll()
.where('slug', '=', slug)
.executeTakeFirst()
return row ?? null
}
export async function getCategoryName(
categoryId: string,
locale: string,
@ -126,8 +122,7 @@ export async function getCategoryName(
}
// Sidebar siblings for a category: every page in the category,
// ordered. Matches the old parent_slug-based sibling lookup but keyed
// on category_id.
// ordered by menu_order then leaf slug.
export async function getCategoryPages(categoryId: string): Promise<Page[]> {
return db
.selectFrom('pages')
@ -177,10 +172,34 @@ export async function getCategoryPageTranslations(
.filter(entry => Boolean(entry.translation))
}
// Public URL resolution for `/about` → first page in About. Picks
// whichever page has the lowest menu_order (ties broken by slug) and
// has at least one published translation, so bare category slugs never
// resolve to something the visitor can't read.
// Public URL resolution for a bare category path (e.g. `/resources`).
// Picks whichever page has the lowest menu_order (ties broken by leaf
// slug) and has at least one published translation, so bare category
// slugs never resolve to something the visitor can't read.
// Set of category ids that directly hold at least one page with a
// published translation in `locale` (or the fallback). Callers combine
// it with `descendantCategoryIds` to decide whether a category subtree
// has any visible pages — e.g. to hide empty categories from the menu
// and the landing-page grid.
export async function categoryIdsWithPublishedPages(
locale: string,
fallbackLocale = 'en'
): Promise<Set<string>> {
const locales = locale === fallbackLocale ? [locale] : [locale, fallbackLocale]
const rows = await db
.selectFrom('pages')
.innerJoin('page_translations', 'page_translations.page_id', 'pages.id')
.select('pages.category_id')
.where('pages.category_id', 'is not', null)
.where('page_translations.status', '=', 'published')
.where('page_translations.locale', 'in', locales)
.distinct()
.execute()
const set = new Set<string>()
for (const r of rows) if (r.category_id) set.add(r.category_id)
return set
}
export async function getCategoryDefaultPage(
categoryId: string,
locale: string,
@ -192,6 +211,7 @@ export async function getCategoryDefaultPage(
export interface CreateCategoryInput {
slug: string
parent_id?: string | null
menu_order?: number
translations: Array<{ locale: string; name: string }>
}
@ -202,6 +222,7 @@ export async function createCategory(input: CreateCategoryInput): Promise<Catego
.insertInto('categories')
.values({
slug: input.slug,
parent_id: input.parent_id ?? null,
menu_order: input.menu_order ?? 0
})
.returningAll()
@ -225,12 +246,15 @@ export async function createCategory(input: CreateCategoryInput): Promise<Catego
export interface UpdateCategoryInput {
slug?: string
parent_id?: string | null
menu_order?: number
translations?: Array<{ locale: string; name: string }>
}
// Returns the set of slugs whose cached rendering needs busting (old
// and new URLs for every renamed member page).
// A slug or parent_id change rewrites the URL of every page in the
// category subtree. We compute the *old* URL for each affected page
// before committing, so cache-purge can invalidate the keys that
// existed prior to the change.
export async function updateCategory(
id: string,
input: UpdateCategoryInput
@ -246,36 +270,44 @@ export async function updateCategory(
}
const slugsToPurge: string[] = []
const structureChanged =
(input.slug !== undefined && input.slug !== existing.slug) ||
(input.parent_id !== undefined && (input.parent_id ?? null) !== existing.parent_id)
// Slug rename cascades to every member page so their stored slugs
// stay `{category.slug}/...` — the app relies on that prefix for
// URL resolution.
if (input.slug && input.slug !== existing.slug) {
const newSlug = input.slug
const memberPages = await trx
// Snapshot the current full URL for every page beneath this
// category before we change anything. After commit the same pages
// will live at new URLs; the old keys still need to be purged.
if (structureChanged) {
const treeBefore = await loadCategoryTree(trx)
const subtreeIds = descendantCategoryIds(treeBefore, id)
const pages = await trx
.selectFrom('pages')
.select(['id', 'slug'])
.where('category_id', '=', id)
.select(['slug', 'category_id'])
.where('category_id', 'in', subtreeIds)
.execute()
for (const page of memberPages) {
const suffix = page.slug.startsWith(`${existing.slug}/`)
? page.slug.slice(existing.slug.length + 1)
: page.slug
const updatedSlug = `${newSlug}/${suffix}`
slugsToPurge.push(page.slug)
slugsToPurge.push(updatedSlug)
await trx
.updateTable('pages')
.set({ slug: updatedSlug, updated: sql`now()` })
.where('id', '=', page.id)
.execute()
for (const p of pages) {
slugsToPurge.push(pageUrlPath(treeBefore, p))
}
// Also bust the bare-category landing URL for every node in the
// subtree so /resources keeps working after a rename.
for (const cid of subtreeIds) {
const path = categoryUrlPath(treeBefore, cid)
if (path) slugsToPurge.push(path)
}
}
if (input.slug !== undefined && input.slug !== existing.slug) {
await trx
.updateTable('categories')
.set({ slug: newSlug, updated: sql`now()` })
.set({ slug: input.slug, updated: sql`now()` })
.where('id', '=', id)
.execute()
}
if (input.parent_id !== undefined && (input.parent_id ?? null) !== existing.parent_id) {
await trx
.updateTable('categories')
.set({ parent_id: input.parent_id ?? null, updated: sql`now()` })
.where('id', '=', id)
.execute()
}
@ -329,20 +361,41 @@ export async function updateCategory(
}
// Every member page's cached response embeds the category's name
// as its `menu_parent.title`. When the category name changes in
// any locale, purge every member so readers see the new label.
// as its `menu_parent.title`. When the name changes in any
// locale, purge every member so readers see the new label.
// Use the post-change tree since the slug/parent (if changed)
// already settled above.
if (translationsChanged) {
const memberSlugs = await trx
const treeAfter = await loadCategoryTree(trx)
const subtreeIds = descendantCategoryIds(treeAfter, id)
const pages = await trx
.selectFrom('pages')
.select('slug')
.where('category_id', '=', id)
.select(['slug', 'category_id'])
.where('category_id', 'in', subtreeIds)
.execute()
for (const { slug } of memberSlugs) {
slugsToPurge.push(slug)
for (const p of pages) {
slugsToPurge.push(pageUrlPath(treeAfter, p))
}
}
}
// The parent category's landing page renders a grid of its child
// categories (each child's url, title, and order). Any edit this
// grid reflects — slug, parent, menu_order, or name — leaves the
// old and new parents' landing pages stale, so purge them too. The
// parents' own paths are unaffected by this category's change, so a
// single post-change tree resolves both correctly.
const parentIdsToPurge = new Set<string>()
if (existing.parent_id) parentIdsToPurge.add(existing.parent_id)
if (input.parent_id) parentIdsToPurge.add(input.parent_id)
if (parentIdsToPurge.size > 0) {
const parentTree = await loadCategoryTree(trx)
for (const pid of parentIdsToPurge) {
const path = categoryUrlPath(parentTree, pid)
if (path) slugsToPurge.push(path)
}
}
const fresh = await trx
.selectFrom('categories')
.selectAll()
@ -362,6 +415,14 @@ export async function deleteCategory(id: string): Promise<void> {
if (pageCount.count > 0) {
throw Object.assign(new Error('Category still has pages'), { statusCode: 409 })
}
const childCount = await db
.selectFrom('categories')
.select(sql<number>`COUNT(*)::int`.as('count'))
.where('parent_id', '=', id)
.executeTakeFirstOrThrow()
if (childCount.count > 0) {
throw Object.assign(new Error('Category still has child categories'), { statusCode: 409 })
}
await db.deleteFrom('categories').where('id', '=', id).execute()
}

View file

@ -0,0 +1,155 @@
// Category tree helpers. The CMS stores only the leaf segment of a
// page's URL on `pages.slug`; the full URL is derived by walking the
// category's ancestor chain at request time. These helpers keep that
// derivation in one place so the lookup endpoint, link builders, cache
// purgers, and admin previews all agree on what `/resources/adoption/foo`
// actually resolves to.
import type { Kysely } from 'kysely'
import { db } from '../utils/database'
import type { Database } from './schema'
export interface TreeCategory {
id: string
parent_id: string | null
slug: string
menu_order: number
}
export interface CategoryTree {
byId: Map<string, TreeCategory>
childrenByParent: Map<string | null, TreeCategory[]>
}
// Load every category row into an in-memory tree. Categories are
// shallow (dozens of rows), so a single SELECT-all + Map build is
// cheaper than recursive CTE walks per page request.
export async function loadCategoryTree(
executor: Kysely<Database> = db
): Promise<CategoryTree> {
const rows = await executor
.selectFrom('categories')
.select(['id', 'parent_id', 'slug', 'menu_order'])
.execute()
const byId = new Map<string, TreeCategory>()
const childrenByParent = new Map<string | null, TreeCategory[]>()
for (const r of rows) {
byId.set(r.id, r)
const key = r.parent_id ?? null
const list = childrenByParent.get(key) ?? []
list.push(r)
childrenByParent.set(key, list)
}
// Stable sibling ordering so callers don't have to re-sort.
for (const list of childrenByParent.values()) {
list.sort((a, b) =>
a.menu_order - b.menu_order || a.slug.localeCompare(b.slug)
)
}
return { byId, childrenByParent }
}
// Returns the chain of categories from root → leaf for the given id,
// or [] if the id isn't in the tree. Empty when called with null.
export function categoryChain(
tree: CategoryTree,
categoryId: string | null
): TreeCategory[] {
if (!categoryId) return []
const chain: TreeCategory[] = []
let id: string | null = categoryId
// Hard cap protects against accidental cycles in malformed data.
for (let i = 0; i < 32 && id; i++) {
const node = tree.byId.get(id)
if (!node) break
chain.unshift(node)
id = node.parent_id
}
return chain
}
// Slug path for a category, e.g. "resources/adoption". Empty string
// when the category isn't in the tree.
export function categoryUrlPath(
tree: CategoryTree,
categoryId: string | null
): string {
return categoryChain(tree, categoryId).map(c => c.slug).join('/')
}
// Full URL path for a page given its (leaf) slug + category. Examples:
// page leaf "overview", category "resources" → "resources/overview"
// page leaf "privacy", no category → "privacy"
export function pageUrlPath(
tree: CategoryTree,
page: { slug: string; category_id: string | null }
): string {
const prefix = categoryUrlPath(tree, page.category_id)
return prefix ? `${prefix}/${page.slug}` : page.slug
}
// Walk a "/"-separated path through the category tree as far as it
// matches. Returns the deepest category we could match, plus the
// segments that come after it (zero or one segment for a page leaf,
// or more if the path doesn't fully resolve).
export interface ResolvedSegments {
category: TreeCategory | null
remaining: string[]
}
export function walkCategorySegments(
tree: CategoryTree,
segments: string[]
): ResolvedSegments {
let parentId: string | null = null
let current: TreeCategory | null = null
let i = 0
for (; i < segments.length; i++) {
const seg = segments[i]!
const siblings: TreeCategory[] = tree.childrenByParent.get(parentId) ?? []
const match: TreeCategory | undefined = siblings.find((c: TreeCategory) => c.slug === seg)
if (!match) break
current = match
parentId = match.id
}
return { category: current, remaining: segments.slice(i) }
}
// Returns the descendant category id set (including the root id),
// e.g. for cache purges that need to enumerate every page beneath a
// category that just got renamed or moved.
export function descendantCategoryIds(
tree: CategoryTree,
rootId: string
): string[] {
const out: string[] = []
// `seen` guards against a back-edge in malformed data (a descendant
// re-parented onto an ancestor). Without it the stack would cycle
// forever and grow `out` unbounded.
const seen = new Set<string>()
const stack: string[] = [rootId]
while (stack.length) {
const id = stack.pop()!
if (seen.has(id)) continue
seen.add(id)
out.push(id)
const children = tree.childrenByParent.get(id) ?? []
for (const c of children) stack.push(c.id)
}
return out
}
// Catches a self-or-ancestor cycle when re-parenting a category. Pass
// the candidate parent id; returns true when assigning it would create
// a loop (parent is the category itself or one of its descendants).
export function wouldCreateCycle(
tree: CategoryTree,
categoryId: string,
newParentId: string | null
): boolean {
if (!newParentId) return false
if (newParentId === categoryId) return true
const descendants = new Set(descendantCategoryIds(tree, categoryId))
return descendants.has(newParentId)
}

View file

@ -1,9 +1,10 @@
// Query helpers for the `pages` + `page_translations` tables (migration
// 005). All functions use Kysely against the shared `db` instance.
// Query helpers for the `pages` + `page_translations` tables.
// `pages.slug` stores only the leaf segment of the URL; the full URL
// is derived by walking `pages.category_id` up the category tree.
//
// Lookup semantics (`getPageBySlug`) mirror the plan: prefer the
// requested locale's published translation, fall back to English if
// that locale is missing/draft, return null when neither is published.
// Translation lookup semantics: prefer the requested locale's
// published translation, fall back to English if that locale is
// missing/draft, return null when neither is published.
import { db } from '../utils/database'
import type { PagesTable, PageTranslationsTable } from './schema'
@ -39,8 +40,28 @@ export async function listPages(): Promise<Page[]> {
.execute()
}
export async function getPageBySlug(
slug: string,
// Find a page by its leaf slug + parent category. Pass `null` for
// `categoryId` to look up an uncategorized (top-level) page. Returns
// the page row regardless of translation state — translation lookup
// is split out into `getLocalizedTranslation` so callers can decide
// whether to surface unpublished pages (the public endpoint won't).
export async function findPageInCategory(
categoryId: string | null,
leafSlug: string
): Promise<Page | null> {
let q = db
.selectFrom('pages')
.selectAll()
.where('slug', '=', leafSlug)
q = categoryId === null
? q.where('category_id', 'is', null)
: q.where('category_id', '=', categoryId)
const row = await q.executeTakeFirst()
return row ?? null
}
export async function getLocalizedTranslation(
pageId: string,
locale: string,
options: { fallback?: string } = {}
): Promise<LocalizedPage | null> {
@ -49,7 +70,7 @@ export async function getPageBySlug(
const page = await db
.selectFrom('pages')
.selectAll()
.where('slug', '=', slug)
.where('id', '=', pageId)
.executeTakeFirst()
if (!page) return null
@ -65,7 +86,6 @@ export async function getPageBySlug(
return { page, translation, resolvedLocale: locale, requestedLocale: locale }
}
// Fall back to published English if the requested locale isn't published
if (locale !== fallbackLocale) {
const fallback = await db
.selectFrom('page_translations')
@ -103,72 +123,6 @@ export async function deletePage(id: string): Promise<void> {
await db.deleteFrom('pages').where('id', '=', id).execute()
}
// Moves a page into a new category (or detaches it by passing null)
// and rewrites its slug so the `{category.slug}/...` prefix stays
// consistent. Returns the set of slugs that need cache purging (old
// slug + new slug).
export async function setPageCategory(
pageId: string,
newCategoryId: string | null
): Promise<{ page: Page; slugsToPurge: string[] }> {
return db.transaction().execute(async trx => {
const page = await trx
.selectFrom('pages')
.selectAll()
.where('id', '=', pageId)
.executeTakeFirst()
if (!page) {
throw Object.assign(new Error('Page not found'), { statusCode: 404 })
}
const oldSlug = page.slug
const oldCategoryId = page.category_id
// Figure out the slug leaf (everything after the current category
// prefix) so we can re-prefix it under the new category.
let leaf = oldSlug
if (oldCategoryId) {
const oldCategory = await trx
.selectFrom('categories')
.select('slug')
.where('id', '=', oldCategoryId)
.executeTakeFirst()
if (oldCategory && oldSlug.startsWith(`${oldCategory.slug}/`)) {
leaf = oldSlug.slice(oldCategory.slug.length + 1)
}
}
let newSlug: string
if (newCategoryId) {
const newCategory = await trx
.selectFrom('categories')
.select('slug')
.where('id', '=', newCategoryId)
.executeTakeFirst()
if (!newCategory) {
throw Object.assign(new Error('Category not found'), { statusCode: 404 })
}
newSlug = `${newCategory.slug}/${leaf}`
} else {
newSlug = leaf
}
const updated = await trx
.updateTable('pages')
.set({
category_id: newCategoryId,
slug: newSlug,
updated: sql`now()`
})
.where('id', '=', pageId)
.returningAll()
.executeTakeFirstOrThrow()
const slugsToPurge = oldSlug === newSlug ? [oldSlug] : [oldSlug, newSlug]
return { page: updated, slugsToPurge }
})
}
export async function upsertTranslation(input: {
page_id: string
locale: string

View file

@ -58,6 +58,7 @@ export interface PagesTable {
export interface CategoriesTable {
id: Generated<string>
slug: string
parent_id: string | null
menu_order: Generated<number>
created: ColumnType<Date, Date | string | undefined, Date | string>
updated: ColumnType<Date, Date | string | undefined, Date | string>

View file

@ -167,6 +167,7 @@ export const listCategoriesInput = z.object({}).strict()
export const createCategoryInput = z
.object({
slug: slugString,
parent_id: idString.nullish(),
menu_order: z.number().int().min(0).optional(),
translations: categoryTranslationsInput
})
@ -176,6 +177,7 @@ export const updateCategoryInput = z
.object({
id: idString,
slug: slugString.optional(),
parent_id: idString.nullish(),
menu_order: z.number().int().min(0).optional(),
translations: categoryTranslationsInput.optional()
})

View file

@ -22,7 +22,7 @@ import {
export const listCategoriesTool = defineMcpTool({
name: 'list_categories',
description: 'List all CMS categories with their per-locale names, menu_order, and the number of pages currently attached.',
description: 'List all CMS categories with their per-locale names, menu_order, parent_id, computed URL path, and the number of pages currently attached.',
scope: 'pages.view',
input: listCategoriesInput,
async handler() {
@ -33,6 +33,8 @@ export const listCategoriesTool = defineMcpTool({
categories: rows.map(row => ({
id: row.id,
slug: row.slug,
parent_id: row.parent_id,
url: row.url,
menu_order: row.menu_order,
page_count: row.page_count,
translations: row.translations.map(t => ({
@ -47,17 +49,19 @@ export const listCategoriesTool = defineMcpTool({
export const createCategoryTool = defineMcpTool({
name: 'create_category',
description: 'Create a new CMS category. Slug must be lowercase letters/digits/dashes (no slashes). Requires at least an English (locale "en") translation entry.',
description: 'Create a new CMS category. Slug must be lowercase letters/digits/dashes (no slashes). Pass `parent_id` to nest under another category (omit/null for top level). Requires at least an English (locale "en") translation entry.',
scope: 'pages.write',
input: createCategoryInput,
async handler(input, ctx) {
const category = await createCmsCategory({
slug: input.slug,
parent_id: input.parent_id ?? null,
menu_order: input.menu_order,
translations: input.translations
})
await mcpLog('CREATE', 'categories', category.id, ctx, {
slug: input.slug,
parent_id: category.parent_id,
translation_count: input.translations.length
})
return {
@ -69,13 +73,14 @@ export const createCategoryTool = defineMcpTool({
export const updateCategoryTool = defineMcpTool({
name: 'update_category',
description: 'Update a CMS category — slug, menu_order, or per-locale translations. Slug renames cascade to every member page and bust the affected cache entries.',
description: 'Update a CMS category — slug, parent_id, menu_order, or per-locale translations. A slug or parent change cascades to every URL in the subtree and busts the affected cache entries.',
scope: 'pages.write',
input: updateCategoryInput,
async handler(input, ctx) {
const result = await updateCmsCategory({
id: input.id,
slug: input.slug,
parent_id: input.parent_id === undefined ? undefined : (input.parent_id ?? null),
menu_order: input.menu_order,
translations: input.translations
})

View file

@ -50,7 +50,7 @@ export const listPagesTool = defineMcpTool({
export const getPageTool = defineMcpTool({
name: 'get_page',
description: 'Fetch a single CMS page by id or slug, including all translations (any status). Each translation includes body_json (Tiptap JSON), a Markdown rendering, and a body_is_lossy_in_markdown flag.',
description: 'Fetch a single CMS page by id or slug, including all translations (any status). The `slug` lookup takes the full URL path (e.g. "resources/overview", as returned in `url`); a bare leaf ("privacy") only matches an uncategorized page. Each translation includes body_json (Tiptap JSON), a Markdown rendering, and a body_is_lossy_in_markdown flag.',
scope: 'pages.view',
input: getPageInput,
async handler(input) {
@ -82,13 +82,15 @@ export const getPageTool = defineMcpTool({
}
})
return {
content: [{ type: 'text', text: `Page "${found.page.slug}" (${translations.length} translation(s))` }],
content: [{ type: 'text', text: `Page "${found.url}" (${translations.length} translation(s))` }],
structuredContent: {
page: {
id: found.page.id,
slug: found.page.slug,
url: found.url,
category_id: found.page.category_id,
category_slug: found.category_slug,
category_path: found.category_path,
menu_order: found.page.menu_order,
theme: found.page.theme,
custom_css: found.page.custom_css
@ -101,7 +103,7 @@ export const getPageTool = defineMcpTool({
export const createPageTool = defineMcpTool({
name: 'create_page',
description: 'Create a new CMS page shell, optionally with an inline initial translation. The page shell + translation are committed atomically. Slug rules: under a category the slug must start with "<category_slug>/"; uncategorized slugs cannot collide with category slugs. If translation.body_markdown is supplied it is converted to Tiptap JSON server-side; if translation.body_json is supplied it is validated against the schema/attribute allowlist.',
description: 'Create a new CMS page shell, optionally with an inline initial translation. The page shell + translation are committed atomically. Slug rules: leaf-only (lowercase letters, digits, dashes — no slashes). The page\'s URL is derived from its category chain + leaf, so a page in category X with slug "foo" lives at /<X-url>/foo. Uniqueness is enforced per-category, and the leaf cannot collide with a sibling category at the same level. If translation.body_markdown is supplied it is converted to Tiptap JSON server-side; if translation.body_json is supplied it is validated against the schema/attribute allowlist.',
scope: 'pages.write',
input: createPageInput,
async handler(input, ctx) {
@ -170,7 +172,7 @@ export const createPageTool = defineMcpTool({
export const updatePageTool = defineMcpTool({
name: 'update_page',
description: 'Update a page\'s metadata: slug, category_id, menu_order, theme, custom_css. Translation edits go through upsert_page_translation. Changing the category rewrites the slug prefix; cache for the old + new slug + categories is purged.',
description: 'Update a page\'s metadata: slug (leaf only), category_id, menu_order, theme, custom_css. Translation edits go through upsert_page_translation. Changing slug or category changes the public URL — cache for the old and new URLs is purged.',
scope: 'pages.write',
input: updatePageInput,
async handler(input, ctx) {
@ -200,11 +202,11 @@ export const deletePageTool = defineMcpTool({
destructive: true,
input: deletePageInput,
async handler(input, ctx) {
const { slug, categoryId } = await deleteCmsPage(input.id)
await mcpLog('DELETE', 'pages', input.id, ctx, { slug })
await applyPageInvalidations([slug], categoryId ? [categoryId] : [])
const { slug, url, categoryId } = await deleteCmsPage(input.id)
await mcpLog('DELETE', 'pages', input.id, ctx, { slug, url })
await applyPageInvalidations([url], categoryId ? [categoryId] : [])
return {
content: [{ type: 'text', text: `Deleted page "${slug}"` }],
content: [{ type: 'text', text: `Deleted page "${url}"` }],
structuredContent: { ok: true }
}
}

View file

@ -121,7 +121,7 @@ export const upsertPageTranslationTool = defineMcpTool({
})
}
await applyTranslationInvalidations(result.pageSlug, result.categoryId, input.locale)
await applyTranslationInvalidations(result.pageUrl, result.categoryId, input.locale)
const warningCount = result.sanitizationWarnings.length
const warningSuffix = warningCount > 0
@ -153,7 +153,7 @@ export const publishTranslationTool = defineMcpTool({
to: 'published',
locale: input.locale
})
await applyTranslationInvalidations(result.pageSlug, result.categoryId, input.locale)
await applyTranslationInvalidations(result.pageUrl, result.categoryId, input.locale)
return {
content: [{ type: 'text', text: `Published ${result.translation.locale} translation` }],
structuredContent: { translation: result.translation }
@ -177,7 +177,7 @@ export const unpublishTranslationTool = defineMcpTool({
to: 'draft',
locale: input.locale
})
await applyTranslationInvalidations(result.pageSlug, result.categoryId, input.locale)
await applyTranslationInvalidations(result.pageUrl, result.categoryId, input.locale)
return {
content: [{ type: 'text', text: `Unpublished ${result.translation.locale} translation` }],
structuredContent: { translation: result.translation }

View file

@ -1,8 +1,5 @@
// Category service. List/get reuse existing helpers; the writes
// implement the validation + cache-purge contract MCP and admin both
// rely on. Delete encodes the "refuse if pages still attached" rule
// from plans/mcp-project/mcp-project.md (count + sample-slugs surface
// on the structuredContent the tool returns).
// Category service. Categories form a tree (`parent_id`); a slug or
// parent change cascades to every URL beneath the affected node.
import type { H3Error } from 'h3'
import { sql } from 'kysely'
@ -16,6 +13,11 @@ import {
purgeSlugs
} from '../database/categories'
import type { Category, CategoryTranslation } from '../database/categories'
import {
loadCategoryTree,
categoryUrlPath,
wouldCreateCycle
} from '../database/categoryTree'
function err(statusCode: number, message: string, extra?: Record<string, unknown>): H3Error {
return Object.assign(new Error(message), {
@ -28,14 +30,79 @@ function err(statusCode: number, message: string, extra?: Record<string, unknown
export interface CategoryListItem extends Category {
translations: CategoryTranslation[]
page_count: number
url: string
// Slug-joined ancestor URL (e.g. "resources" — usable in href).
parent_path: string | null
// Human-readable ancestor chain using each ancestor's English name
// (e.g. "Resources" — for labels and dropdowns). Falls back to the
// slug for any ancestor missing an EN translation.
parent_label: string | null
}
// Joins a category's ancestor chain (root → immediate parent) using
// each ancestor's English translation, falling back to the slug.
function ancestorLabel(
tree: Awaited<ReturnType<typeof loadCategoryTree>>,
parentId: string | null,
enNameByCategory: Map<string, string>
): string | null {
if (!parentId) return null
const labels: string[] = []
let id: string | null = parentId
for (let i = 0; i < 32 && id; i++) {
const node = tree.byId.get(id)
if (!node) break
labels.unshift(enNameByCategory.get(id) ?? node.slug)
id = node.parent_id
}
return labels.length ? labels.join(' / ') : null
}
export async function listCmsCategories(): Promise<CategoryListItem[]> {
return dbListCategoriesWithTranslations()
const rows = await dbListCategoriesWithTranslations()
if (rows.length === 0) return []
const tree = await loadCategoryTree()
const enByCategory = new Map<string, string>()
for (const row of rows) {
const en = row.translations.find(t => t.locale === 'en')?.name
if (en) enByCategory.set(row.id, en)
}
return rows.map(row => ({
...row,
url: categoryUrlPath(tree, row.id),
parent_path: row.parent_id ? categoryUrlPath(tree, row.parent_id) : null,
parent_label: ancestorLabel(tree, row.parent_id, enByCategory)
}))
}
export async function getCmsCategory(id: string) {
return dbGetCategory(id)
const record = await dbGetCategory(id)
if (!record) return null
const tree = await loadCategoryTree()
// Walk every ancestor's EN translation in one bulk query so the
// label chain doesn't fan out into per-ancestor SELECTs.
const ancestorIds: string[] = []
let cursor: string | null = record.category.parent_id
for (let i = 0; i < 32 && cursor; i++) {
ancestorIds.push(cursor)
cursor = tree.byId.get(cursor)?.parent_id ?? null
}
const enByCategory = new Map<string, string>()
if (ancestorIds.length > 0) {
const rows = await db
.selectFrom('category_translations')
.select(['category_id', 'name'])
.where('category_id', 'in', ancestorIds)
.where('locale', '=', 'en')
.execute()
for (const r of rows) enByCategory.set(r.category_id, r.name)
}
return {
...record,
url: categoryUrlPath(tree, id),
parent_path: record.category.parent_id ? categoryUrlPath(tree, record.category.parent_id) : null,
parent_label: ancestorLabel(tree, record.category.parent_id, enByCategory)
}
}
function validateCategorySlug(slug: string): string {
@ -57,8 +124,43 @@ function normalizeTranslations(
.map(t => ({ locale: t.locale, name: t.name.trim() }))
}
// Sibling-collision check shared by create + update. A category and a
// page at the same level can't share a leaf slug (the URL would be
// ambiguous), nor can two sibling categories.
async function ensureNoSiblingCollision(
slug: string,
parentId: string | null,
excludeCategoryId?: string
): Promise<void> {
let catQ = db
.selectFrom('categories')
.select('id')
.where('slug', '=', slug)
catQ = parentId === null
? catQ.where('parent_id', 'is', null)
: catQ.where('parent_id', '=', parentId)
if (excludeCategoryId) catQ = catQ.where('id', '!=', excludeCategoryId)
const collidingCat = await catQ.executeTakeFirst()
if (collidingCat) {
throw err(409, 'A category with that slug already exists at this level')
}
let pageQ = db
.selectFrom('pages')
.select('id')
.where('slug', '=', slug)
pageQ = parentId === null
? pageQ.where('category_id', 'is', null)
: pageQ.where('category_id', '=', parentId)
const collidingPage = await pageQ.executeTakeFirst()
if (collidingPage) {
throw err(409, `A page already uses the slug "${slug}" at this level. Pick a different slug or move that page.`)
}
}
export interface CreateCategoryInput {
slug: string
parent_id?: string | null
menu_order?: number
translations: Array<{ locale: string; name: string }>
}
@ -70,52 +172,86 @@ export async function createCmsCategory(input: CreateCategoryInput): Promise<Cat
throw err(400, 'An English name is required')
}
// Block collision with an uncategorized page
const collidingPage = await db
.selectFrom('pages')
.select('id')
.where('slug', '=', slug)
.where('category_id', 'is', null)
.executeTakeFirst()
if (collidingPage) {
throw err(409, `A page already uses the slug "${slug}". Pick a different category slug or reassign that page.`)
const parentId = input.parent_id ?? null
if (parentId) {
const parent = await db
.selectFrom('categories')
.select('id')
.where('id', '=', parentId)
.executeTakeFirst()
if (!parent) throw err(400, 'Parent category not found')
}
await ensureNoSiblingCollision(slug, parentId)
let created: Category
try {
return await dbCreateCategory({
created = await dbCreateCategory({
slug,
parent_id: parentId,
menu_order: input.menu_order ?? 0,
translations
})
} catch (e: unknown) {
if ((e as { code?: string })?.code === '23505') {
throw err(409, 'A category with that slug already exists')
throw err(409, 'A category with that slug already exists at this level')
}
throw e
}
// A new child appears in its parent's landing-page grid; bust the
// parent's cached landing so it shows up without waiting for the TTL.
if (created.parent_id) {
const tree = await loadCategoryTree()
const parentPath = categoryUrlPath(tree, created.parent_id)
if (parentPath) await applyCategoryInvalidations([parentPath])
}
return created
}
export interface UpdateCategoryInput {
id: string
slug?: string
parent_id?: string | null
menu_order?: number
translations?: Array<{ locale: string; name: string }>
}
export async function updateCmsCategory(input: UpdateCategoryInput): Promise<{ category: Category; slugsToPurge: string[] }> {
const existing = await db
.selectFrom('categories')
.selectAll()
.where('id', '=', input.id)
.executeTakeFirst()
if (!existing) throw err(404, 'Category not found')
const args: Parameters<typeof dbUpdateCategory>[1] = {}
let nextSlug = existing.slug
let nextParentId: string | null = existing.parent_id
if (input.slug !== undefined) {
const slug = validateCategorySlug(input.slug)
const collidingPage = await db
.selectFrom('pages')
.select('id')
.where('slug', '=', slug)
.where('category_id', 'is', null)
.executeTakeFirst()
if (collidingPage) {
throw err(409, `A page already uses the slug "${slug}".`)
nextSlug = validateCategorySlug(input.slug)
args.slug = nextSlug
}
if (input.parent_id !== undefined) {
nextParentId = input.parent_id ?? null
if (nextParentId) {
const tree = await loadCategoryTree()
if (wouldCreateCycle(tree, input.id, nextParentId)) {
throw err(400, 'Parent must not be the category itself or one of its descendants')
}
const parent = await db
.selectFrom('categories')
.select('id')
.where('id', '=', nextParentId)
.executeTakeFirst()
if (!parent) throw err(400, 'Parent category not found')
}
args.slug = slug
args.parent_id = nextParentId
}
if ((args.slug !== undefined && nextSlug !== existing.slug) ||
(args.parent_id !== undefined && nextParentId !== existing.parent_id)) {
await ensureNoSiblingCollision(nextSlug, nextParentId, input.id)
}
if (input.menu_order !== undefined) args.menu_order = input.menu_order
if (input.translations !== undefined) args.translations = normalizeTranslations(input.translations)
@ -124,30 +260,48 @@ export async function updateCmsCategory(input: UpdateCategoryInput): Promise<{ c
return await dbUpdateCategory(input.id, args)
} catch (e: unknown) {
if ((e as { code?: string })?.code === '23505') {
throw err(409, 'A category with that slug already exists')
throw err(409, 'A category with that slug already exists at this level')
}
throw e
}
}
export interface DeleteCategoryError {
error: 'attached_pages_present'
error: 'attached_pages_present' | 'child_categories_present'
attached_page_count: number
sample_slugs: string[]
}
// Returns null on success, or a structured error payload when pages
// are still attached. Uses the FK constraint to handle the rare race
// between count and delete (see plans/mcp-project/mcp-project.md).
// Returns null on success, or a structured error payload when the
// category still has pages or child categories.
export async function deleteCmsCategory(id: string): Promise<{ ok: true; slug: string } | DeleteCategoryError> {
const existing = await db
.selectFrom('categories')
.select(['id', 'slug'])
.select(['id', 'slug', 'parent_id'])
.where('id', '=', id)
.executeTakeFirst()
if (!existing) throw err(404, 'Category not found')
const result = await db.transaction().execute(async (tx) => {
const childCountRow = await tx
.selectFrom('categories')
.select(sql<number>`COUNT(*)::int`.as('count'))
.where('parent_id', '=', id)
.executeTakeFirstOrThrow()
if (Number(childCountRow.count) > 0) {
const sample = await tx
.selectFrom('categories')
.select('slug')
.where('parent_id', '=', id)
.limit(5)
.execute()
return {
kind: 'children' as const,
attached_page_count: Number(childCountRow.count),
sample_slugs: sample.map(s => s.slug)
}
}
const countRow = await tx
.selectFrom('pages')
.select(sql<number>`COUNT(*)::int`.as('count'))
@ -174,7 +328,6 @@ export async function deleteCmsCategory(id: string): Promise<{ ok: true; slug: s
return { kind: 'deleted' as const, slug: existing.slug }
} catch (e: unknown) {
if ((e as { code?: string })?.code === '23503') {
// FK violation: race window — a page was just attached.
return {
kind: 'attached' as const,
attached_page_count: 1,
@ -192,6 +345,22 @@ export async function deleteCmsCategory(id: string): Promise<{ ok: true; slug: s
sample_slugs: result.sample_slugs
}
}
if (result.kind === 'children') {
return {
error: 'child_categories_present',
attached_page_count: result.attached_page_count,
sample_slugs: result.sample_slugs
}
}
// The deleted child drops out of its parent's landing-page grid; bust
// the parent's cached landing. (The parent still exists post-delete,
// so its path resolves.)
if (existing.parent_id) {
const tree = await loadCategoryTree()
const parentPath = categoryUrlPath(tree, existing.parent_id)
if (parentPath) await applyCategoryInvalidations([parentPath])
}
return { ok: true, slug: result.slug }
}

View file

@ -1,11 +1,13 @@
// Shared service for CMS page operations. Called by MCP tools today;
// admin routes will follow under a separate service-extraction PR
// (Phase P1/P4 in plans/mcp-project/mcp-project.md).
// Shared service for CMS page operations. Called by MCP tools and admin
// routes. Each operation owns: validation, DB writes (transactional
// when more than one row changes), activity logging (post-commit unless
// inside a transaction), and cache invalidation (always post-commit).
//
// Each operation is responsible for: validation, DB writes (in a
// transaction when more than one row changes), activity logging
// (post-commit unless inside a transaction), and cache invalidation
// (always post-commit).
// Slugs are leaves only — the public URL is derived from the page's
// category chain at request time. Cache purges therefore work in terms
// of *URLs* (`pageUrl`), not the raw `slug` column. Service callers get
// the URL back in `slugsToPurge` (kept that name for compatibility with
// the cache layer's API).
import { sql } from 'kysely'
import type { Kysely } from 'kysely'
@ -14,7 +16,14 @@ import { db } from '../utils/database'
import { purgeCmsPage, purgeCmsCategory } from '../utils/cmsCache'
import { ENABLED_LANGUAGE_CODES } from '~~/config/languages'
import type { Page, PageTranslation } from '../database/pages'
import { findPageInCategory } from '../database/pages'
import type { Database } from '../database/schema'
import {
loadCategoryTree,
walkCategorySegments,
pageUrlPath,
categoryUrlPath
} from '../database/categoryTree'
export interface PageActor {
userId: string
@ -25,8 +34,10 @@ export interface PageActor {
export interface PageListItem {
id: string
slug: string
url: string
category_id: string | null
category_slug: string | null
category_path: string | null
theme: 'default' | 'green'
menu_order: number
translations: Array<{
@ -138,9 +149,6 @@ export async function listCmsPages(opts: ListPagesOptions = {}): Promise<ListPag
let translations = await tQuery.execute()
// Title substring match: requires a translation row to exist for
// the page (any locale). When a query is provided we also keep
// pages whose slug matched (already filtered above).
if (opts.query) {
const pattern = opts.query.toLowerCase()
translations = translations.filter(t => t.title.toLowerCase().includes(pattern))
@ -158,15 +166,21 @@ export async function listCmsPages(opts: ListPagesOptions = {}): Promise<ListPag
tByPage.set(t.page_id, list)
}
const out: PageListItem[] = pageRows.map(p => ({
id: p.id,
slug: p.slug,
category_id: p.category_id,
category_slug: p.category_slug ?? null,
theme: p.theme as 'default' | 'green',
menu_order: p.menu_order,
translations: tByPage.get(p.id) ?? []
}))
const tree = await loadCategoryTree()
const out: PageListItem[] = pageRows.map(p => {
const categoryPath = p.category_id ? categoryUrlPath(tree, p.category_id) : null
return {
id: p.id,
slug: p.slug,
url: pageUrlPath(tree, { slug: p.slug, category_id: p.category_id }),
category_id: p.category_id,
category_slug: p.category_slug ?? null,
category_path: categoryPath || null,
theme: p.theme as 'default' | 'green',
menu_order: p.menu_order,
translations: tByPage.get(p.id) ?? []
}
})
return { pages: out, nextCursor }
}
@ -175,42 +189,50 @@ export interface FullPage {
page: Page
translations: PageTranslation[]
category_slug: string | null
category_path: string | null
url: string
}
export async function getCmsPage(input: { id?: string; slug?: string }): Promise<FullPage | null> {
let pageQuery = db.selectFrom('pages').selectAll()
if (input.id) pageQuery = pageQuery.where('id', '=', input.id)
else if (input.slug) pageQuery = pageQuery.where('slug', '=', input.slug)
else return null
const tree = await loadCategoryTree()
const page = await pageQuery.executeTakeFirst()
let page: Page | undefined
if (input.id) {
page = await db.selectFrom('pages').selectAll().where('id', '=', input.id).executeTakeFirst()
} else if (input.slug) {
// Resolve the slug as a full URL path ("resources/overview") against
// the category tree so a leaf that repeats across categories is
// unambiguous. A bare leaf ("privacy") resolves to an uncategorized
// page; a path that ends on a category (no trailing page) is not a
// page lookup and returns null.
const segments = input.slug.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean)
if (segments.length === 0) return null
const { category, remaining } = walkCategorySegments(tree, segments)
if (remaining.length !== 1) return null
page = (await findPageInCategory(category?.id ?? null, remaining[0]!)) ?? undefined
} else {
return null
}
if (!page) return null
const [translations, category] = await Promise.all([
db
.selectFrom('page_translations')
.selectAll()
.where('page_id', '=', page.id)
.orderBy('locale', 'asc')
.execute(),
page.category_id
? db
.selectFrom('categories')
.select('slug')
.where('id', '=', page.category_id)
.executeTakeFirst()
: Promise.resolve(null)
])
const translations = await db
.selectFrom('page_translations')
.selectAll()
.where('page_id', '=', page.id)
.orderBy('locale', 'asc')
.execute()
const categoryPath = page.category_id ? categoryUrlPath(tree, page.category_id) : null
const categorySlug = page.category_id ? (tree.byId.get(page.category_id)?.slug ?? null) : null
return {
page,
translations,
category_slug: category?.slug ?? null
category_slug: categorySlug,
category_path: categoryPath || null,
url: pageUrlPath(tree, page)
}
}
// Validates a slug shape and uniqueness. Throws an H3-shaped error so
// the MCP layer's mcpError mapper can translate to a clean response.
function err(statusCode: number, message: string): H3Error {
return Object.assign(new Error(message), {
statusCode,
@ -219,62 +241,53 @@ function err(statusCode: number, message: string): H3Error {
}) as unknown as H3Error
}
// Normalize slug input: trim whitespace, strip leading/trailing
// slashes. Single source of truth — admin routes and MCP tools both
// pass raw input; services normalize before validating. Empty result
// passes through; the regex check downstream rejects empty strings.
// Normalize slug input: trim whitespace, strip any slashes (slugs are
// leaves — slashes were valid only in the old prefix-as-slug model).
export function normalizeSlugInput(raw: string | null | undefined): string {
return (raw ?? '').trim().replace(/^\/+|\/+$/g, '')
return (raw ?? '').trim().replace(/^\/+|\/+$/g, '').replace(/\/+/g, '-')
}
// Pass an `executor` (a Kysely transaction handle) when validation
// is part of a larger atomic operation — the read-side category
// existence/prefix check and the page-slug-uniqueness check then
// run on the same connection as the subsequent insert/update, so
// a concurrent category rename or page-slug change can't slip
// between validation and write.
// Validates a leaf slug: shape, no collision with a sibling category
// at the same level (a category and a page can't share a leaf because
// `/parent/leaf` would be ambiguous), and no collision with another
// page in the same category.
export async function validatePageSlug(
slug: string,
opts: { categoryId: string | null; excludePageId?: string },
executor: Kysely<Database> = db
): Promise<void> {
if (!/^[a-z0-9][a-z0-9/-]*$/.test(slug)) {
throw err(400, 'slug must be lowercase letters, digits, dashes, and slashes')
}
if (slug.endsWith('/')) {
throw err(400, 'slug cannot end with /')
}
if (opts.categoryId) {
const category = await executor
.selectFrom('categories')
.select(['slug'])
.where('id', '=', opts.categoryId)
.executeTakeFirst()
if (!category) throw err(400, 'Category not found')
if (!slug.startsWith(`${category.slug}/`)) {
throw err(400, `Slug must start with "${category.slug}/"`)
}
} else {
const collidingCategory = await executor
.selectFrom('categories')
.select('id')
.where('slug', '=', slug)
.executeTakeFirst()
if (collidingCategory) {
throw err(409, `"${slug}" is already used by a category.`)
}
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) {
throw err(400, 'slug must be lowercase letters, digits, and dashes (no slashes)')
}
// Sibling-category collision: a category whose `parent_id` matches
// this page's category would render at the same URL level.
let categoryQuery = executor
.selectFrom('categories')
.select('id')
.where('slug', '=', slug)
categoryQuery = opts.categoryId === null
? categoryQuery.where('parent_id', 'is', null)
: categoryQuery.where('parent_id', '=', opts.categoryId)
const collidingCategory = await categoryQuery.executeTakeFirst()
if (collidingCategory) {
throw err(409, `"${slug}" is already used by a category at this level.`)
}
// Sibling-page collision in the same category.
let pageQuery = executor
.selectFrom('pages')
.select('id')
.where('slug', '=', slug)
pageQuery = opts.categoryId === null
? pageQuery.where('category_id', 'is', null)
: pageQuery.where('category_id', '=', opts.categoryId)
if (opts.excludePageId) {
pageQuery = pageQuery.where('id', '!=', opts.excludePageId)
}
const collidingPage = await pageQuery.executeTakeFirst()
if (collidingPage) {
throw err(409, 'A page with that slug already exists')
throw err(409, 'A page with that slug already exists in this category')
}
}
@ -297,18 +310,8 @@ export interface CreatePageInput {
}
}
// Creates a page (and optionally an inline initial translation) in a
// single transaction. The audit hook receives the transaction's
// executor; callers (admin routes, MCP tools) are expected to pass a
// `throwOnError: true` to logEvent (or use mcpLog, which does this
// automatically when an executor is provided) so an audit-insert
// failure inside the transaction rolls back the page insert.
export interface CreateCmsPageAuditContext {
pageId: string
// The translation row id, set only when an inline translation was
// created in the same transaction. Lets callers emit a separate
// CREATE event on page_translations keyed by the actual row id so
// audit-log greps on `record_id` find the translation.
translationId: string | null
}
@ -323,15 +326,17 @@ export async function createCmsPage(
throw err(400, 'locale is not enabled')
}
}
// Use the normalized form everywhere downstream — input.slug never
// touches the DB after this point.
input = { ...input, slug }
const result = await db.transaction().execute(async (tx) => {
// Validate the slug inside the transaction so the category-prefix
// check and slug-uniqueness check run on the same connection as
// the insert. Without this a concurrent category rename could
// change the prefix between validation and write.
if (input.category_id) {
const cat = await tx
.selectFrom('categories')
.select('id')
.where('id', '=', input.category_id)
.executeTakeFirst()
if (!cat) throw err(400, 'Category not found')
}
await validatePageSlug(slug, { categoryId: input.category_id ?? null }, tx)
// New pages append to the bottom of their category. When the caller
@ -386,7 +391,11 @@ export async function createCmsPage(
return { page, translation }
})
const slugsToPurge = [result.page.slug]
// Compute the full public URL so the cache layer purges by the same
// key the lookup endpoint writes (full path, hex-encoded).
const tree = await loadCategoryTree()
const url = pageUrlPath(tree, result.page)
const slugsToPurge = [url]
const categoriesToPurge: string[] = []
if (result.page.category_id) categoriesToPurge.push(result.page.category_id)
@ -410,13 +419,6 @@ export interface UpdatePageResult {
}
export async function updateCmsPage(input: UpdatePageInput): Promise<UpdatePageResult> {
// Wrap reads + slug validation + UPDATE in one transaction so a
// concurrent rename of the source or target category — or a
// concurrent slug change on another page — can't slip between the
// category-prefix calculation and the row write. The bare-UPDATE
// version that lived here briefly had a race window where the
// page would commit with a stale prefix relative to the category's
// current slug.
return await db.transaction().execute(async (tx) => {
const existing = await tx
.selectFrom('pages')
@ -425,7 +427,12 @@ export async function updateCmsPage(input: UpdatePageInput): Promise<UpdatePageR
.executeTakeFirst()
if (!existing) throw err(404, 'Page not found')
const slugsToPurge = new Set<string>([existing.slug])
// Snapshot the page's current full URL before any changes so we
// can purge the old key after the update commits.
const treeBefore = await loadCategoryTree(tx)
const oldUrl = pageUrlPath(treeBefore, existing)
const slugsToPurge = new Set<string>([oldUrl])
const categoriesToPurge = new Set<string>()
const changes: Record<string, unknown> = {}
@ -437,40 +444,30 @@ export async function updateCmsPage(input: UpdatePageInput): Promise<UpdatePageR
let finalSlug = existing.slug
if (input.slug !== undefined) {
finalSlug = normalizeSlugInput(input.slug)
} else if (input.category_id !== undefined && finalCategoryId !== existing.category_id) {
// Category change without slug change: rewrite the prefix.
let leaf = existing.slug
if (existing.category_id) {
const oldCat = await tx
.selectFrom('categories')
.select('slug')
.where('id', '=', existing.category_id)
.executeTakeFirst()
if (oldCat && existing.slug.startsWith(`${oldCat.slug}/`)) {
leaf = existing.slug.slice(oldCat.slug.length + 1)
}
}
if (finalCategoryId) {
const newCat = await tx
.selectFrom('categories')
.select('slug')
.where('id', '=', finalCategoryId)
.executeTakeFirst()
if (!newCat) throw err(400, 'Category not found')
finalSlug = `${newCat.slug}/${leaf}`
} else {
finalSlug = leaf
}
}
if (finalSlug !== existing.slug) {
await validatePageSlug(finalSlug, { categoryId: finalCategoryId, excludePageId: input.id }, tx)
changes.slug = finalSlug
}
if (finalCategoryId !== existing.category_id) {
changes.category_id = finalCategoryId
}
// Validate the (slug, category) pair when either changed. Done in
// the same transaction so a concurrent rename can't slip between
// validation and write.
if (changes.slug !== undefined || changes.category_id !== undefined) {
if (finalCategoryId) {
const cat = await tx
.selectFrom('categories')
.select('id')
.where('id', '=', finalCategoryId)
.executeTakeFirst()
if (!cat) throw err(400, 'Category not found')
}
await validatePageSlug(finalSlug, { categoryId: finalCategoryId, excludePageId: input.id }, tx)
}
if (input.menu_order !== undefined && input.menu_order !== existing.menu_order) {
changes.menu_order = input.menu_order
}
@ -497,7 +494,8 @@ export async function updateCmsPage(input: UpdatePageInput): Promise<UpdatePageR
.returningAll()
.executeTakeFirstOrThrow()
slugsToPurge.add(updated.slug)
const treeAfter = await loadCategoryTree(tx)
slugsToPurge.add(pageUrlPath(treeAfter, updated))
if (updated.slug !== existing.slug || updated.menu_order !== existing.menu_order || updated.category_id !== existing.category_id) {
if (existing.category_id) categoriesToPurge.add(existing.category_id)
if (updated.category_id) categoriesToPurge.add(updated.category_id)
@ -512,7 +510,7 @@ export async function updateCmsPage(input: UpdatePageInput): Promise<UpdatePageR
})
}
export async function deleteCmsPage(id: string): Promise<{ slug: string; categoryId: string | null }> {
export async function deleteCmsPage(id: string): Promise<{ slug: string; url: string; categoryId: string | null }> {
const existing = await db
.selectFrom('pages')
.select(['id', 'slug', 'category_id'])
@ -520,14 +518,14 @@ export async function deleteCmsPage(id: string): Promise<{ slug: string; categor
.executeTakeFirst()
if (!existing) throw err(404, 'Page not found')
const tree = await loadCategoryTree()
const url = pageUrlPath(tree, existing)
await db.deleteFrom('pages').where('id', '=', id).execute()
return { slug: existing.slug, categoryId: existing.category_id }
return { slug: existing.slug, url, categoryId: existing.category_id }
}
export async function applyPageInvalidations(slugs: string[], categoryIds: string[]): Promise<void> {
await Promise.all(slugs.map(s => purgeCmsPage(s)))
// Skip the slugs we just purged from the category-sibling fan-out;
// otherwise purgeCmsCategory enumerates every member (including the
// page we already touched) and re-purges keys for no benefit.
await Promise.all(categoryIds.map(id => purgeCmsCategory(id, slugs)))
}

View file

@ -214,7 +214,7 @@ export async function translatePage(input: TranslatePageInput): Promise<Translat
// keeps the invalidation contract identical to admin's pre-
// unification behavior — locales that errored or were skipped
// never had their cache touched.
await applyTranslationInvalidations(upserted.pageSlug, upserted.categoryId, target)
await applyTranslationInvalidations(upserted.pageUrl, upserted.categoryId, target)
results.push({ locale: target, ok: true })
} catch (e: unknown) {

View file

@ -12,6 +12,7 @@ import { detectLossy } from '../utils/tiptapLossyDetector'
import { tiptapValidate, type SanitizationWarning } from '../utils/tiptapValidate'
import { markdownToTiptap } from '../utils/tiptapFromMarkdown'
import { isSafeHttpUrl as isSafeUrl } from '../utils/urlValidation'
import { loadCategoryTree, pageUrlPath } from '../database/categoryTree'
function err(statusCode: number, message: string, extra?: Record<string, unknown>): H3Error {
return Object.assign(new Error(message), {
@ -44,11 +45,12 @@ export interface UpsertTranslationInput {
export interface UpsertTranslationResult {
translation: PageTranslation
// Full public URL path (no leading slash), e.g. "resources/adoption/overview".
// Cache purge keys are URL-based, so callers pass this through to
// `applyTranslationInvalidations` rather than the raw leaf slug.
pageUrl: string
pageSlug: string
categoryId: string | null
// True iff the input came in as Markdown and the existing body was
// lossy-in-markdown but allow_lossy_overwrite was set — the caller
// should log an mcp.lossy_overwrite event with the dropped reasons.
lossyOverwriteApplied: boolean
droppedReasons: string[]
// Sanitizer notes from tiptapValidate — attrs/marks/nodes that were
@ -202,8 +204,10 @@ export async function upsertCmsPageTranslation(input: UpsertTranslationInput): P
})
}
const tree = await loadCategoryTree()
return {
translation,
pageUrl: pageUrlPath(tree, page),
pageSlug: page.slug,
categoryId: page.category_id,
lossyOverwriteApplied,
@ -226,7 +230,7 @@ export async function setCmsTranslationStatus(input: {
page_id: string
locale: string
status: 'draft' | 'published'
}): Promise<{ translation: PageTranslation; pageSlug: string; categoryId: string | null }> {
}): Promise<{ translation: PageTranslation; pageUrl: string; pageSlug: string; categoryId: string | null }> {
if (!ENABLED_LANGUAGE_CODES.includes(input.locale)) {
throw err(400, 'locale is not enabled')
}
@ -248,11 +252,14 @@ export async function setCmsTranslationStatus(input: {
if (!updated) throw err(404, 'Translation not found')
return { translation: updated, pageSlug: page.slug, categoryId: page.category_id }
const tree = await loadCategoryTree()
return { translation: updated, pageUrl: pageUrlPath(tree, page), pageSlug: page.slug, categoryId: page.category_id }
}
export async function applyTranslationInvalidations(slug: string, categoryId: string | null, locale?: string): Promise<void> {
await purgeCmsPage(slug, locale ? [locale] : undefined)
if (categoryId) await purgeCmsCategory(categoryId, slug)
// `url` is the full public URL path (no leading slash) — same string
// the lookup endpoint uses as its cache key suffix.
export async function applyTranslationInvalidations(url: string, categoryId: string | null, locale?: string): Promise<void> {
await purgeCmsPage(url, locale ? [locale] : undefined)
if (categoryId) await purgeCmsCategory(categoryId, url)
}

View file

@ -9,6 +9,13 @@
import { ENABLED_LANGUAGE_CODES } from '~~/config/languages'
import { db } from './database'
import {
loadCategoryTree,
categoryChain,
descendantCategoryIds,
pageUrlPath,
categoryUrlPath
} from '../database/categoryTree'
// Key format matches what defineCachedEventHandler writes in
// server/api/pages/[...slug].get.ts. Nitro composes
@ -29,30 +36,49 @@ export async function purgeCmsPage(slug: string, locales?: string[]) {
)
}
// Purge every page in a category across all locales. Needed whenever a
// field that leaks into a sibling's cached response changes — titles,
// excerpts, featured images (all embedded in `children[]`) and
// menu_order (alters sibling sort order). `excludeSlugs` lets the
// caller skip pages whose entries were already purged independently
// (avoids duplicate removeItem calls + lets `applyPageInvalidations`
// pass the page-being-modified's slug — and its old slug, on a
// category move — through).
// Purge every cached page across a category's whole top-level subtree,
// in all locales. Needed whenever a field that leaks into another cached
// response changes — titles, excerpts, featured images (embedded in
// `children[]` and the landing grid), menu_order (sort order), or a
// page's published state (whether a category appears in the menu/grid at
// all). The sidebar nav tree and the landing-page grids span the entire
// top-level subtree — every page under `/resources` shares one nav, and
// ancestor landings list their descendants — so the invalidation has to
// reach from the top-level ancestor down, not just this category's own
// subtree.
//
// `excludeUrls` skips entries already purged by the caller (avoids
// duplicate removeItem calls when `applyPageInvalidations` already
// handled the page-being-modified's old + new URL).
export async function purgeCmsCategory(
categoryId: string,
excludeSlugs?: string | ReadonlyArray<string>
excludeUrls?: string | ReadonlyArray<string>
): Promise<void> {
const skip = new Set<string>(
excludeSlugs === undefined
excludeUrls === undefined
? []
: (typeof excludeSlugs === 'string' ? [excludeSlugs] : excludeSlugs)
: (typeof excludeUrls === 'string' ? [excludeUrls] : excludeUrls)
)
const tree = await loadCategoryTree()
const chain = categoryChain(tree, categoryId)
const rootId = chain[0]?.id ?? categoryId
const subtreeIds = descendantCategoryIds(tree, rootId)
if (subtreeIds.length === 0) return
const rows = await db
.selectFrom('pages')
.select('slug')
.where('category_id', '=', categoryId)
.select(['slug', 'category_id'])
.where('category_id', 'in', subtreeIds)
.execute()
const slugs = rows.map(r => r.slug).filter(s => !skip.has(s))
await Promise.all(slugs.map(s => purgeCmsPage(s)))
const urls = new Set<string>()
for (const r of rows) urls.add(pageUrlPath(tree, r))
// Bare-category landings cache too (e.g. `/resources` resolves
// through getCategoryDefaultPage); purge them along with the pages.
for (const cid of subtreeIds) {
const path = categoryUrlPath(tree, cid)
if (path) urls.add(path)
}
const targets = [...urls].filter(u => !skip.has(u))
await Promise.all(targets.map(u => purgeCmsPage(u)))
}
// Wipe every cached CMS page response. Used by the admin "Flush Cache"