This commit is contained in:
corsacca 2026-04-21 14:41:43 +01:00
parent d5e06c8c71
commit b2975650ec
13 changed files with 638 additions and 30 deletions

150
app/pages/[...slug].vue Normal file
View file

@ -0,0 +1,150 @@
<script setup lang="ts">
// CMS catch-all renderer. Port of marketing-theme/page.php — the default
// page template with optional sidebar child-page nav. Fetches the
// rendered page from /api/pages/:slug and renders its title + body_html
// + sidebar. Falls back to a child-cards grid if a parent page has no
// body content (matches the `empty(trim(strip_tags(get_the_content())))`
// branch in the PHP source).
//
// Nuxt's router will only mount this file after every more-specific
// route (/, /research, /pray, /adopt, /adopt/[slug], /contact-us, /admin/*)
// has failed to match — so the hardcoded pages keep their priority.
interface ChildPage {
slug: string
title: string
excerpt: string | null
featured_image: string | null
menu_order: number
}
interface PageResponse {
slug: string
parent_slug: string | null
menu_order: number
requested_locale: string
resolved_locale: string
title: string
excerpt: string | null
featured_image: string | null
meta_title: string | null
meta_description: string | null
og_image: string | null
body_html: string
body_is_empty: boolean
menu_parent: { slug: string; title: string }
children: ChildPage[]
}
const route = useRoute()
const { locale } = useI18n()
const localePath = useLocalePath()
const slug = computed(() => {
const raw = route.params.slug
const parts = Array.isArray(raw) ? raw : [raw]
return parts.filter(Boolean).join('/')
})
const { data, error } = await useAsyncData<PageResponse | null>(
() => `cms-${slug.value}-${locale.value}`,
async () => {
if (!slug.value) return null
try {
return await $fetch<PageResponse>(`/api/pages/${slug.value}`, {
query: { locale: locale.value }
})
} catch (e: any) {
if (e?.statusCode === 404) return null
throw e
}
},
{ watch: [() => slug.value, () => locale.value] }
)
if (!data.value && !error.value) {
// Explicit 404 if the page isn't in the CMS (or not published)
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
}
// <head>
useHead(() => {
if (!data.value) return {}
return {
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 }] : [])
]
}
})
const hasSidebar = computed(() => (data.value?.children.length ?? 0) > 0)
const isParentSelf = computed(() => data.value?.menu_parent.slug === data.value?.slug)
const showChildGrid = computed(() =>
// Parent page with no body content → render children as cards
Boolean(data.value && isParentSelf.value && data.value.body_is_empty && data.value.children.length > 0)
)
useTextHighlight()
</script>
<template>
<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">
<div>
<ul class="stack | max-width-xs" role="list">
<li>
<NuxtLink
class="font-size-lg"
:class="{ 'current-link': data.slug === data.menu_parent.slug }"
:to="localePath(`/${data.menu_parent.slug}`)"
>
{{ data.menu_parent.title }}
</NuxtLink>
</li>
<li v-for="child in data.children" :key="child.slug">
<NuxtLink
:class="{ 'current-link': data.slug === child.slug }"
:to="localePath(`/${child.slug}`)"
>
{{ child.title }}
</NuxtLink>
</li>
</ul>
</div>
</nav>
</aside>
<article :id="`page-${data.slug}`" class="page">
<div v-if="data.featured_image" class="page-featured-image">
<img :src="data.featured_image" :alt="data.title">
</div>
<h1 v-if="!showChildGrid">{{ data.title }}</h1>
<div v-if="!showChildGrid" 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="child.featured_image" class="child-thumbnail">
<NuxtLink :to="localePath(`/${child.slug}`)">
<img :src="child.featured_image" :alt="child.title">
</NuxtLink>
</div>
<div class="stack">
<h3>
<NuxtLink class="color-white" :to="localePath(`/${child.slug}`)">
{{ child.title }}
</NuxtLink>
</h3>
<div v-if="child.excerpt" class="child-excerpt" v-html="child.excerpt" />
</div>
</div>
</div>
</article>
</div>
</div>
</template>

View file

@ -1,6 +0,0 @@
<template>
<section class="container stack stack--md">
<h1>Definitions</h1>
<p>This page is rendered by the CMS in Phase 5.</p>
</section>
</template>

View file

@ -1,6 +0,0 @@
<template>
<section class="container stack stack--md">
<h1>About</h1>
<p>This page is rendered by the CMS in Phase 5.</p>
</section>
</template>

View file

@ -1,6 +0,0 @@
<template>
<section class="container stack stack--md">
<h1>Vision</h1>
<p>This page is rendered by the CMS in Phase 5.</p>
</section>
</template>

View file

@ -1,6 +0,0 @@
<template>
<section class="container stack stack--md">
<h1>Privacy Policy</h1>
<p>This page is rendered by the CMS in Phase 5.</p>
</section>
</template>

View file

@ -1,6 +0,0 @@
<template>
<section class="container stack stack--md">
<h1>Adoption Resources</h1>
<p>Resources are rendered by the CMS in Phase 5.</p>
</section>
</template>

View file

@ -12,6 +12,19 @@
"@nuxt/ui": "^4.6.1",
"@nuxtjs/i18n": "^10.2.4",
"@nuxtjs/turnstile": "^1.1.2",
"@tiptap/core": "^3.22.4",
"@tiptap/extension-color": "^3.22.4",
"@tiptap/extension-highlight": "^3.22.4",
"@tiptap/extension-image": "^3.22.4",
"@tiptap/extension-link": "^3.22.4",
"@tiptap/extension-subscript": "^3.22.4",
"@tiptap/extension-superscript": "^3.22.4",
"@tiptap/extension-text-align": "^3.22.4",
"@tiptap/extension-text-style": "^3.22.4",
"@tiptap/extension-typography": "^3.22.4",
"@tiptap/extension-youtube": "^3.22.4",
"@tiptap/html": "^3.22.4",
"@tiptap/starter-kit": "^3.22.4",
"bcrypt": "^6.0.0",
"fuse.js": "^7.3.0",
"intl-tel-input": "^26.7.5",
@ -825,6 +838,8 @@
"@tiptap/extension-collaboration": ["@tiptap/extension-collaboration@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4", "@tiptap/pm": "3.22.4", "@tiptap/y-tiptap": "^3.0.2", "yjs": "^13" } }, "sha512-4g4DdpvXZyYYCcWs3cO4DwtzhukqI13waYUKfwOcNnQwZ4YOCR83ET/kgqMk/xMAfbymKghbyAZRCc33zD5xvw=="],
"@tiptap/extension-color": ["@tiptap/extension-color@3.22.4", "", { "peerDependencies": { "@tiptap/extension-text-style": "3.22.4" } }, "sha512-1vDuVsrOETshe4j4nZhWalbKYcWfNybRCe30h829ExX06XwFryUYLb/LgTIaGCr9beWZUldsK+vOkBWdDTGMTw=="],
"@tiptap/extension-document": ["@tiptap/extension-document@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-XQKla1+703FqQJC48tPDVgt9ucGiFbIEmQdOg5L5o07z9a6/NzuaZAc+1zJ7NxcUZzy+z6wBn1PrVMTiqiSXlw=="],
"@tiptap/extension-drag-handle": ["@tiptap/extension-drag-handle@3.22.4", "", { "dependencies": { "@floating-ui/dom": "^1.6.13" }, "peerDependencies": { "@tiptap/core": "3.22.4", "@tiptap/extension-collaboration": "3.22.4", "@tiptap/extension-node-range": "3.22.4", "@tiptap/pm": "3.22.4", "@tiptap/y-tiptap": "^3.0.2" } }, "sha512-VCheiqy0OGrcrxOYt7Kp7MXWMZoEaZp517HnRrdrvcCCO0j2hcrT5mbo1mMPzP/WvTUHkXCmqcLv+uIk3Bdo/g=="],
@ -841,6 +856,8 @@
"@tiptap/extension-heading": ["@tiptap/extension-heading@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-TUaj5f0Ir5qy9HKKt2ocnwfXKpZDYeHgbbP9gshKFzdq5PLe1RbIgkjfy6bnoI865cYjmPYWRjcT7XsKyIcb9Q=="],
"@tiptap/extension-highlight": ["@tiptap/extension-highlight@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-QItbgkWqGZ04rOdYfjrn1TaUTv9XLKQE9VVJm9f6bxCpacPREJpz4lzkdea6zISHBZjEXJzlkjRWZP0zQLXXFA=="],
"@tiptap/extension-horizontal-rule": ["@tiptap/extension-horizontal-rule@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4", "@tiptap/pm": "3.22.4" } }, "sha512-cCI1HekGQwhY/MbgaKQ0R/7HcH5ZM1oFAyI/J72QGLC0XnF403S/OXoHMuBWr1mCu8hNiQWCzeNRJUty0iytNw=="],
"@tiptap/extension-image": ["@tiptap/extension-image@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-ZDc+fLaratTQ4IgnKcJJwfUgUgpcHjbZSBi6UQAILJwkflMy1Zxj8mpbma5P934nLSI+uDnR5ret6ZZLNITKhA=="],
@ -867,12 +884,26 @@
"@tiptap/extension-strike": ["@tiptap/extension-strike@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-aRHWQj42HiailXSC9LkKYM3jWMcSeGwOjbqM4PiuxQZmHVDRFmeHkfJItOdn2cSHaO0vuEVK+TvrWUWsBFi3pg=="],
"@tiptap/extension-subscript": ["@tiptap/extension-subscript@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4", "@tiptap/pm": "3.22.4" } }, "sha512-95Uwi8jEYVr/qil7tGZhG87wR0SNIUBZXSFcDkDeFBzRdccofwD2XL2HU5FqWm2IPNuE21lQWPImVspXR0Kzyw=="],
"@tiptap/extension-superscript": ["@tiptap/extension-superscript@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4", "@tiptap/pm": "3.22.4" } }, "sha512-VGNR67uTmdTfRC5+1HqtAfA+kK6Lbh9tXitcIs8Scwxlq89PBOUEs11RnzcXfwv5hYq94WjgQJF3A99Jc9Cg2w=="],
"@tiptap/extension-text": ["@tiptap/extension-text@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-mM69uUW5cSxIhyEpWXi/YcfyupcJMDLCPEfYi62awH0iOP/LRoCv/nHjJq4Hyj/KxRJbe8HKwIUnqaCUf7m5Pg=="],
"@tiptap/extension-text-align": ["@tiptap/extension-text-align@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-W7TnXWSyfDXSatGXp5y/CahE8G4btrQPb0/sy+eG+42FxdzYsqvh1ys3OE9j2XSuTrZ1q/tZA/NLPUkc7vw6Kw=="],
"@tiptap/extension-text-style": ["@tiptap/extension-text-style@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-24DVBdySNKq3ovY+v9ERVxAyHStDa6ftUlyoHuZv0YXQ2amjUNOmqQtGEHBIULpCbBb1jZ+atHhv9MBZ0Ia9Pw=="],
"@tiptap/extension-typography": ["@tiptap/extension-typography@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-LXcJbhLl7m8jQ+VFj8u6T5p8ylZLv8WUUZQal7JF4KgDLBhPGiS0O2HXXzcRfjuVzT+erqakwybnS+0bkTGcsQ=="],
"@tiptap/extension-underline": ["@tiptap/extension-underline@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-08kGdbhIrA6h10GWXqOkqIveaBj5tmxclK208/nUIAlonI9hPd739vu7fmVtpnmqCnSSNpoRtU4u6Gj5at0ZpA=="],
"@tiptap/extension-youtube": ["@tiptap/extension-youtube@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-H5dKDEdVhGeE2JZ4z6AvurN9GNwZQBNige8wjUie3R+hkwX/0DDql0oqvNM8lO5WM1M90Sq/AwyPQ2K9auSbbw=="],
"@tiptap/extensions": ["@tiptap/extensions@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4", "@tiptap/pm": "3.22.4" } }, "sha512-fOe8VptJvLPs32bNdUYo8SRyljwqKNQVXWW056VoXIc5en/59OdJlJQVeHI0jRRciH3MtrqODi/gfJR0VHNZ8A=="],
"@tiptap/html": ["@tiptap/html@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4", "@tiptap/pm": "3.22.4", "happy-dom": "^20.8.9" } }, "sha512-Y0ydX5ghuypurDRzU5YEVAXKJH2SWar7J7bDNslWHuXSmMnu2CBxFViGf7RKWw0BDMPv33wkuMxzkw1n0gOpgg=="],
"@tiptap/markdown": ["@tiptap/markdown@3.22.4", "", { "dependencies": { "marked": "^17.0.1" }, "peerDependencies": { "@tiptap/core": "3.22.4", "@tiptap/pm": "3.22.4" } }, "sha512-gcoLOq5TBntw13QdeWMy7yc2X+b9yTplcFb5kpMQNgNoxlu0+jlX4LiBR+E6lOV+m+AtkprHHltTYyKG23bdOw=="],
"@tiptap/pm": ["@tiptap/pm@3.22.4", "", { "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-commands": "^1.6.2", "prosemirror-dropcursor": "^1.8.1", "prosemirror-gapcursor": "^1.3.2", "prosemirror-history": "^1.4.1", "prosemirror-keymap": "^1.2.2", "prosemirror-model": "^1.24.1", "prosemirror-schema-list": "^1.5.0", "prosemirror-state": "^1.4.3", "prosemirror-tables": "^1.6.4", "prosemirror-transform": "^1.10.2", "prosemirror-view": "^1.38.1" } }, "sha512-hj8Qka6WcHRllHUdeSjDnq2XaisUo4KsoGJc1WcFpoa1Yd+OeD861zUMnV7DFVGdZRy45Obht0CUYJpXQ4yA4w=="],
@ -919,6 +950,10 @@
"@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="],
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/type-utils": "8.59.0", "@typescript-eslint/utils": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/types": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg=="],
@ -1499,6 +1534,8 @@
"h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="],
"happy-dom": ["happy-dom@20.9.0", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
@ -2273,6 +2310,8 @@
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
"whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"wheel-gestures": ["wheel-gestures@2.2.48", "", {}, "sha512-f+Gy33Oa5Z14XY9679Zze+7VFhbsQfBFXodnU2x589l4kxGM9L5Y8zETTmcMR5pWOPQyRv4Z0lNax6xCO0NSlA=="],

View file

@ -0,0 +1,58 @@
import { Kysely, sql } from 'kysely'
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('pages')
.ifNotExists()
.addColumn('id', 'uuid', col => col.primaryKey().defaultTo(sql`gen_random_uuid()`))
.addColumn('slug', 'text', col => col.unique().notNull())
.addColumn('parent_slug', 'text')
.addColumn('menu_order', 'integer', col => col.notNull().defaultTo(0))
.addColumn('created', 'timestamptz', col => col.notNull().defaultTo(sql`now()`))
.addColumn('updated', 'timestamptz', col => col.notNull().defaultTo(sql`now()`))
.execute()
await db.schema
.createIndex('pages_parent_slug_idx')
.ifNotExists()
.on('pages')
.column('parent_slug')
.execute()
await db.schema
.createTable('page_translations')
.ifNotExists()
.addColumn('id', 'uuid', col => col.primaryKey().defaultTo(sql`gen_random_uuid()`))
.addColumn('page_id', 'uuid', col => col.notNull().references('pages.id').onDelete('cascade'))
.addColumn('locale', 'text', col => col.notNull())
.addColumn('title', 'text', col => col.notNull())
.addColumn('body_json', 'jsonb', col => col.notNull().defaultTo(sql`'{}'::jsonb`))
.addColumn('excerpt', 'text')
.addColumn('featured_image', 'text')
.addColumn('meta_title', 'text')
.addColumn('meta_description', 'text')
.addColumn('og_image', 'text')
.addColumn('status', 'text', col => col.notNull().defaultTo('draft'))
.addColumn('updated', 'timestamptz', col => col.notNull().defaultTo(sql`now()`))
.execute()
await db.schema
.createIndex('page_translations_page_locale_uidx')
.ifNotExists()
.on('page_translations')
.columns(['page_id', 'locale'])
.unique()
.execute()
await db.schema
.createIndex('page_translations_status_idx')
.ifNotExists()
.on('page_translations')
.column('status')
.execute()
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('page_translations').ifExists().execute()
await db.schema.dropTable('pages').ifExists().execute()
}

View file

@ -18,6 +18,19 @@
"@nuxt/ui": "^4.6.1",
"@nuxtjs/i18n": "^10.2.4",
"@nuxtjs/turnstile": "^1.1.2",
"@tiptap/core": "^3.22.4",
"@tiptap/extension-color": "^3.22.4",
"@tiptap/extension-highlight": "^3.22.4",
"@tiptap/extension-image": "^3.22.4",
"@tiptap/extension-link": "^3.22.4",
"@tiptap/extension-subscript": "^3.22.4",
"@tiptap/extension-superscript": "^3.22.4",
"@tiptap/extension-text-align": "^3.22.4",
"@tiptap/extension-text-style": "^3.22.4",
"@tiptap/extension-typography": "^3.22.4",
"@tiptap/extension-youtube": "^3.22.4",
"@tiptap/html": "^3.22.4",
"@tiptap/starter-kit": "^3.22.4",
"bcrypt": "^6.0.0",
"fuse.js": "^7.3.0",
"intl-tel-input": "^26.7.5",

View file

@ -0,0 +1,86 @@
// 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
//
// Returns the translation (title, excerpt, featured_image, meta_*,
// status) plus pre-rendered HTML body (so the public bundle doesn't
// have to ship the Tiptap renderer) and the list of published child
// pages for sidebar navigation.
import { defineEventHandler, getRouterParam, getQuery, createError } from 'h3'
import { getPageBySlug, getChildTranslations } from '../../database/pages'
import { renderTiptap } from '../../utils/renderTiptap'
const ENABLED_LOCALES = new Set(['en', 'es', 'fr', 'pt', 'ar', 'ru'])
export default defineEventHandler(async (event) => {
const raw = getRouterParam(event, 'slug')
const slugParam = Array.isArray(raw) ? raw.join('/') : (raw ?? '')
const slug = slugParam.replace(/^\/+|\/+$/g, '')
if (!slug) {
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 result = await getPageBySlug(slug, locale, { fallback: 'en' })
if (!result) {
throw createError({ statusCode: 404, statusMessage: 'Page not found' })
}
const { page, translation, resolvedLocale, requestedLocale } = result
const bodyHtml = renderTiptap(translation.body_json)
// Menu parent = parent page if this is a child, or self if this is a
// top-level page. Sidebar items = children of that menu parent.
// Mirrors the WP page.php logic using `wp_get_post_parent_id()` and
// `get_pages(['child_of' => $menu_parent_id])`.
let menuParentSlug: string
let menuParentTitle: string
if (page.parent_slug) {
const parent = await getPageBySlug(page.parent_slug, resolvedLocale, { fallback: 'en' })
if (parent) {
menuParentSlug = parent.page.slug
menuParentTitle = parent.translation.title
} else {
// Parent row exists but isn't published — fall back to self
menuParentSlug = page.slug
menuParentTitle = translation.title
}
} else {
menuParentSlug = page.slug
menuParentTitle = translation.title
}
const siblings = await getChildTranslations(menuParentSlug, resolvedLocale, 'en')
return {
slug: page.slug,
parent_slug: page.parent_slug,
menu_order: page.menu_order,
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,
body_html: bodyHtml,
body_is_empty: bodyHtml.trim() === '',
menu_parent: {
slug: menuParentSlug,
title: menuParentTitle
},
children: siblings.map(({ page: c, translation: t }) => ({
slug: c.slug,
title: t.title,
excerpt: t.excerpt,
featured_image: t.featured_image,
menu_order: c.menu_order
}))
}
})

214
server/database/pages.ts Normal file
View file

@ -0,0 +1,214 @@
// Query helpers for the `pages` + `page_translations` tables (migration
// 005). All functions use Kysely against the shared `db` instance.
//
// 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.
import { db } from '../utils/database'
import type { PagesTable, PageTranslationsTable } from './schema'
import type { Selectable } from 'kysely'
export type Page = Selectable<PagesTable>
export type PageTranslation = Selectable<PageTranslationsTable>
export interface LocalizedPage {
page: Page
translation: PageTranslation
resolvedLocale: string // locale actually rendered (may differ from requested when fallback kicked in)
requestedLocale: string
}
export async function listPages(): Promise<Page[]> {
return db
.selectFrom('pages')
.selectAll()
.orderBy('parent_slug', 'asc')
.orderBy('menu_order', 'asc')
.orderBy('slug', 'asc')
.execute()
}
export async function getPageChildren(parentSlug: string): Promise<Page[]> {
return db
.selectFrom('pages')
.selectAll()
.where('parent_slug', '=', parentSlug)
.orderBy('menu_order', 'asc')
.orderBy('slug', 'asc')
.execute()
}
export async function getPageBySlug(
slug: string,
locale: string,
options: { fallback?: string } = {}
): Promise<LocalizedPage | null> {
const fallbackLocale = options.fallback ?? 'en'
const page = await db
.selectFrom('pages')
.selectAll()
.where('slug', '=', slug)
.executeTakeFirst()
if (!page) return null
const translation = await db
.selectFrom('page_translations')
.selectAll()
.where('page_id', '=', page.id)
.where('locale', '=', locale)
.where('status', '=', 'published')
.executeTakeFirst()
if (translation) {
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')
.selectAll()
.where('page_id', '=', page.id)
.where('locale', '=', fallbackLocale)
.where('status', '=', 'published')
.executeTakeFirst()
if (fallback) {
return { page, translation: fallback, resolvedLocale: fallbackLocale, requestedLocale: locale }
}
}
return null
}
export async function getChildTranslations(
parentSlug: string,
locale: string,
fallbackLocale = 'en'
): Promise<Array<{ page: Page; translation: PageTranslation }>> {
const children = await getPageChildren(parentSlug)
if (children.length === 0) return []
const ids = children.map(c => c.id)
const preferred = await db
.selectFrom('page_translations')
.selectAll()
.where('page_id', 'in', ids)
.where('locale', '=', locale)
.where('status', '=', 'published')
.execute()
const haveLocale = new Set(preferred.map(p => p.page_id))
const missing = ids.filter(id => !haveLocale.has(id))
let fallbacks: PageTranslation[] = []
if (missing.length && locale !== fallbackLocale) {
fallbacks = await db
.selectFrom('page_translations')
.selectAll()
.where('page_id', 'in', missing)
.where('locale', '=', fallbackLocale)
.where('status', '=', 'published')
.execute()
}
const byPageId = new Map<string, PageTranslation>()
for (const t of preferred) byPageId.set(t.page_id, t)
for (const t of fallbacks) byPageId.set(t.page_id, t)
return children
.map(page => ({ page, translation: byPageId.get(page.id)! }))
.filter(entry => Boolean(entry.translation))
}
export async function createPage(input: {
slug: string
parent_slug?: string | null
menu_order?: number
}): Promise<Page> {
const row = await db
.insertInto('pages')
.values({
slug: input.slug,
parent_slug: input.parent_slug ?? null,
menu_order: input.menu_order ?? 0
})
.returningAll()
.executeTakeFirstOrThrow()
return row
}
export async function deletePage(id: string): Promise<void> {
await db.deleteFrom('pages').where('id', '=', id).execute()
}
export async function upsertTranslation(input: {
page_id: string
locale: string
title: string
body_json: Record<string, any>
excerpt?: string | null
featured_image?: string | null
meta_title?: string | null
meta_description?: string | null
og_image?: string | null
status?: 'draft' | 'published'
}): Promise<PageTranslation> {
const existing = await db
.selectFrom('page_translations')
.selectAll()
.where('page_id', '=', input.page_id)
.where('locale', '=', input.locale)
.executeTakeFirst()
if (existing) {
const updated = await db
.updateTable('page_translations')
.set({
title: input.title,
body_json: input.body_json,
excerpt: input.excerpt ?? null,
featured_image: input.featured_image ?? null,
meta_title: input.meta_title ?? null,
meta_description: input.meta_description ?? null,
og_image: input.og_image ?? null,
...(input.status ? { status: input.status } : {}),
updated: new Date()
})
.where('id', '=', existing.id)
.returningAll()
.executeTakeFirstOrThrow()
return updated
}
const inserted = await db
.insertInto('page_translations')
.values({
page_id: input.page_id,
locale: input.locale,
title: input.title,
body_json: input.body_json,
excerpt: input.excerpt ?? null,
featured_image: input.featured_image ?? null,
meta_title: input.meta_title ?? null,
meta_description: input.meta_description ?? null,
og_image: input.og_image ?? null,
status: input.status ?? 'draft'
})
.returningAll()
.executeTakeFirstOrThrow()
return inserted
}
export async function setTranslationStatus(
pageId: string,
locale: string,
status: 'draft' | 'published'
): Promise<void> {
await db
.updateTable('page_translations')
.set({ status, updated: new Date() })
.where('page_id', '=', pageId)
.where('locale', '=', locale)
.execute()
}

View file

@ -35,8 +35,34 @@ export interface ActivityLogsTable {
metadata: Generated<Record<string, any>>
}
export interface PagesTable {
id: Generated<string>
slug: string
parent_slug: string | null
menu_order: Generated<number>
created: ColumnType<Date, Date | string | undefined, Date | string>
updated: ColumnType<Date, Date | string | undefined, Date | string>
}
export interface PageTranslationsTable {
id: Generated<string>
page_id: string
locale: string
title: string
body_json: Generated<Record<string, any>>
excerpt: string | null
featured_image: string | null
meta_title: string | null
meta_description: string | null
og_image: string | null
status: Generated<'draft' | 'published'>
updated: ColumnType<Date, Date | string | undefined, Date | string>
}
export interface Database {
users: UsersTable
password_reset_requests: PasswordResetRequestsTable
activity_logs: ActivityLogsTable
pages: PagesTable
page_translations: PageTranslationsTable
}

View file

@ -0,0 +1,52 @@
// Server-side Tiptap JSON → HTML renderer. The extension set here must
// stay aligned with app/components/admin/RichTextEditor.vue (built in
// Phase 6) so content authored in the editor round-trips back out to
// the public page with the same tags/attributes.
//
// Keeping this on the server keeps the public bundle free of Tiptap
// runtime code — the renderer serializes once and we send HTML.
import { generateHTML } from '@tiptap/html'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
import Image from '@tiptap/extension-image'
import TextAlign from '@tiptap/extension-text-align'
import { TextStyle } from '@tiptap/extension-text-style'
import Color from '@tiptap/extension-color'
import Highlight from '@tiptap/extension-highlight'
import Typography from '@tiptap/extension-typography'
import Subscript from '@tiptap/extension-subscript'
import Superscript from '@tiptap/extension-superscript'
import Youtube from '@tiptap/extension-youtube'
const EXTENSIONS = [
StarterKit,
Link.configure({ openOnClick: false, autolink: true }),
Image,
TextAlign.configure({ types: ['heading', 'paragraph'] }),
TextStyle,
Color,
Highlight,
Typography,
Subscript,
Superscript,
Youtube
]
function isEmptyDoc(doc: unknown): boolean {
if (!doc || typeof doc !== 'object') return true
const d = doc as { type?: string; content?: unknown[] }
if (d.type !== 'doc') return true
if (!Array.isArray(d.content) || d.content.length === 0) return true
return false
}
export function renderTiptap(doc: unknown): string {
if (isEmptyDoc(doc)) return ''
try {
return generateHTML(doc as any, EXTENSIONS)
} catch (err) {
console.error('renderTiptap: failed to serialize doc', err)
return ''
}
}