From c7692446631e83a4d4c2094c3037fd3b099fabc8 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 30 Mar 2026 17:38:20 +0000
Subject: [PATCH 01/19] Initial plan
From ee7dc1e9bf1747bd72b2686d2ba343ac3246db2d Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 30 Mar 2026 17:54:49 +0000
Subject: [PATCH 02/19] feat: add page content type and exposeToHomepage to
ottablog + worker APIs + admin editor
Agent-Logs-Url: https://github.com/thinkdj/ottabase/sessions/046b035e-9b57-4779-b4ad-6fa561dfc03b
---
.../pages/admin/blog/AdminBlogEditorPage.tsx | 26 ++++++-
.../src/types/blog.ts | 1 +
.../worker/routes/blog.ts | 71 +++++++++++++++++--
.../worker/routes/router.ts | 11 +++
packages/ottablog/src/index.ts | 1 +
.../src/ottaorm-models/Post.schema.ts | 6 ++
packages/ottablog/src/ottaorm-models/Post.ts | 21 ++++++
packages/ottablog/src/types.ts | 12 +++-
8 files changed, 141 insertions(+), 8 deletions(-)
diff --git a/apps/ottabase-template-app-tanstack/src/pages/admin/blog/AdminBlogEditorPage.tsx b/apps/ottabase-template-app-tanstack/src/pages/admin/blog/AdminBlogEditorPage.tsx
index 06cc1b1fc..0b46a3960 100644
--- a/apps/ottabase-template-app-tanstack/src/pages/admin/blog/AdminBlogEditorPage.tsx
+++ b/apps/ottabase-template-app-tanstack/src/pages/admin/blog/AdminBlogEditorPage.tsx
@@ -107,6 +107,7 @@ interface BlogPost {
allowComments: boolean;
isProtected?: boolean;
passwordHint?: string | null;
+ exposeToHomepage?: boolean;
publishedAt: string | null;
maxVersionsToKeep: number | null;
wordCount: number | null;
@@ -239,6 +240,7 @@ function BlogEditorForm({ postId, isEditMode, initialData }: BlogEditorFormProps
const [isFeatured, setIsFeatured] = useState(initialData?.isFeatured || false);
const [allowComments, setAllowComments] = useState(initialData?.allowComments ?? true);
const [isProtected, setIsProtected] = useState(initialData?.isProtected ?? false);
+ const [exposeToHomepage, setExposeToHomepage] = useState(initialData?.exposeToHomepage ?? false);
const [passwordHint, setPasswordHint] = useState(initialData?.passwordHint ?? '');
const [password, setPassword] = useState(''); // transient: only sent when setting/changing
const [publishedAt, setPublishedAt] = useState(
@@ -483,6 +485,7 @@ function BlogEditorForm({ postId, isEditMode, initialData }: BlogEditorFormProps
isFeatured === initialData.isFeatured &&
allowComments === initialData.allowComments &&
isProtected === (initialData.isProtected ?? false) &&
+ exposeToHomepage === (initialData.exposeToHomepage ?? false) &&
(passwordHint ?? '') === (initialData.passwordHint ?? '') &&
!password &&
(publishedAt || '') ===
@@ -519,6 +522,7 @@ function BlogEditorForm({ postId, isEditMode, initialData }: BlogEditorFormProps
authorName,
isFeatured,
allowComments,
+ exposeToHomepage,
publishedAt,
seriesId,
seriesOrder,
@@ -881,6 +885,7 @@ function BlogEditorForm({ postId, isEditMode, initialData }: BlogEditorFormProps
isFeatured,
allowComments,
isProtected,
+ exposeToHomepage: contentType === 'page' ? exposeToHomepage : false,
passwordHint: passwordHint || undefined,
...(isProtected && password.trim() ? { password: password.trim() } : {}),
publishedAt: publishNow && !publishedAt ? Date.now() : publishedAt || undefined,
@@ -1350,7 +1355,12 @@ function BlogEditorForm({ postId, isEditMode, initialData }: BlogEditorFormProps
id="contentType"
aria-label="Content type"
value={contentType}
- onChange={(e) => setContentType(e.target.value as ContentType)}
+ onChange={(e) => {
+ const next = e.target.value as ContentType;
+ setContentType(next);
+ // Clear exposeToHomepage when switching away from 'page'
+ if (next !== 'page') setExposeToHomepage(false);
+ }}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
>
{Object.entries(CONTENT_TYPES).map(([value, { label }]) => (
@@ -1409,6 +1419,20 @@ function BlogEditorForm({ postId, isEditMode, initialData }: BlogEditorFormProps
+ {contentType === 'page' && (
+
+ setExposeToHomepage(e.target.checked)}
+ className="rounded"
+ />
+
+
+ )}
+
{
+ const { env, url } = context;
+ const d1Error = ensureD1(env);
+ if (d1Error) return d1Error;
+ registerConnection('default', createD1Driver(env.OBCF_D1));
+
+ const appId = url.searchParams.get('appId') || null;
+ const where: Record
= { slug, status: 'published', contentType: 'page' };
+ if (appId) where.appId = appId;
+ const record = await Post.first(where);
+ if (!record) {
+ return errorResponse('Page not found', 404, { code: 'NOT_FOUND' });
+ }
+
+ const data = await publicPostJson(record, { enrichTags: true, enrichCategory: true });
+ return jsonResponse(data);
+}
+
+/**
+ * GET /api/blog/pages/exposed
+ * Returns published pages with exposeToHomepage: true.
+ * Used by the marketing homepage to build navbar links.
+ */
+export async function handleExposedPages(context: BlogRouteContext): Promise {
+ const { env, url } = context;
+ const d1Error = ensureD1(env);
+ if (d1Error) return d1Error;
+ registerConnection('default', createD1Driver(env.OBCF_D1));
+
+ const appId = url.searchParams.get('appId') || null;
+
+ try {
+ const where: Record = {
+ contentType: 'page',
+ status: 'published',
+ exposeToHomepage: true,
+ };
+ if (appId) where.appId = appId;
+
+ const pages = await Post.where(where, { orderBy: 'title', orderDirection: 'asc' });
+ const exposedPages = pages.map((p) => ({
+ slug: p.get('slug') as string,
+ title: p.get('title') as string,
+ }));
+ return jsonResponse({ exposedPages });
+ } catch {
+ // Never hard-fail: return empty array
+ return jsonResponse({ exposedPages: [] });
+ }
+}
+
export async function handleBlogPostUnlock(context: BlogRouteContext): Promise {
const { request, env, url } = context;
const d1Error = ensureD1(env);
@@ -390,7 +447,7 @@ export async function handleBlogPostUnlock(context: BlogRouteContext): Promise = { status: 'published', contentType: { $ne: 'changelog' } };
+ // Sitemap: only blog articles (exclude changelog + page)
+ const where: Record = { status: 'published', contentType: { $in: BLOG_FEED_CONTENT_TYPES } };
if (appId) where.appId = appId;
const posts = await Post.where(where, {
diff --git a/apps/ottabase-template-app-tanstack/worker/routes/router.ts b/apps/ottabase-template-app-tanstack/worker/routes/router.ts
index 8dae78dc5..185cb8382 100644
--- a/apps/ottabase-template-app-tanstack/worker/routes/router.ts
+++ b/apps/ottabase-template-app-tanstack/worker/routes/router.ts
@@ -50,6 +50,7 @@ import {
} from './auth';
import {
handleBlogCategoryBySlug,
+ handleBlogPageBySlug,
handleBlogPostBySlug,
handleBlogPostUnlock,
handleBlogPostsList,
@@ -63,6 +64,7 @@ import {
handleBlogStudioPluginEnable,
handleBlogStudioState,
handleBlogTagBySlug,
+ handleExposedPages,
} from './blog';
import { handleChangelogEntriesList, handleChangelogEntryBySlug } from './changelog';
import { handleBrandApi } from './brand';
@@ -219,6 +221,15 @@ async function handleGetRoutes(context: ApiRouteContext): Promise
Date: Mon, 30 Mar 2026 18:01:11 +0000
Subject: [PATCH 03/19] feat: add Next.js page route, navbar integration, and
tests for exposed pages
Agent-Logs-Url: https://github.com/thinkdj/ottabase/sessions/046b035e-9b57-4779-b4ad-6fa561dfc03b
---
.../.env.example | 3 +
.../__tests__/components.test.tsx | 50 +++++++++++
.../app/{ => (site)}/page.tsx | 0
.../app/layout-shell.tsx | 44 ++++++++--
.../app/layout.tsx | 8 +-
.../app/page/[slug]/page-content.tsx | 37 ++++++++
.../app/page/[slug]/page.tsx | 26 ++++++
.../lib/api.ts | 86 +++++++++++++++++++
.../package.json | 1 +
packages/ottablog/src/__tests__/types.test.ts | 52 ++++++++++-
pnpm-lock.yaml | 3 +
11 files changed, 301 insertions(+), 9 deletions(-)
rename apps/ottabase-template-app-nextjs-homepage/app/{ => (site)}/page.tsx (100%)
create mode 100644 apps/ottabase-template-app-nextjs-homepage/app/page/[slug]/page-content.tsx
create mode 100644 apps/ottabase-template-app-nextjs-homepage/app/page/[slug]/page.tsx
create mode 100644 apps/ottabase-template-app-nextjs-homepage/lib/api.ts
diff --git a/apps/ottabase-template-app-nextjs-homepage/.env.example b/apps/ottabase-template-app-nextjs-homepage/.env.example
index 9dbdc953c..82309a117 100644
--- a/apps/ottabase-template-app-nextjs-homepage/.env.example
+++ b/apps/ottabase-template-app-nextjs-homepage/.env.example
@@ -51,6 +51,9 @@ NODE_ENV="development"
# Set to "true" to show on production, or leave unset to auto-show in dev only
# NEXT_PUBLIC_SHOW_CONFIG_PANEL="true"
+# Ottabase Worker API URL (for CMS pages, exposed pages)
+# NEXT_PUBLIC_API_URL="http://localhost:3004"
+
# ============================================================
# Database Configuration (Prisma + D1)
# ============================================================
diff --git a/apps/ottabase-template-app-nextjs-homepage/__tests__/components.test.tsx b/apps/ottabase-template-app-nextjs-homepage/__tests__/components.test.tsx
index 03c50152b..e7a5cbf4c 100644
--- a/apps/ottabase-template-app-nextjs-homepage/__tests__/components.test.tsx
+++ b/apps/ottabase-template-app-nextjs-homepage/__tests__/components.test.tsx
@@ -314,3 +314,53 @@ describe('ThemePresetSwitcher', () => {
});
});
});
+
+describe('LayoutShell navbar merge', () => {
+ let mergeNavLinks: any;
+
+ beforeEach(async () => {
+ ({ mergeNavLinks } = await import('../app/layout-shell'));
+ });
+
+ it('returns base links when no exposed pages', () => {
+ const base = [
+ { href: '/', label: 'Home' },
+ { href: '/about', label: 'About' },
+ ];
+ const result = mergeNavLinks(base, []);
+ expect(result).toEqual(base);
+ });
+
+ it('appends exposed pages as /page/slug links', () => {
+ const base = [{ href: '/', label: 'Home' }];
+ const exposedPages = [
+ { slug: 'about-us', title: 'About Us' },
+ { slug: 'pricing', title: 'Pricing' },
+ ];
+ const result = mergeNavLinks(base, exposedPages);
+ expect(result).toEqual([
+ { href: '/', label: 'Home' },
+ { href: '/page/about-us', label: 'About Us' },
+ { href: '/page/pricing', label: 'Pricing' },
+ ]);
+ });
+
+ it('deduplicates exposed pages by href', () => {
+ const base = [{ href: '/page/about-us', label: 'Existing About' }];
+ const exposedPages = [
+ { slug: 'about-us', title: 'About Us' },
+ { slug: 'pricing', title: 'Pricing' },
+ ];
+ const result = mergeNavLinks(base, exposedPages);
+ // /page/about-us already exists in base, so only pricing is appended
+ expect(result).toEqual([
+ { href: '/page/about-us', label: 'Existing About' },
+ { href: '/page/pricing', label: 'Pricing' },
+ ]);
+ });
+
+ it('handles empty base links with exposed pages', () => {
+ const result = mergeNavLinks([], [{ slug: 'faq', title: 'FAQ' }]);
+ expect(result).toEqual([{ href: '/page/faq', label: 'FAQ' }]);
+ });
+});
diff --git a/apps/ottabase-template-app-nextjs-homepage/app/page.tsx b/apps/ottabase-template-app-nextjs-homepage/app/(site)/page.tsx
similarity index 100%
rename from apps/ottabase-template-app-nextjs-homepage/app/page.tsx
rename to apps/ottabase-template-app-nextjs-homepage/app/(site)/page.tsx
diff --git a/apps/ottabase-template-app-nextjs-homepage/app/layout-shell.tsx b/apps/ottabase-template-app-nextjs-homepage/app/layout-shell.tsx
index 1cd24f54e..0f7939a6e 100644
--- a/apps/ottabase-template-app-nextjs-homepage/app/layout-shell.tsx
+++ b/apps/ottabase-template-app-nextjs-homepage/app/layout-shell.tsx
@@ -2,13 +2,15 @@
import { ConfigPanel } from '../components/ConfigPanel';
import { SlotRenderer } from '../components/SlotRenderer';
+import type { NavLink } from '../components/variants/navbar/types';
const GITHUB_URL = 'https://github.com/thinkdj/ottabase';
-const NAVBAR_DATA = {
- title: 'Ottabase',
- githubUrl: GITHUB_URL,
-};
+const BASE_NAVBAR_LINKS: NavLink[] = [
+ { href: '/', label: 'Home' },
+ { href: '/about', label: 'About' },
+ { href: '/theme-demo', label: 'Themes' },
+];
const FOOTER_DATA = {
siteName: 'Ottabase',
@@ -21,10 +23,40 @@ const FOOTER_DATA = {
],
};
-export function LayoutShell({ children }: { children: React.ReactNode }) {
+/** Merge exposed CMS pages into navbar links, deduplicating by href. */
+export function mergeNavLinks(baseLinks: NavLink[], exposedPages: { slug: string; title: string }[]): NavLink[] {
+ const pageLinks: NavLink[] = exposedPages.map((p) => ({
+ href: `/page/${p.slug}`,
+ label: p.title,
+ }));
+ const seen = new Set(baseLinks.map((l) => l.href));
+ const merged = [...baseLinks];
+ for (const link of pageLinks) {
+ if (!seen.has(link.href)) {
+ seen.add(link.href);
+ merged.push(link);
+ }
+ }
+ return merged;
+}
+
+export interface LayoutShellProps {
+ children: React.ReactNode;
+ /** Exposed pages from the CMS, passed from server layout. */
+ exposedPages?: { slug: string; title: string }[];
+}
+
+export function LayoutShell({ children, exposedPages = [] }: LayoutShellProps) {
+ const navbarLinks = mergeNavLinks(BASE_NAVBAR_LINKS, exposedPages);
+ const navbarData = {
+ title: 'Ottabase',
+ githubUrl: GITHUB_URL,
+ links: navbarLinks.length > 0 ? navbarLinks : undefined,
+ };
+
return (
<>
-
+
{children}
diff --git a/apps/ottabase-template-app-nextjs-homepage/app/layout.tsx b/apps/ottabase-template-app-nextjs-homepage/app/layout.tsx
index 173a78777..adc3ed82f 100644
--- a/apps/ottabase-template-app-nextjs-homepage/app/layout.tsx
+++ b/apps/ottabase-template-app-nextjs-homepage/app/layout.tsx
@@ -1,5 +1,6 @@
import { buildCriticalCSS } from '@ottabase/brand-engine';
import type { Metadata } from 'next';
+import { fetchExposedPages } from '../lib/api';
import { generateBrandConfig } from '../lib/brand-server';
import './globals.css';
import { LayoutShell } from './layout-shell';
@@ -13,7 +14,7 @@ export const metadata: Metadata = {
authors: [{ name: 'Ottabase' }],
};
-export default function RootLayout({ children }: { children: React.ReactNode }) {
+export default async function RootLayout({ children }: { children: React.ReactNode }) {
// Generate brand config server-side (SSR)
// Note: Using 'light' for initial SSR. BrandProvider will handle dynamic theme switching on client.
const brandConfig = generateBrandConfig('light');
@@ -22,6 +23,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
// Generate critical CSS for SSR (prevents FOUC)
const criticalCSS = buildCriticalCSS(theme);
+ // Fetch exposed CMS pages for navbar
+ const exposedPages = await fetchExposedPages();
+
return (
@@ -34,7 +38,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
- {children}
+ {children}
diff --git a/apps/ottabase-template-app-nextjs-homepage/app/page/[slug]/page-content.tsx b/apps/ottabase-template-app-nextjs-homepage/app/page/[slug]/page-content.tsx
new file mode 100644
index 000000000..8d16b50f8
--- /dev/null
+++ b/apps/ottabase-template-app-nextjs-homepage/app/page/[slug]/page-content.tsx
@@ -0,0 +1,37 @@
+'use client';
+
+import { Blocks, customRenderers, defaultEJSRConfigs } from '@ottabase/ottarenderer';
+import '@ottabase/ottarenderer/styles';
+import type { PageData } from '../../../lib/api';
+
+export function PageContent({ page }: { page: PageData }) {
+ const hasContent = page.content?.blocks && page.content.blocks.length > 0;
+
+ return (
+
+
+ {page.title}
+ {page.excerpt && {page.excerpt}
}
+
+
+ {page.heroImage?.url && (
+
+

+ {page.heroImage.caption && (
+
{page.heroImage.caption}
+ )}
+
+ )}
+
+ {hasContent && (
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/ottabase-template-app-nextjs-homepage/app/page/[slug]/page.tsx b/apps/ottabase-template-app-nextjs-homepage/app/page/[slug]/page.tsx
new file mode 100644
index 000000000..9a04f05d3
--- /dev/null
+++ b/apps/ottabase-template-app-nextjs-homepage/app/page/[slug]/page.tsx
@@ -0,0 +1,26 @@
+import type { Metadata } from 'next';
+import { notFound } from 'next/navigation';
+import { fetchPageBySlug } from '../../../lib/api';
+import { PageContent } from './page-content';
+
+interface PageProps {
+ params: Promise<{ slug: string }>;
+}
+
+export async function generateMetadata({ params }: PageProps): Promise {
+ const { slug } = await params;
+ const page = await fetchPageBySlug(slug);
+ if (!page) return { title: 'Not Found' };
+ return {
+ title: page.seoMeta?.title || page.title,
+ description: page.seoMeta?.description || page.excerpt || undefined,
+ keywords: page.seoMeta?.keywords,
+ };
+}
+
+export default async function CmsPage({ params }: PageProps) {
+ const { slug } = await params;
+ const page = await fetchPageBySlug(slug);
+ if (!page) notFound();
+ return ;
+}
diff --git a/apps/ottabase-template-app-nextjs-homepage/lib/api.ts b/apps/ottabase-template-app-nextjs-homepage/lib/api.ts
new file mode 100644
index 000000000..0d597ccfa
--- /dev/null
+++ b/apps/ottabase-template-app-nextjs-homepage/lib/api.ts
@@ -0,0 +1,86 @@
+/**
+ * API helpers for fetching data from the Ottabase worker backend.
+ *
+ * Requires NEXT_PUBLIC_API_URL to be set (e.g. http://localhost:3004).
+ */
+
+const API_URL = process.env.NEXT_PUBLIC_API_URL ?? '';
+
+/** EditorJS block data shape */
+export interface EditorJSData {
+ time?: number;
+ blocks: Array<{
+ id?: string;
+ type: string;
+ data: Record;
+ }>;
+ version?: string;
+}
+
+/** Public page data returned by /api/blog/pages/by-slug/:slug */
+export interface PageData {
+ id: string;
+ title: string;
+ slug: string;
+ excerpt: string | null;
+ content: EditorJSData | null;
+ contentType: string;
+ status: string;
+ heroImage: {
+ url: string;
+ alt?: string;
+ caption?: string;
+ } | null;
+ seoMeta: {
+ title?: string;
+ description?: string;
+ keywords?: string[];
+ } | null;
+ authorName: string | null;
+ publishedAt: string | null;
+ createdAt: string;
+ updatedAt: string;
+}
+
+/** Exposed page link for homepage navbar */
+export interface ExposedPage {
+ slug: string;
+ title: string;
+}
+
+/**
+ * Fetch a published page by slug from the worker API.
+ * Returns null if not found or on error.
+ */
+export async function fetchPageBySlug(slug: string): Promise {
+ if (!API_URL) return null;
+ try {
+ const res = await fetch(`${API_URL}/api/blog/pages/by-slug/${encodeURIComponent(slug)}`, {
+ next: { revalidate: 60 },
+ });
+ if (!res.ok) return null;
+ const data = await res.json();
+ if (data.contentType !== 'page') return null;
+ return data as PageData;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Fetch exposed pages for the homepage navbar.
+ * Returns an empty array on error so the homepage never hard-fails.
+ */
+export async function fetchExposedPages(): Promise {
+ if (!API_URL) return [];
+ try {
+ const res = await fetch(`${API_URL}/api/blog/pages/exposed`, {
+ next: { revalidate: 60 },
+ });
+ if (!res.ok) return [];
+ const data = await res.json();
+ return Array.isArray(data.exposedPages) ? data.exposedPages : [];
+ } catch {
+ return [];
+ }
+}
diff --git a/apps/ottabase-template-app-nextjs-homepage/package.json b/apps/ottabase-template-app-nextjs-homepage/package.json
index 76ebd4d44..ea453c28f 100644
--- a/apps/ottabase-template-app-nextjs-homepage/package.json
+++ b/apps/ottabase-template-app-nextjs-homepage/package.json
@@ -22,6 +22,7 @@
"@ottabase/brand-engine-react": "workspace:*",
"@ottabase/config": "workspace:*",
"@ottabase/ottalayout": "workspace:*",
+ "@ottabase/ottarenderer": "workspace:*",
"@ottabase/ui-components": "workspace:*",
"@ottabase/ui-shadcn": "workspace:*",
"@radix-ui/react-slot": "^1.1.1",
diff --git a/packages/ottablog/src/__tests__/types.test.ts b/packages/ottablog/src/__tests__/types.test.ts
index 4acc1afd2..98c0a2b4c 100644
--- a/packages/ottablog/src/__tests__/types.test.ts
+++ b/packages/ottablog/src/__tests__/types.test.ts
@@ -1,5 +1,14 @@
import { describe, expect, it } from 'vitest';
-import { calculateReadingTime, extractExcerpt, generateSlug, formatDate, formatShortDate } from '../types';
+import {
+ BLOG_FEED_CONTENT_TYPES,
+ CONTENT_TYPES,
+ calculateReadingTime,
+ extractExcerpt,
+ formatDate,
+ formatShortDate,
+ generateSlug,
+} from '../types';
+import type { ContentType } from '../types';
describe('ottablog helpers', () => {
describe('generateSlug', () => {
@@ -108,4 +117,45 @@ describe('ottablog helpers', () => {
expect(formatted).toMatch(/Dec 25, 2024/);
});
});
+
+ describe('CONTENT_TYPES', () => {
+ it('includes all 6 content types', () => {
+ const keys = Object.keys(CONTENT_TYPES);
+ expect(keys).toEqual(['blog', 'changelog', 'docs', 'news', 'announcement', 'page']);
+ });
+
+ it('page content type has correct metadata', () => {
+ expect(CONTENT_TYPES.page).toEqual({
+ label: 'Page',
+ description: 'Static/marketing page managed via CMS',
+ });
+ });
+
+ it('every ContentType key has label and description', () => {
+ for (const [, meta] of Object.entries(CONTENT_TYPES)) {
+ expect(meta).toHaveProperty('label');
+ expect(meta).toHaveProperty('description');
+ expect(typeof meta.label).toBe('string');
+ expect(typeof meta.description).toBe('string');
+ }
+ });
+ });
+
+ describe('BLOG_FEED_CONTENT_TYPES', () => {
+ it('includes blog, docs, news, announcement', () => {
+ expect(BLOG_FEED_CONTENT_TYPES).toEqual(['blog', 'docs', 'news', 'announcement']);
+ });
+
+ it('excludes changelog and page', () => {
+ expect(BLOG_FEED_CONTENT_TYPES).not.toContain('changelog');
+ expect(BLOG_FEED_CONTENT_TYPES).not.toContain('page');
+ });
+
+ it('is a subset of CONTENT_TYPES keys', () => {
+ const allTypes = Object.keys(CONTENT_TYPES);
+ for (const type of BLOG_FEED_CONTENT_TYPES) {
+ expect(allTypes).toContain(type);
+ }
+ });
+ });
});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4f191026a..e699ee2ee 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -362,6 +362,9 @@ importers:
'@ottabase/ottalayout':
specifier: workspace:*
version: link:../../packages/ottalayout
+ '@ottabase/ottarenderer':
+ specifier: workspace:*
+ version: link:../../packages/ottarenderer
'@ottabase/ui-components':
specifier: workspace:*
version: link:../../packages/ui-components
From 72478b60ffcf079ef11bf2d4e32b0ed8749b2112 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 30 Mar 2026 18:07:12 +0000
Subject: [PATCH 04/19] fix: correct import path in (site)/page.tsx for route
group, update ottablog README docs
Agent-Logs-Url: https://github.com/thinkdj/ottabase/sessions/046b035e-9b57-4779-b4ad-6fa561dfc03b
---
.../app/(site)/page.tsx | 2 +-
packages/ottablog/README.md | 23 ++++++++++++++++++-
.../ottablog/src/__tests__/models.test.ts | 23 +++++++++++++++++++
3 files changed, 46 insertions(+), 2 deletions(-)
diff --git a/apps/ottabase-template-app-nextjs-homepage/app/(site)/page.tsx b/apps/ottabase-template-app-nextjs-homepage/app/(site)/page.tsx
index 4dfb59eb5..e772ba669 100644
--- a/apps/ottabase-template-app-nextjs-homepage/app/(site)/page.tsx
+++ b/apps/ottabase-template-app-nextjs-homepage/app/(site)/page.tsx
@@ -1,7 +1,7 @@
'use client';
import { Github, Palette, Rocket } from 'lucide-react';
-import { SlotRenderer } from '../components/SlotRenderer';
+import { SlotRenderer } from '../../components/SlotRenderer';
/**
* Homepage content data — single source of truth.
diff --git a/packages/ottablog/README.md b/packages/ottablog/README.md
index 6bc75504e..445ea7212 100644
--- a/packages/ottablog/README.md
+++ b/packages/ottablog/README.md
@@ -102,7 +102,7 @@ const post = await Post.create({
content: {
/* EditorJS JSON */
},
- contentType: 'blog', // blog, changelog, docs, news, announcement
+ contentType: 'blog', // blog, changelog, docs, news, announcement, page
status: 'published', // draft, published, archived, scheduled
categoryId: 'cat-123',
seriesId: 'series-123',
@@ -164,6 +164,7 @@ post.generateExcerpt(); // Auto-generate from content
- `readingTimeMinutes`, `wordCount` - Auto-calculated stats
- `viewCount` - View/hit counter (incremented via `trackView()`)
- `isFeatured` - Pin to top
+- `exposeToHomepage` - Show page link in marketing homepage navbar (only for `page` content type)
- `allowComments` - Enable comments
- `publishAt`, `publishedAt`, `postedAt` - Dates
- `appId` - Multi-app identifier
@@ -375,6 +376,7 @@ const customCats = await PostCategory.where({
- `docs` - Documentation
- `changelog` - Change logs
- `announcement` - Announcements
+- `page` - Static/marketing pages managed via CMS
- Custom types supported
## Relationships
@@ -508,6 +510,25 @@ GET /api/blog/posts/by-slug/{slug}
Returns a single post with tags, categories (via junction), and series title. View tracking is opt-in (call
`trackView()` explicitly to avoid D1 write costs per page view).
+> **Note:** Returns 404 for `changelog` and `page` content types — they have dedicated endpoints.
+
+### Page by Slug
+
+```
+GET /api/blog/pages/by-slug/{slug}
+```
+
+Returns a published page (`contentType: 'page'`). Used by the marketing homepage to render CMS-managed static pages.
+
+### Exposed Pages
+
+```
+GET /api/blog/pages/exposed
+```
+
+Returns `{ exposedPages: [{ slug, title }] }` — all published pages with `exposeToHomepage: true`, sorted by title. Used
+by the marketing homepage to build navbar links. Returns `{ exposedPages: [] }` on failure.
+
### Related Posts
```
diff --git a/packages/ottablog/src/__tests__/models.test.ts b/packages/ottablog/src/__tests__/models.test.ts
index bbe7e0bab..7ca93c736 100644
--- a/packages/ottablog/src/__tests__/models.test.ts
+++ b/packages/ottablog/src/__tests__/models.test.ts
@@ -65,6 +65,29 @@ describe('ottablog models', () => {
expect(fields.viewCount.editable).toBe(false);
expect(fields.viewCount.sortable).toBe(true);
});
+
+ it('should have exposeToHomepage in casts', () => {
+ expect(Post.casts).toHaveProperty('exposeToHomepage');
+ expect(Post.casts.exposeToHomepage).toBe('boolean');
+ });
+
+ it('should have exposeToHomepage in writable fields', () => {
+ expect(Post.writable.create).toContain('exposeToHomepage');
+ expect(Post.writable.update).toContain('exposeToHomepage');
+ });
+
+ it('should have exposeToHomepage field metadata', () => {
+ const fields = Post.getFields();
+ expect(fields).toHaveProperty('exposeToHomepage');
+ expect(fields.exposeToHomepage.type).toBe('boolean');
+ expect(fields.exposeToHomepage.editable).toBe(true);
+ expect(fields.exposeToHomepage.filterable).toBe(true);
+ });
+
+ it('should have exposeToHomepage default to false', () => {
+ const defaults = (Post as any).defaults;
+ expect(defaults.exposeToHomepage).toBe(false);
+ });
});
describe('PostCategory model', () => {
From 4db1801cd2fd6fc788ef903eaa326078c92a56db Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 30 Mar 2026 18:43:09 +0000
Subject: [PATCH 05/19] feat: add homepage DB models (Section, Feature, Action,
DisplaySettings) + GET /api/homepage/data
Agent-Logs-Url: https://github.com/thinkdj/ottabase/sessions/b9bee764-b52d-4fdc-a63f-1aa2b58b0afb
---
.../app/layout-shell.tsx | 73 ++++++-
.../app/layout.tsx | 10 +-
.../lib/api.ts | 60 ++++++
.../ottabase/db/schemas-helper.ts | 12 ++
.../ottabase/models/HomepageAction.schema.ts | 34 ++++
.../ottabase/models/HomepageAction.ts | 104 ++++++++++
.../models/HomepageDisplaySettings.schema.ts | 38 ++++
.../models/HomepageDisplaySettings.ts | 101 ++++++++++
.../ottabase/models/HomepageFeature.schema.ts | 30 +++
.../ottabase/models/HomepageFeature.ts | 92 +++++++++
.../ottabase/models/HomepageSection.schema.ts | 40 ++++
.../ottabase/models/HomepageSection.ts | 108 ++++++++++
.../worker/lib/db-utils.ts | 6 +-
.../worker/routes/homepage.ts | 190 ++++++++++++++++++
.../worker/routes/router.ts | 6 +
15 files changed, 897 insertions(+), 7 deletions(-)
create mode 100644 apps/ottabase-template-app-tanstack/ottabase/models/HomepageAction.schema.ts
create mode 100644 apps/ottabase-template-app-tanstack/ottabase/models/HomepageAction.ts
create mode 100644 apps/ottabase-template-app-tanstack/ottabase/models/HomepageDisplaySettings.schema.ts
create mode 100644 apps/ottabase-template-app-tanstack/ottabase/models/HomepageDisplaySettings.ts
create mode 100644 apps/ottabase-template-app-tanstack/ottabase/models/HomepageFeature.schema.ts
create mode 100644 apps/ottabase-template-app-tanstack/ottabase/models/HomepageFeature.ts
create mode 100644 apps/ottabase-template-app-tanstack/ottabase/models/HomepageSection.schema.ts
create mode 100644 apps/ottabase-template-app-tanstack/ottabase/models/HomepageSection.ts
create mode 100644 apps/ottabase-template-app-tanstack/worker/routes/homepage.ts
diff --git a/apps/ottabase-template-app-nextjs-homepage/app/layout-shell.tsx b/apps/ottabase-template-app-nextjs-homepage/app/layout-shell.tsx
index 0f7939a6e..11f6b002a 100644
--- a/apps/ottabase-template-app-nextjs-homepage/app/layout-shell.tsx
+++ b/apps/ottabase-template-app-nextjs-homepage/app/layout-shell.tsx
@@ -2,6 +2,7 @@
import { ConfigPanel } from '../components/ConfigPanel';
import { SlotRenderer } from '../components/SlotRenderer';
+import type { HomepageDataPayload } from '../lib/api';
import type { NavLink } from '../components/variants/navbar/types';
const GITHUB_URL = 'https://github.com/thinkdj/ottabase';
@@ -40,25 +41,93 @@ export function mergeNavLinks(baseLinks: NavLink[], exposedPages: { slug: string
return merged;
}
+/**
+ * Build slot data overrides from the homepage API sections.
+ * Maps section data to the SlotRenderer data contracts.
+ */
+function buildSlotDataFromSections(sections: HomepageDataPayload['sections']) {
+ const dataBySlot: Record> = {};
+ for (const section of sections) {
+ const slot = section.slot;
+ if (slot === 'hero') {
+ dataBySlot[slot] = {
+ title: section.title ?? '',
+ subtitle: section.subtitle ?? undefined,
+ body: section.body ?? undefined,
+ actions: section.actions.map((a) => ({
+ label: a.label,
+ href: a.href,
+ variant: a.variant ?? 'default',
+ external: a.external,
+ })),
+ };
+ } else if (slot === 'features') {
+ dataBySlot[slot] = {
+ title: section.title ?? undefined,
+ features: section.features,
+ };
+ } else if (slot === 'cta') {
+ dataBySlot[slot] = {
+ title: section.title ?? '',
+ description: section.subtitle ?? undefined,
+ actions: section.actions.map((a) => ({
+ label: a.label,
+ href: a.href,
+ variant: a.variant ?? 'default',
+ external: a.external,
+ })),
+ };
+ } else if (slot === 'about') {
+ dataBySlot[slot] = {
+ title: section.title ?? undefined,
+ description: section.subtitle ?? undefined,
+ githubUrl: section.githubUrl ?? undefined,
+ };
+ } else if (slot === 'navbar') {
+ dataBySlot[slot] = {
+ title: section.title ?? 'Ottabase',
+ githubUrl: section.githubUrl ?? undefined,
+ };
+ } else if (slot === 'footer') {
+ dataBySlot[slot] = {
+ siteName: section.title ?? 'Ottabase',
+ tagline: section.subtitle ?? undefined,
+ };
+ }
+ }
+ return dataBySlot;
+}
+
export interface LayoutShellProps {
children: React.ReactNode;
/** Exposed pages from the CMS, passed from server layout. */
exposedPages?: { slug: string; title: string }[];
+ /** Full homepage data payload from the API. */
+ homepageData?: HomepageDataPayload;
}
-export function LayoutShell({ children, exposedPages = [] }: LayoutShellProps) {
+export function LayoutShell({ children, exposedPages = [], homepageData }: LayoutShellProps) {
const navbarLinks = mergeNavLinks(BASE_NAVBAR_LINKS, exposedPages);
+
+ // Build slot data overrides from DB sections (if available)
+ const dbSlotData = homepageData?.sections?.length ? buildSlotDataFromSections(homepageData.sections) : {};
+
const navbarData = {
title: 'Ottabase',
githubUrl: GITHUB_URL,
+ // Override with DB data if present (title/githubUrl from navbar section)
+ ...(dbSlotData.navbar ?? {}),
+ // Always include merged links (DB navbar data should not override links)
links: navbarLinks.length > 0 ? navbarLinks : undefined,
};
+ const footerData = dbSlotData.footer ? { ...FOOTER_DATA, ...dbSlotData.footer } : FOOTER_DATA;
+
return (
<>
{children}
-
+
>
);
diff --git a/apps/ottabase-template-app-nextjs-homepage/app/layout.tsx b/apps/ottabase-template-app-nextjs-homepage/app/layout.tsx
index adc3ed82f..7c01d00a5 100644
--- a/apps/ottabase-template-app-nextjs-homepage/app/layout.tsx
+++ b/apps/ottabase-template-app-nextjs-homepage/app/layout.tsx
@@ -1,6 +1,6 @@
import { buildCriticalCSS } from '@ottabase/brand-engine';
import type { Metadata } from 'next';
-import { fetchExposedPages } from '../lib/api';
+import { fetchHomepageData } from '../lib/api';
import { generateBrandConfig } from '../lib/brand-server';
import './globals.css';
import { LayoutShell } from './layout-shell';
@@ -23,8 +23,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
// Generate critical CSS for SSR (prevents FOUC)
const criticalCSS = buildCriticalCSS(theme);
- // Fetch exposed CMS pages for navbar
- const exposedPages = await fetchExposedPages();
+ // Fetch full homepage data (sections, display settings, exposed pages) from the worker API
+ const homepageData = await fetchHomepageData();
return (
@@ -38,7 +38,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo
- {children}
+
+ {children}
+