diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
new file mode 100644
index 0000000..47ff54f
--- /dev/null
+++ b/.github/workflows/pages.yml
@@ -0,0 +1,64 @@
+name: Pages Demo
+
+on:
+ push:
+ branches:
+ - main
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: pages
+ cancel-in-progress: true
+
+jobs:
+ build:
+ name: Build demo
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ env:
+ VITE_YEOLLIN_BASE_PATH: /yeollin-cms
+ VITE_YEOLLIN_DEMO: 'true'
+
+ steps:
+ - uses: actions/checkout@v7
+
+ - uses: actions-rust-lang/setup-rust-toolchain@v1
+
+ - uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Install dependencies
+ run: bun install --frozen-lockfile
+
+ - name: Build interactive demo
+ working-directory: apps/example-app
+ run: cargo run -p yeollin-cli -- build --skip-backend
+
+ - name: Disable Jekyll processing
+ run: touch apps/example-app/.yeollin/app/out/.nojekyll
+
+ - uses: actions/configure-pages@v6
+
+ - name: Upload Pages artifact
+ uses: actions/upload-pages-artifact@v5
+ with:
+ path: apps/example-app/.yeollin/app/out
+
+ deploy:
+ name: Deploy demo
+ needs: build
+ runs-on: ubuntu-latest
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+
+ steps:
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v5
diff --git a/README.md b/README.md
index 418075e..5e002d4 100644
--- a/README.md
+++ b/README.md
@@ -2,11 +2,13 @@
Yeollin CMS is a Tauri-inspired, plugin-based CMS *framework* rather than a finished CMS product. A plugin is a single Rust crate that bundles its Axum/Vespera API routes, a vinext (Vite + RSC, Next-compatible) frontend under `app/`, and optionally sea-orm models with vespertide JSON migrations. The `yeollin-cli` binary assembles the registered plugins into one application: it extracts the frontend template, merges each plugin's pages, statically exports the result, and embeds it into the release binary via `include_dir!`, so a deployment is a single executable that serves both the API and the UI.
+Try the serverless [interactive demo](https://dev-five-git.github.io/yeollin-cms/). It includes the example app's plugins and keeps all mock data inside the browser tab.
+
## Status
> **Status: v0.1, pre-release. APIs change without notice. Not production ready.**
>
-> This repository is private and under active development. Treat every public
+> This project is open source and under active development. Treat every public
> interface, file layout, and CLI flag as unstable.
## Prerequisites
diff --git a/packages/app/src/app/(auth)/settings/page.tsx b/packages/app/src/app/(auth)/settings/page.tsx
index 83e2423..76a30c1 100644
--- a/packages/app/src/app/(auth)/settings/page.tsx
+++ b/packages/app/src/app/(auth)/settings/page.tsx
@@ -1,8 +1,9 @@
import { Box, Flex, Grid, Text, VStack } from '@devup-ui/react'
import { readFile } from 'fs/promises'
-import Link from 'next/link'
import path from 'path'
+import { AppLink } from '@/components/common/AppLink'
+
interface PluginInfo {
description?: string
name: string
@@ -41,11 +42,7 @@ export default async function SettingsPage() {
{configurablePlugins.map((plugin) => (
-
+
-
+
))}
-
+
-
+
diff --git a/packages/app/src/app/(auth)/settings/plugins/page.tsx b/packages/app/src/app/(auth)/settings/plugins/page.tsx
index b101540..2f90993 100644
--- a/packages/app/src/app/(auth)/settings/plugins/page.tsx
+++ b/packages/app/src/app/(auth)/settings/plugins/page.tsx
@@ -1,8 +1,9 @@
import { Box, Flex, Grid, Text, VStack } from '@devup-ui/react'
import { readFile } from 'fs/promises'
-import Link from 'next/link'
import path from 'path'
+import { AppLink } from '@/components/common/AppLink'
+
interface PluginInfo {
name: string
version: string
@@ -86,7 +87,7 @@ export default async function PluginsPage() {
-
+
Settings
-
+
/
Plugins
diff --git a/packages/app/src/app/(guest)/signin/page.tsx b/packages/app/src/app/(guest)/signin/page.tsx
index 7033b90..27b9a97 100644
--- a/packages/app/src/app/(guest)/signin/page.tsx
+++ b/packages/app/src/app/(guest)/signin/page.tsx
@@ -4,6 +4,8 @@ import { Box, Flex, Text, VStack } from '@devup-ui/react'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
+import { DEMO_MODE, demoPageHref } from '@/lib/demo-path'
+
export default function SignInPage() {
const router = useRouter()
const [username, setUsername] = useState('')
@@ -37,8 +39,12 @@ export default function SignInPage() {
document.cookie = `refresh_token=${data.refresh_token}; path=/; max-age=${60 * 60 * 24 * 7}` // 7 days
// Redirect to dashboard
- router.push('/')
- router.refresh()
+ if (DEMO_MODE) {
+ window.location.assign(demoPageHref('/'))
+ } else {
+ router.push('/')
+ router.refresh()
+ }
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred')
} finally {
diff --git a/packages/app/src/components/common/AppLink.tsx b/packages/app/src/components/common/AppLink.tsx
new file mode 100644
index 0000000..3f39333
--- /dev/null
+++ b/packages/app/src/components/common/AppLink.tsx
@@ -0,0 +1,29 @@
+'use client'
+
+import Link from 'next/link'
+import type { ReactNode } from 'react'
+
+import { DEMO_MODE, demoPageHref } from '@/lib/demo-path'
+
+interface AppLinkProps {
+ children: ReactNode
+ href: string
+}
+
+export function AppLink({ children, href }: AppLinkProps) {
+ const style = { textDecoration: 'none' }
+
+ if (DEMO_MODE) {
+ return (
+
+ {children}
+
+ )
+ }
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/packages/app/src/components/content/ContentCollectionsHub.tsx b/packages/app/src/components/content/ContentCollectionsHub.tsx
index 27b6a28..f9087d2 100644
--- a/packages/app/src/components/content/ContentCollectionsHub.tsx
+++ b/packages/app/src/components/content/ContentCollectionsHub.tsx
@@ -1,5 +1,6 @@
import { Box, Grid, Text, VStack } from '@devup-ui/react'
-import Link from 'next/link'
+
+import { AppLink } from '@/components/common/AppLink'
interface ContentCollectionLink {
label: string
@@ -38,11 +39,7 @@ export function ContentCollectionsHub({
gridTemplateColumns={['1fr', 'repeat(2, minmax(0, 1fr))']}
>
{collections.map((collection) => (
-
+
-
+
))}
diff --git a/packages/app/src/components/layout/Header.tsx b/packages/app/src/components/layout/Header.tsx
index 6c477c6..de257c2 100644
--- a/packages/app/src/components/layout/Header.tsx
+++ b/packages/app/src/components/layout/Header.tsx
@@ -3,6 +3,10 @@
import { Box, Flex, Text } from '@devup-ui/react'
import { useRouter } from 'next/navigation'
+import { resetMockApi } from '@/lib/mock-api'
+
+const DEMO_MODE = import.meta.env.VITE_YEOLLIN_DEMO === 'true'
+
/**
* Header component with logout functionality.
* Requires 'use client' due to cookie manipulation and useRouter.
@@ -10,6 +14,11 @@ import { useRouter } from 'next/navigation'
export function Header() {
const router = useRouter()
+ const handleReset = () => {
+ resetMockApi()
+ window.location.reload()
+ }
+
const handleLogout = () => {
// Clear auth cookies
document.cookie = 'access_token=; path=/; max-age=0'
@@ -26,21 +35,39 @@ export function Header() {
borderBottom="1px solid $border"
display="flex"
h="56px"
- justifyContent="flex-end"
+ justifyContent="space-between"
px={6}
w="100%"
>
+ {DEMO_MODE ? (
+
+
+
+ Interactive demo
+
+
+
+ Data stays in this browser tab
+
+
+ ) : (
+
+ )}
- Logout
+ {DEMO_MODE ? 'Reset demo' : 'Logout'}
)
diff --git a/packages/app/src/components/layout/NavItem.tsx b/packages/app/src/components/layout/NavItem.tsx
index e43e665..9570c98 100644
--- a/packages/app/src/components/layout/NavItem.tsx
+++ b/packages/app/src/components/layout/NavItem.tsx
@@ -1,9 +1,11 @@
'use client'
import { Box, Flex, Text } from '@devup-ui/react'
-import Link from 'next/link'
import { usePathname } from 'next/navigation'
+import { AppLink } from '@/components/common/AppLink'
+import { normalizeDemoPath } from '@/lib/demo-path'
+
interface MenuItem {
id: string
label: string
@@ -20,14 +22,14 @@ interface NavItemProps {
* Requires 'use client' due to usePathname hook.
*/
export function NavItem({ item }: NavItemProps) {
- const pathname = usePathname()
+ const pathname = normalizeDemoPath(usePathname())
const isActive = pathname === item.path
const hasChildren = item.children && item.children.length > 0
const isChildActive = item.children?.some((child) => pathname === child.path)
return (
-
+
{item.label}
-
+
{hasChildren && (isActive || isChildActive) && (
{item.children!.map((child) => (
-
+
{child.label}
-
+
))}
)}
diff --git a/packages/app/src/components/providers.tsx b/packages/app/src/components/providers.tsx
index 08c8ee6..bad8e13 100644
--- a/packages/app/src/components/providers.tsx
+++ b/packages/app/src/components/providers.tsx
@@ -3,6 +3,10 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState } from 'react'
+import { installMockApi } from '@/lib/mock-api'
+
+if (import.meta.env.VITE_YEOLLIN_DEMO === 'true') installMockApi()
+
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
diff --git a/packages/app/src/lib/demo-path.ts b/packages/app/src/lib/demo-path.ts
new file mode 100644
index 0000000..06f5d8e
--- /dev/null
+++ b/packages/app/src/lib/demo-path.ts
@@ -0,0 +1,21 @@
+export const DEMO_MODE = import.meta.env.VITE_YEOLLIN_DEMO === 'true'
+
+const DEMO_BASE_PATH = import.meta.env.VITE_YEOLLIN_BASE_PATH ?? ''
+
+export function demoPageHref(path: string): string {
+ if (!DEMO_MODE) return path
+ if (path === '/') return `${DEMO_BASE_PATH}/`
+
+ return `${DEMO_BASE_PATH}${path}.html`
+}
+
+export function normalizeDemoPath(pathname: string): string {
+ if (!DEMO_MODE) return pathname
+
+ const withoutBase = pathname.startsWith(DEMO_BASE_PATH)
+ ? pathname.slice(DEMO_BASE_PATH.length)
+ : pathname
+ const withoutHtml = withoutBase.replace(/\.html$/, '')
+
+ return withoutHtml || '/'
+}
diff --git a/packages/app/src/lib/mock-api.ts b/packages/app/src/lib/mock-api.ts
new file mode 100644
index 0000000..72e0684
--- /dev/null
+++ b/packages/app/src/lib/mock-api.ts
@@ -0,0 +1,691 @@
+interface DemoState {
+ auditEvents: Array>
+ contentEntries: Array>
+ deliveries: Array>
+ forms: Array>
+ media: Array>
+ memos: Array>
+ redirects: Array>
+ settings: Record>
+ submissions: Record>>
+ users: Array>
+ webhooks: Array>
+}
+
+const DEMO_NOW = '2026-09-01T04:30:00.000Z'
+const DEMO_BASE_PATH = import.meta.env.VITE_YEOLLIN_BASE_PATH ?? ''
+
+let installed = false
+let sequence = 100
+let state = createDemoState()
+
+function id(prefix: string): string {
+ sequence += 1
+ return `${prefix}-${sequence}`
+}
+
+function demoUrl(path: string): string {
+ return `${DEMO_BASE_PATH}${path}.html`
+}
+
+function imageData(label: string, start: string, end: string): string {
+ const svg = ``
+ return `data:image/svg+xml,${encodeURIComponent(svg)}`
+}
+
+function createDemoState(): DemoState {
+ const formFields = [
+ {
+ id: 'email',
+ kind: 'email',
+ label: 'Work email',
+ options: [],
+ placeholder: 'you@example.com',
+ required: true,
+ },
+ {
+ id: 'message',
+ kind: 'textarea',
+ label: 'How can we help?',
+ options: [],
+ placeholder: 'Tell us about your project',
+ required: true,
+ },
+ ]
+
+ return {
+ auditEvents: [
+ {
+ createdAt: '2026-09-01T04:18:00.000Z',
+ id: 14,
+ name: 'content.pages.published',
+ payload: { actor: 'admin', slug: 'welcome-to-yeollin' },
+ },
+ {
+ createdAt: '2026-09-01T03:42:00.000Z',
+ id: 13,
+ name: 'auth.user.role_changed',
+ payload: { actor: 'admin', role: 'user', username: 'editor' },
+ },
+ {
+ createdAt: '2026-08-31T23:10:00.000Z',
+ id: 12,
+ name: 'media.asset.created',
+ payload: { mimeType: 'image/svg+xml', originalName: 'open-cms.svg' },
+ },
+ ],
+ contentEntries: [
+ {
+ author: 'admin',
+ collection: 'pages',
+ createdAt: '2026-08-28T01:00:00.000Z',
+ fields: {
+ body: 'Yeollin CMS keeps backend routes, frontend pages, migrations, and metadata together in plugin crates.',
+ excerpt: 'A quick tour of an open and extensible Rust CMS.',
+ heroImage: 'media:11111111111111111111111111111111',
+ },
+ id: 'page-welcome',
+ publishedAt: '2026-08-29T02:00:00.000Z',
+ slug: 'welcome-to-yeollin',
+ status: 'published',
+ title: 'Welcome to Yeollin CMS',
+ updatedAt: '2026-09-01T04:18:00.000Z',
+ },
+ {
+ author: 'editor',
+ collection: 'pages',
+ createdAt: '2026-08-30T08:15:00.000Z',
+ fields: {
+ body: 'This draft demonstrates typed content fields and the publish workflow.',
+ excerpt: 'An unpublished page ready for editorial review.',
+ heroImage: null,
+ },
+ id: 'page-roadmap',
+ publishedAt: null,
+ slug: 'product-roadmap',
+ status: 'draft',
+ title: 'Product roadmap',
+ updatedAt: '2026-09-01T01:12:00.000Z',
+ },
+ ],
+ deliveries: [
+ {
+ attempts: 1,
+ createdAt: '2026-09-01T04:18:01.000Z',
+ deliveredAt: '2026-09-01T04:18:02.000Z',
+ eventId: 14,
+ eventName: 'content.pages.published',
+ id: 'delivery-1',
+ lastError: null,
+ maxAttempts: 5,
+ responseStatus: 204,
+ status: 'delivered',
+ updatedAt: '2026-09-01T04:18:02.000Z',
+ webhookId: 'webhook-1',
+ },
+ {
+ attempts: 3,
+ createdAt: '2026-09-01T03:42:01.000Z',
+ deliveredAt: null,
+ eventId: 13,
+ eventName: 'auth.user.role_changed',
+ id: 'delivery-2',
+ lastError: 'The demo endpoint returned HTTP 503.',
+ maxAttempts: 5,
+ responseStatus: 503,
+ status: 'dead_letter',
+ updatedAt: '2026-09-01T03:45:01.000Z',
+ webhookId: 'webhook-1',
+ },
+ ],
+ forms: [
+ {
+ createdAt: '2026-08-27T09:00:00.000Z',
+ createdBy: 'admin',
+ description: 'Collect questions from prospective Yeollin users.',
+ enabled: true,
+ fields: formFields,
+ id: 'contact-form',
+ maxSubmissionsPerHour: 100,
+ name: 'Contact us',
+ successMessage: 'Thanks - we will get back to you soon.',
+ updatedAt: '2026-08-31T11:25:00.000Z',
+ },
+ ],
+ media: [
+ {
+ createdAt: '2026-08-31T23:10:00.000Z',
+ id: '11111111111111111111111111111111',
+ mimeType: 'image/svg+xml',
+ originalName: 'open-cms.svg',
+ reference: 'media:11111111111111111111111111111111',
+ sizeBytes: 18432,
+ uploadedBy: 'admin',
+ url: imageData('OPEN CMS', '#0070f3', '#7c3aed'),
+ },
+ {
+ createdAt: '2026-08-30T07:40:00.000Z',
+ id: '22222222222222222222222222222222',
+ mimeType: 'image/svg+xml',
+ originalName: 'plugin-workspace.svg',
+ reference: 'media:22222222222222222222222222222222',
+ sizeBytes: 22104,
+ uploadedBy: 'editor',
+ url: imageData('PLUGIN WORKSPACE', '#0f766e', '#06b6d4'),
+ },
+ ],
+ memos: [
+ {
+ content:
+ 'The GitHub Pages demo runs entirely in the browser. Try creating, editing, and deleting this memo.',
+ createdAt: '2026-09-01T02:10:00.000Z',
+ id: 1,
+ title: 'Welcome to the interactive demo',
+ updatedAt: '2026-09-01T02:10:00.000Z',
+ },
+ {
+ content:
+ 'Plugins package Axum routes and vinext UI together in one Rust crate.',
+ createdAt: '2026-08-31T09:20:00.000Z',
+ id: 2,
+ title: 'Plugin architecture',
+ updatedAt: '2026-08-31T09:20:00.000Z',
+ },
+ ],
+ redirects: [
+ {
+ createdAt: '2026-08-29T04:00:00.000Z',
+ createdBy: 'admin',
+ destinationPath: '/welcome-to-yeollin',
+ enabled: true,
+ id: 'redirect-1',
+ sourcePath: '/getting-started',
+ updatedAt: '2026-08-29T04:00:00.000Z',
+ },
+ {
+ createdAt: '2026-08-30T05:00:00.000Z',
+ createdBy: 'admin',
+ destinationPath: 'https://github.com/dev-five-git/yeollin-cms',
+ enabled: false,
+ id: 'redirect-2',
+ sourcePath: '/source',
+ updatedAt: '2026-08-31T05:00:00.000Z',
+ },
+ ],
+ settings: {
+ '/api/example-plugin/settings': {
+ homepageMessage: 'Welcome to the Yeollin CMS demo',
+ maintenanceMode: false,
+ },
+ },
+ submissions: {
+ 'contact-form': [
+ {
+ createdAt: '2026-09-01T00:20:00.000Z',
+ fields: formFields,
+ formId: 'contact-form',
+ formName: 'Contact us',
+ id: 'submission-1',
+ values: {
+ email: 'hello@example.com',
+ message: 'Can I build a multilingual publication with Yeollin?',
+ },
+ },
+ ],
+ },
+ users: [
+ {
+ createdAt: '2026-08-24T01:00:00.000Z',
+ id: 1,
+ role: 'admin',
+ username: 'admin',
+ },
+ {
+ createdAt: '2026-08-28T06:30:00.000Z',
+ id: 2,
+ role: 'user',
+ username: 'editor',
+ },
+ ],
+ webhooks: [
+ {
+ allowPrivateNetworks: false,
+ createdAt: '2026-08-26T03:00:00.000Z',
+ enabled: true,
+ eventNames: ['content.pages.published', 'auth.user.role_changed'],
+ hasSecret: true,
+ id: 'webhook-1',
+ name: 'Editorial automation',
+ timeoutSeconds: 5,
+ updatedAt: '2026-08-31T08:20:00.000Z',
+ url: 'https://example.com/hooks/yeollin',
+ },
+ ],
+ }
+}
+
+function json(value: unknown, status = 200): Response {
+ return new Response(JSON.stringify(value), {
+ headers: { 'Content-Type': 'application/json' },
+ status,
+ })
+}
+
+function bodyOf(init?: RequestInit): Record {
+ if (typeof init?.body !== 'string') return {}
+ try {
+ const value = JSON.parse(init.body) as unknown
+ return typeof value === 'object' && value !== null
+ ? (value as Record)
+ : {}
+ } catch {
+ return {}
+ }
+}
+
+function pageOf(items: T[], url: URL, defaultPageSize: number) {
+ const page = Math.max(1, Number(url.searchParams.get('page') ?? 1))
+ const pageSize = Math.max(
+ 1,
+ Number(url.searchParams.get('pageSize') ?? defaultPageSize),
+ )
+ const start = (page - 1) * pageSize
+ return { items: items.slice(start, start + pageSize), page, pageSize }
+}
+
+function handleUsers(path: string, method: string, init?: RequestInit) {
+ if (path === '/api/auth/login' && method === 'POST') {
+ return json({
+ access_token: 'yeollin-demo-access-token',
+ expires_in: 3600,
+ refresh_token: 'yeollin-demo-refresh-token',
+ })
+ }
+ if (path === '/api/auth/users' && method === 'GET') {
+ return json({ total: state.users.length, users: state.users })
+ }
+ if (path === '/api/auth/users' && method === 'POST') {
+ const body = bodyOf(init)
+ state.users.push({
+ createdAt: DEMO_NOW,
+ id: sequence + 1,
+ role: body.role === 'admin' ? 'admin' : 'user',
+ username: String(body.username ?? 'new-user'),
+ })
+ sequence += 1
+ return json(state.users.at(-1), 201)
+ }
+ const match = path.match(/^\/api\/auth\/users\/(\d+)(\/password)?$/)
+ if (!match) return null
+ const userId = Number(match[1])
+ const user = state.users.find((entry) => entry.id === userId)
+ if (!user) return json({ error: 'User not found.' }, 404)
+ if (match[2] === '/password' && method === 'POST') return json({ ok: true })
+ if (method === 'PATCH') Object.assign(user, bodyOf(init))
+ if (method === 'DELETE')
+ state.users = state.users.filter((item) => item !== user)
+ return json(user)
+}
+
+function handleMemos(path: string, method: string, init?: RequestInit) {
+ if (path === '/api/example-memo-plugin' && method === 'GET') {
+ return json({ memos: state.memos })
+ }
+ if (path === '/api/example-memo-plugin' && method === 'POST') {
+ const memo = {
+ ...bodyOf(init),
+ createdAt: DEMO_NOW,
+ id: sequence + 1,
+ updatedAt: DEMO_NOW,
+ }
+ sequence += 1
+ state.memos.unshift(memo)
+ return json(memo, 201)
+ }
+ const match = path.match(/^\/api\/example-memo-plugin\/(\d+)$/)
+ if (!match) return null
+ const memo = state.memos.find((entry) => entry.id === Number(match[1]))
+ if (!memo) return json({ error: 'Memo not found.' }, 404)
+ if (method === 'PATCH')
+ Object.assign(memo, bodyOf(init), { updatedAt: DEMO_NOW })
+ if (method === 'DELETE')
+ state.memos = state.memos.filter((item) => item !== memo)
+ return json(memo)
+}
+
+function handleRedirects(path: string, method: string, init?: RequestInit) {
+ if (path === '/api/redirects' && method === 'GET') {
+ return json({ redirects: state.redirects })
+ }
+ if (path === '/api/redirects' && method === 'POST') {
+ const redirect = {
+ ...bodyOf(init),
+ createdAt: DEMO_NOW,
+ createdBy: 'demo-admin',
+ id: id('redirect'),
+ updatedAt: DEMO_NOW,
+ }
+ state.redirects.unshift(redirect)
+ return json(redirect, 201)
+ }
+ const match = path.match(/^\/api\/redirects\/([^/]+)$/)
+ if (!match) return null
+ const redirect = state.redirects.find((entry) => entry.id === match[1])
+ if (!redirect) return json({ error: 'Redirect not found.' }, 404)
+ if (method === 'PUT')
+ Object.assign(redirect, bodyOf(init), { updatedAt: DEMO_NOW })
+ if (method === 'DELETE') {
+ state.redirects = state.redirects.filter((item) => item !== redirect)
+ }
+ return json(redirect)
+}
+
+function handleForms(path: string, method: string, init?: RequestInit) {
+ if (path === '/api/forms' && method === 'GET')
+ return json({ forms: state.forms })
+ if (path === '/api/forms' && method === 'POST') {
+ const form = {
+ ...bodyOf(init),
+ createdAt: DEMO_NOW,
+ createdBy: 'demo-admin',
+ id: id('form'),
+ updatedAt: DEMO_NOW,
+ }
+ state.forms.unshift(form)
+ return json(form, 201)
+ }
+ const submissions = path.match(/^\/api\/forms\/([^/]+)\/submissions$/)
+ if (submissions && method === 'GET') {
+ const items = state.submissions[submissions[1]] ?? []
+ return json({
+ page: 1,
+ pageSize: 50,
+ submissions: items,
+ total: items.length,
+ })
+ }
+ const match = path.match(/^\/api\/forms\/([^/]+)$/)
+ if (!match) return null
+ const form = state.forms.find((entry) => entry.id === match[1])
+ if (!form) return json({ error: 'Form not found.' }, 404)
+ if (method === 'PUT')
+ Object.assign(form, bodyOf(init), { updatedAt: DEMO_NOW })
+ if (method === 'DELETE') {
+ state.forms = state.forms.filter((item) => item !== form)
+ delete state.submissions[match[1]]
+ }
+ return json(form)
+}
+
+function handleMedia(
+ path: string,
+ method: string,
+ init: RequestInit | undefined,
+ url: URL,
+) {
+ if (path === '/api/media/settings' && method === 'GET') {
+ return json({ maxUploadMegabytes: 5 })
+ }
+ if (path === '/api/media' && method === 'GET') {
+ const page = pageOf(state.media, url, 24)
+ return json({
+ media: page.items,
+ page: page.page,
+ pageSize: page.pageSize,
+ total: state.media.length,
+ })
+ }
+ if (path === '/api/media' && method === 'POST') {
+ const file = init?.body instanceof FormData ? init.body.get('file') : null
+ const mediaId = String(sequence + 1).padStart(32, '0')
+ sequence += 1
+ const media = {
+ createdAt: DEMO_NOW,
+ id: mediaId,
+ mimeType: file instanceof File ? file.type : 'image/svg+xml',
+ originalName: file instanceof File ? file.name : 'demo-upload.svg',
+ reference: `media:${mediaId}`,
+ sizeBytes: file instanceof File ? file.size : 1024,
+ uploadedBy: 'demo-admin',
+ url:
+ file instanceof File
+ ? URL.createObjectURL(file)
+ : imageData('DEMO UPLOAD', '#9333ea', '#db2777'),
+ }
+ state.media.unshift(media)
+ return json(media, 201)
+ }
+ const match = path.match(/^\/api\/media\/([^/]+)$/)
+ if (!match || method !== 'DELETE') return null
+ state.media = state.media.filter((entry) => entry.id !== match[1])
+ return json({ ok: true })
+}
+
+function handleAudit(path: string, method: string, url: URL) {
+ if (path !== '/api/audit-log' || method !== 'GET') return null
+ const eventName = url.searchParams.get('eventName') ?? ''
+ const filtered =
+ eventName === ''
+ ? state.auditEvents
+ : state.auditEvents.filter((event) => event.name === eventName)
+ const page = pageOf(filtered, url, 20)
+ return json({
+ events: page.items,
+ page: page.page,
+ pageSize: page.pageSize,
+ retentionDays: 90,
+ total: filtered.length,
+ })
+}
+
+function handleSearch(path: string, method: string, url: URL) {
+ if (path !== '/api/search' || method !== 'GET') return null
+ const query = (url.searchParams.get('q') ?? '').toLowerCase()
+ const status = url.searchParams.get('status') ?? ''
+ const results = state.contentEntries
+ .filter((entry) => {
+ const haystack =
+ `${entry.title} ${entry.slug} ${JSON.stringify(entry.fields)}`.toLowerCase()
+ return (
+ haystack.includes(query) && (status === '' || entry.status === status)
+ )
+ })
+ .map((entry) => ({
+ collection: entry.collection,
+ excerpt: (entry.fields as Record).excerpt ?? '',
+ id: entry.id,
+ relevance: 1,
+ status: entry.status,
+ subject: `${entry.collection}:${entry.id}`,
+ title: entry.title,
+ updatedAt: entry.updatedAt,
+ url: demoUrl('/content/pages'),
+ }))
+ const page = pageOf(results, url, 20)
+ return json({
+ page: page.page,
+ pageSize: page.pageSize,
+ query: url.searchParams.get('q') ?? '',
+ results: page.items,
+ total: results.length,
+ })
+}
+
+function handleWebhooks(
+ path: string,
+ method: string,
+ init: RequestInit | undefined,
+ url: URL,
+) {
+ if (path === '/api/webhooks' && method === 'GET') {
+ return json({ webhooks: state.webhooks })
+ }
+ if (path === '/api/webhooks' && method === 'POST') {
+ const webhook = {
+ ...bodyOf(init),
+ createdAt: DEMO_NOW,
+ hasSecret: true,
+ id: id('webhook'),
+ updatedAt: DEMO_NOW,
+ }
+ state.webhooks.unshift(webhook)
+ return json(webhook, 201)
+ }
+ if (path === '/api/webhooks/deliveries' && method === 'GET') {
+ const status = url.searchParams.get('status') ?? ''
+ const filtered =
+ status === ''
+ ? state.deliveries
+ : state.deliveries.filter((delivery) => delivery.status === status)
+ const page = pageOf(filtered, url, 25)
+ return json({
+ deliveries: page.items,
+ page: page.page,
+ pageSize: page.pageSize,
+ total: filtered.length,
+ })
+ }
+ const retry = path.match(/^\/api\/webhooks\/deliveries\/([^/]+)\/retry$/)
+ if (retry && method === 'POST') {
+ const delivery = state.deliveries.find((entry) => entry.id === retry[1])
+ if (!delivery) return json({ error: 'Delivery not found.' }, 404)
+ Object.assign(delivery, {
+ attempts: 0,
+ lastError: null,
+ responseStatus: null,
+ status: 'pending',
+ updatedAt: DEMO_NOW,
+ })
+ return json(delivery)
+ }
+ const match = path.match(/^\/api\/webhooks\/([^/]+)$/)
+ if (!match) return null
+ const webhook = state.webhooks.find((entry) => entry.id === match[1])
+ if (!webhook) return json({ error: 'Webhook not found.' }, 404)
+ if (method === 'PUT')
+ Object.assign(webhook, bodyOf(init), {
+ hasSecret: true,
+ updatedAt: DEMO_NOW,
+ })
+ if (method === 'DELETE') {
+ state.webhooks = state.webhooks.filter((item) => item !== webhook)
+ state.deliveries = state.deliveries.filter(
+ (item) => item.webhookId !== match[1],
+ )
+ }
+ return json(webhook)
+}
+
+function handleContent(
+ path: string,
+ method: string,
+ init: RequestInit | undefined,
+ url: URL,
+) {
+ const root = '/api/content/pages'
+ if (path === root && method === 'GET') {
+ const status = url.searchParams.get('status') ?? ''
+ const filtered =
+ status === '' || status === 'all'
+ ? state.contentEntries
+ : state.contentEntries.filter((entry) => entry.status === status)
+ const page = pageOf(filtered, url, 20)
+ return json({
+ entries: page.items,
+ page: page.page,
+ pageSize: page.pageSize,
+ total: filtered.length,
+ })
+ }
+ if (path === root && method === 'POST') {
+ const entry = {
+ ...bodyOf(init),
+ author: 'demo-admin',
+ collection: 'pages',
+ createdAt: DEMO_NOW,
+ id: id('page'),
+ publishedAt: null,
+ status: 'draft',
+ updatedAt: DEMO_NOW,
+ }
+ state.contentEntries.unshift(entry)
+ return json(entry, 201)
+ }
+ const match = path.match(
+ /^\/api\/content\/pages\/([^/]+)(\/(publish|unpublish))?$/,
+ )
+ if (!match) return null
+ const entry = state.contentEntries.find((item) => item.id === match[1])
+ if (!entry) return json({ error: 'Content entry not found.' }, 404)
+ if (method === 'PUT')
+ Object.assign(entry, bodyOf(init), { updatedAt: DEMO_NOW })
+ if (method === 'POST' && match[3]) {
+ Object.assign(entry, {
+ publishedAt: match[3] === 'publish' ? DEMO_NOW : null,
+ status: match[3] === 'publish' ? 'published' : 'draft',
+ updatedAt: DEMO_NOW,
+ })
+ }
+ if (method === 'DELETE') {
+ state.contentEntries = state.contentEntries.filter((item) => item !== entry)
+ }
+ return json(entry)
+}
+
+function handleSettings(path: string, method: string, init?: RequestInit) {
+ if (!path.endsWith('/settings')) return null
+ if (method === 'GET') return json(state.settings[path] ?? {})
+ if (method === 'PUT') {
+ state.settings[path] = bodyOf(init)
+ return json(state.settings[path])
+ }
+ return null
+}
+
+function mockResponse(url: URL, method: string, init?: RequestInit): Response {
+ const path = url.pathname.replace(DEMO_BASE_PATH, '')
+ return (
+ handleUsers(path, method, init) ??
+ handleMemos(path, method, init) ??
+ handleRedirects(path, method, init) ??
+ handleForms(path, method, init) ??
+ handleMedia(path, method, init, url) ??
+ handleAudit(path, method, url) ??
+ handleSearch(path, method, url) ??
+ handleWebhooks(path, method, init, url) ??
+ handleContent(path, method, init, url) ??
+ handleSettings(path, method, init) ??
+ json(
+ { error: `No demo response is registered for ${method} ${path}.` },
+ 404,
+ )
+ )
+}
+
+/** Installs the browser-only API simulator used by the public GitHub Pages demo. */
+export function installMockApi(): void {
+ if (installed || typeof window === 'undefined') return
+ installed = true
+ const nativeFetch = window.fetch.bind(window)
+ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
+ const rawUrl = input instanceof Request ? input.url : String(input)
+ const url = new URL(rawUrl, window.location.origin)
+ const method = (
+ init?.method ?? (input instanceof Request ? input.method : 'GET')
+ ).toUpperCase()
+ if (
+ url.pathname === '/api' ||
+ url.pathname.startsWith('/api/') ||
+ url.pathname.startsWith(`${DEMO_BASE_PATH}/api/`)
+ ) {
+ return mockResponse(url, method, init)
+ }
+ return nativeFetch(input, init)
+ }
+}
+
+/** Restores the deterministic seed data for another demo session. */
+export function resetMockApi(): void {
+ sequence = 100
+ state = createDemoState()
+}
diff --git a/packages/app/vite.config.ts b/packages/app/vite.config.ts
index 59e82df..7640371 100644
--- a/packages/app/vite.config.ts
+++ b/packages/app/vite.config.ts
@@ -4,6 +4,7 @@ import vinext from 'vinext'
import { defineConfig } from 'vite'
export default defineConfig({
+ base: process.env.VITE_YEOLLIN_BASE_PATH || '/',
optimizeDeps: {
exclude: ['@devup-ui/react'],
},