create-kywi-app 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +21 -15
  2. package/lib/templates.mjs +123 -677
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -47,9 +47,11 @@ Set via `--mode` (and written into the generated `kywi.config.ts`'s
47
47
  ## What gets generated
48
48
 
49
49
  A generated project **boots and works with zero manual edits**:
50
- `pnpm install && pnpm migrate && pnpm seed && pnpm dev` gives you a working admin
51
- at `/admin` (sign in, create + publish a page) and in `coupled` mode — a public
52
- site that renders published pages at their slug.
50
+ `pnpm install && pnpm migrate && pnpm seed && pnpm dev` gives you a working
51
+ `/admin` the **full** Kywi admin (dashboard, site tree, media, users, forms,
52
+ audiences, settings, and every other core surface, sign in and create +
53
+ publish a page) — and, in `coupled` mode, a public site that renders published
54
+ pages at their slug.
53
55
 
54
56
  `buildFileSet()` writes (`lib/templates.mjs`):
55
57
 
@@ -57,31 +59,35 @@ site that renders published pages at their slug.
57
59
  package.json scripts (dev/build/start/migrate/seed) + deps incl. drizzle-kit + tsx
58
60
  kywi.config.ts defineKywiConfig/Site/Theme + a default "Page" content type
59
61
  tsconfig.json
60
- next.config.mjs the FULL config to consume core (extensionAlias, serverExternalPackages,
61
- isomorphic-dompurify stub) — every line is load-bearing
62
+ next.config.mjs the FULL config to consume core (transpilePackages, extensionAlias,
63
+ serverExternalPackages) — every line is load-bearing
62
64
  .env.example DATABASE_URL + AUTH_SECRET
63
65
  .gitignore
64
- README.md project-specific quick start (createdb, migrate, seed, dev, /admin)
65
- lib/dompurify-stub.js webpack stub referenced by next.config
66
+ README.md project-specific quick start (createdb, migrate, seed, dev, /admin, admin.features)
66
67
  lib/kywi.ts server runtime: DB, API handler, content scope (memoised)
67
68
  lib/config.ts re-export of kywi.config
68
- lib/admin-auth.ts client admin helpers, re-exported from @kywi-software/core/host-client
69
- lib/content-types.ts config + built-in (page/folder/link) content-type resolver
70
69
  middleware.ts auth gate + session refresh + cookie→bearer bridge (over core/host)
71
70
  app/api/v1/[...kywi]/route.ts the versioned API (delegates to core; lifts tokens into httpOnly cookies)
72
71
  app/layout.tsx root <html>/<body>
73
- components/admin-shell.tsx minimal admin chrome (your app's own layer)
74
- app/admin/layout.tsx pass-through admin segment layout
75
- app/admin/page.tsx redirect → /admin/content
76
- app/admin/login/page.tsx sign-in form
77
- app/admin/content/page.tsx content-type picker
78
- app/admin/content/[type]/… list / new / edit + publish (core's ContentEditForm)
72
+ app/icon.svg favicon
73
+ app/admin/[[...admin]]/page.tsx mounts the FULL core admin (KywiAdminApp, every surface) at /admin
79
74
  ```
80
75
 
81
76
  **coupled** additionally gets `app/(site)/layout.tsx` + `app/(site)/[[...slug]]/page.tsx`
82
77
  (the public site — renders "/" and every published page at its slug). **headless**
83
78
  and **decoupled** instead get `app/page.tsx` (returns 404 — no public rendering).
84
79
 
80
+ Note what's *not* in this list: there is no `components/admin-shell.tsx`, no
81
+ per-route `app/admin/content/**`, no `lib/admin-auth.ts`. Since 0.2.0 the whole
82
+ admin is one component (`KywiAdminApp`) shipped by `@kywi-software/core` — the
83
+ generated project mounts it with the single catch-all page above instead of
84
+ hand-rolling admin pages. See [`docs/hosting.md`](../../docs/hosting.md) §3
85
+ for exactly what that mount does, and
86
+ [`docs/admin-configuration.md`](../../docs/admin-configuration.md) for
87
+ disabling surfaces you don't want (per deployment via `admin.features` in
88
+ `kywi.config.ts`, or per site at runtime via the superAdmin "Admin Features"
89
+ surface — no redeploy).
90
+
85
91
  The security-critical session plumbing (JWT verify, the httpOnly cookie contract,
86
92
  the cookie→bearer bridge) is NOT copied into every project. It lives once in
87
93
  `@kywi-software/core/host` (+ `/host-client`); the generated `middleware.ts` and
package/lib/templates.mjs CHANGED
@@ -7,9 +7,16 @@
7
7
  * CLI writes, which is what the tests assert against.
8
8
  *
9
9
  * A generated project boots and works as documented with ZERO manual edits:
10
- * `pnpm install && pnpm migrate && pnpm seed && pnpm dev` gives a working admin
11
- * at /admin (log in with the seeded credentials, create + publish a page) and,
12
- * in coupled mode, a public site that renders published pages at their slug.
10
+ * `pnpm install && pnpm migrate && pnpm seed && pnpm dev` gives the COMPLETE
11
+ * admin at /admin (log in with the seeded credentials every surface is present:
12
+ * dashboard, site tree, media, users, forms, categories, tags, audiences,
13
+ * settings, …) and, in coupled mode, a public site that renders published pages
14
+ * at their slug.
15
+ *
16
+ * The whole admin is core's `KywiAdminApp`, mounted by ONE optional-catch-all
17
+ * host page (app/admin/[[...admin]]/page.tsx). The scaffold does NOT hand-write
18
+ * per-surface admin pages, a bespoke shell, or a login page — core provides all
19
+ * of them, so `npm update @kywi-software/core` upgrades the entire admin.
13
20
  *
14
21
  * The three deployment modes differ only in the public surface:
15
22
  * - coupled: renders the public site (app/(site)) AND serves the admin + API.
@@ -73,6 +80,12 @@ function packageJson(a) {
73
80
  // interactive approval prompt (no-op for npm/yarn).
74
81
  pnpm: {
75
82
  onlyBuiltDependencies: ['esbuild', 'sharp'],
83
+ // @auth/core lists nodemailer as an OPTIONAL peer capped at ^7; core ships
84
+ // nodemailer 8 (its createTransport/sendMail usage is identical). Tell pnpm
85
+ // 8 is fine so a fresh install prints no unmet-peer warning.
86
+ peerDependencyRules: {
87
+ allowedVersions: { nodemailer: '8' },
88
+ },
76
89
  },
77
90
  },
78
91
  null,
@@ -182,12 +195,7 @@ function tsconfig() {
182
195
  // ── next.config.mjs ───────────────────────────────────────────────────────────
183
196
 
184
197
  function nextConfig() {
185
- return `import { createRequire } from 'node:module'
186
-
187
- // require() is needed for require.resolve() of the local dompurify stub below.
188
- const require = createRequire(import.meta.url)
189
-
190
- /**
198
+ return `/**
191
199
  * Next.js config for a Kywi app. Every setting here is required to consume
192
200
  * @kywi-software/core; removing any of them breaks the build:
193
201
  *
@@ -196,10 +204,10 @@ const require = createRequire(import.meta.url)
196
204
  * must resolve those specifiers to the real \`.ts\`/\`.tsx\`.
197
205
  * - serverExternalPackages: core's server deps (sharp, drizzle, postgres, …)
198
206
  * stay server-side and out of the client bundle.
199
- * - isomorphic-dompurify stub: jsdom fails when webpack bundles it on the
200
- * server, and its CJS/ESM interop breaks client transpile;
201
- * the stub passes content through (sanitisation is
202
- * defense-in-depth CMS-authored content is trusted).
207
+ *
208
+ * Note: core sanitizes all CMS HTML itself with a DOM-free sanitizer
209
+ * (sanitize-html), so there is no jsdom-based sanitizer to work around and no
210
+ * client-side sanitizer stub to wire up here.
203
211
  */
204
212
  /** @type {import('next').NextConfig} */
205
213
  const nextConfig = {
@@ -211,8 +219,6 @@ const nextConfig = {
211
219
  'nodemailer',
212
220
  'bcryptjs',
213
221
  '@aws-sdk/client-s3',
214
- 'jsdom',
215
- 'isomorphic-dompurify',
216
222
  ],
217
223
  webpack(config) {
218
224
  config.resolve.extensionAlias = {
@@ -220,10 +226,6 @@ const nextConfig = {
220
226
  '.js': ['.ts', '.tsx', '.js'],
221
227
  '.jsx': ['.tsx', '.jsx'],
222
228
  }
223
- config.resolve.alias = {
224
- ...config.resolve.alias,
225
- 'isomorphic-dompurify': require.resolve('./lib/dompurify-stub.js'),
226
- }
227
229
  return config
228
230
  },
229
231
  }
@@ -232,30 +234,6 @@ export default nextConfig
232
234
  `
233
235
  }
234
236
 
235
- function dompurifyStub() {
236
- return `/**
237
- * Stub for isomorphic-dompurify used in both server and client webpack bundles.
238
- * Server: isomorphic-dompurify uses jsdom, which fails when webpack bundles it.
239
- * Client: its CJS/ESM interop breaks when @kywi-software/core is transpiled.
240
- * This stub passes content through unchanged — DOMPurify sanitisation is
241
- * defense-in-depth; CMS-authored content is trusted.
242
- */
243
- const DOMPurify = {
244
- sanitize: (dirty) => dirty,
245
- addHook: () => {},
246
- removeHook: () => {},
247
- removeHooks: () => {},
248
- removeAllHooks: () => {},
249
- isValidAttribute: () => true,
250
- setConfig: () => {},
251
- clearConfig: () => {},
252
- }
253
-
254
- export default DOMPurify
255
- export const sanitize = DOMPurify.sanitize
256
- `
257
- }
258
-
259
237
  // ── env / gitignore ───────────────────────────────────────────────────────────
260
238
 
261
239
  function envExample() {
@@ -271,6 +249,17 @@ AUTH_SECRET=CHANGE_ME
271
249
  `
272
250
  }
273
251
 
252
+ // A simple, neutral favicon. Next's App Router auto-serves app/icon.svg and
253
+ // injects <link rel="icon">, so the browser stops requesting /favicon.ico (no
254
+ // more 404 in the console on a fresh boot).
255
+ function faviconSvg() {
256
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
257
+ <rect width="32" height="32" rx="7" fill="#0f172a"/>
258
+ <path d="M11 8v16M11 16l8-8M11 16l8 8" fill="none" stroke="#e2e8f0" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
259
+ </svg>
260
+ `
261
+ }
262
+
274
263
  function gitignore() {
275
264
  return `node_modules
276
265
  .next
@@ -345,26 +334,6 @@ export default config
345
334
  `
346
335
  }
347
336
 
348
- function libAdminAuth() {
349
- return `/**
350
- * Client-side admin helpers. Re-exported from @kywi-software/core/host-client so
351
- * every admin surface imports them from one local path — swap the source here if
352
- * you ever want to customise them.
353
- */
354
- export {
355
- adminFetch,
356
- getAdminUser,
357
- setAuth,
358
- clearAuth,
359
- signOut,
360
- getActiveSiteId,
361
- setActiveSiteId,
362
- clearActiveSiteId,
363
- type AdminUser,
364
- } from '@kywi-software/core/host-client'
365
- `
366
- }
367
-
368
337
  // ── middleware.ts (thin framework wiring over @kywi-software/core/host) ───────
369
338
 
370
339
  function middleware() {
@@ -638,603 +607,70 @@ export const dynamic = 'force-dynamic'
638
607
  `
639
608
  }
640
609
 
641
- // ── admin ─────────────────────────────────────────────────────────────────────
642
-
643
- function adminLayout() {
644
- return `import React from 'react'
645
-
646
- // Pass-through: the root layout provides <html>/<body>; admin pages bring their
647
- // own chrome (AdminShell for authed pages, nothing extra for the login page).
648
- export default function AdminLayout({ children }: { children: React.ReactNode }) {
649
- return <>{children}</>
650
- }
651
- `
652
- }
653
-
654
- function adminIndexPage() {
655
- return `import { redirect } from 'next/navigation'
656
-
657
- export default function AdminIndexPage() {
658
- redirect('/admin/content')
659
- }
660
- `
661
- }
662
-
663
- function adminShellComponent() {
664
- return `'use client'
665
-
666
- import React, { useEffect, useState } from 'react'
667
- import { usePathname } from 'next/navigation'
668
- import '@kywi-software/core/admin/styles.css'
669
- import { getAdminUser, signOut, type AdminUser } from '../lib/admin-auth'
610
+ // ── admin (the full core admin, mounted at /admin) ─────────────────────────────
670
611
 
671
612
  /**
672
- * Minimal admin chrome: a sidebar + top bar around the authed admin pages. The
673
- * server middleware is the real access gate (a valid session cookie was already
674
- * verified to reach here); this only loads the user descriptor for display and
675
- * bounces to /admin/login if it's gone (e.g. signed out in another tab).
613
+ * The single optional-catch-all host page that mounts the ENTIRE core admin
614
+ * (all surfaces) at /admin. Modeled on apps/reference the whole scaffolded
615
+ * admin is `KywiAdminApp`, upgraded via `npm update @kywi-software/core`, not
616
+ * hand-written per-surface pages.
617
+ */
618
+ function adminCatchAllPage() {
619
+ return `/**
620
+ * Host page for the core-mounted admin (\`KywiAdminApp\`).
621
+ *
622
+ * This ONE optional-catch-all serves your entire admin at /admin — dashboard,
623
+ * site tree, media, users, forms, categories, tags, audiences, settings, and
624
+ * every other surface. It is a server component: it projects the full,
625
+ * server-side kywi.config.ts down to the secret-free AdminRuntimeConfig
626
+ * (toAdminRuntimeConfig), extracts ?next= for the login surface, and renders the
627
+ * one client KywiAdminApp, which maps the URL segments to a surface and mounts
628
+ * it inside the core admin chrome.
676
629
  *
677
- * Extend this as you grow the admin — it's your app's own layer over core's
678
- * admin components (ContentEditForm etc.).
630
+ * The @kywi-software/core/admin/styles.css import is LOAD-BEARING: the admin
631
+ * design-system tokens/inputs live there, so without it every surface (and the
632
+ * login page) renders unstyled. Do not remove it.
633
+ *
634
+ * Server-side 404: before rendering, the segments are checked against the config
635
+ * ceiling with the pure resolveSurface helper, calling Next's notFound() for a
636
+ * true HTTP 404 on an unknown or config-disabled path. The \`login\` segment is
637
+ * exempt (it is not a registry surface — the middleware's auth exception renders
638
+ * it chrome-less through KywiAdminApp); the bare /admin index (segments === [])
639
+ * resolves to the dashboard, so it never 404s.
640
+ *
641
+ * You should not need to edit this file. To disable surfaces per deployment, set
642
+ * \`admin.features\` in kywi.config.ts; superAdmins can also toggle surfaces at
643
+ * runtime from the "Admin Features" surface.
679
644
  */
680
- const NAV = [
681
- { href: '/admin/content', label: 'Content' },
682
- ]
683
-
684
- export function AdminShell({ children }: { children: React.ReactNode }) {
685
- const pathname = usePathname()
686
- const [user, setUser] = useState<AdminUser | null>(null)
687
- const [checked, setChecked] = useState(false)
688
-
689
- useEffect(() => {
690
- const u = getAdminUser()
691
- if (!u) {
692
- window.location.href = '/admin/login'
693
- return
694
- }
695
- setUser(u)
696
- setChecked(true)
697
- }, [])
698
-
699
- async function handleSignOut() {
700
- await signOut()
701
- window.location.href = '/admin/login'
702
- }
703
-
704
- if (!checked) {
705
- return (
706
- <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
707
- Loading…
708
- </div>
709
- )
710
- }
711
-
712
- return (
713
- <div style={{ display: 'flex', minHeight: '100vh', background: '#f8fafc' }}>
714
- <aside style={{ width: 220, background: '#0f172a', color: '#e2e8f0', padding: '1.25rem 0', flexShrink: 0 }}>
715
- <div style={{ padding: '0 1.25rem 1rem', fontWeight: 700, fontSize: '1.0625rem' }}>Kywi Admin</div>
716
- <nav>
717
- {NAV.map((item) => {
718
- const active = pathname === item.href || pathname.startsWith(item.href + '/')
719
- return (
720
- <a
721
- key={item.href}
722
- href={item.href}
723
- style={{
724
- display: 'block',
725
- padding: '0.5rem 1.25rem',
726
- color: active ? '#fff' : '#cbd5e1',
727
- background: active ? '#1e293b' : 'transparent',
728
- textDecoration: 'none',
729
- fontSize: '0.9375rem',
730
- }}
731
- >
732
- {item.label}
733
- </a>
734
- )
735
- })}
736
- </nav>
737
- </aside>
738
- <div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
739
- <header
740
- style={{
741
- display: 'flex',
742
- justifyContent: 'flex-end',
743
- alignItems: 'center',
744
- gap: '1rem',
745
- padding: '0.75rem 1.5rem',
746
- borderBottom: '1px solid #e2e8f0',
747
- background: '#fff',
748
- }}
749
- >
750
- <a href="/" style={{ color: '#64748b', fontSize: '0.875rem', textDecoration: 'none' }}>View site ↗</a>
751
- <span style={{ color: '#64748b', fontSize: '0.875rem' }}>{user?.role}</span>
752
- <button type="button" onClick={handleSignOut} className="kywi-btn kywi-btn-secondary kywi-btn-sm">
753
- Sign out
754
- </button>
755
- </header>
756
- <main style={{ padding: '1.5rem', flex: 1 }}>{children}</main>
757
- </div>
758
- </div>
759
- )
760
- }
761
- `
762
- }
763
-
764
- function adminLoginPage() {
765
- return `'use client'
766
-
767
- import React, { useState } from 'react'
768
- import { setAuth } from '../../../lib/admin-auth'
645
+ import { notFound } from 'next/navigation'
646
+ import { KywiAdminApp } from '@kywi-software/core/admin/app'
647
+ import { toAdminRuntimeConfig, resolveSurface } from '@kywi-software/core/admin/server'
648
+ // CRITICAL: load the admin design system once for the whole mount.
769
649
  import '@kywi-software/core/admin/styles.css'
770
-
771
- /** Honor a safe internal ?next= (set by middleware.ts), else the content list. */
772
- function safeNext(): string {
773
- const fallback = '/admin/content'
774
- if (typeof window === 'undefined') return fallback
775
- const next = new URLSearchParams(window.location.search).get('next')
776
- if (next && next.startsWith('/admin/') && !next.startsWith('//') && next !== '/admin/login') {
777
- return next
778
- }
779
- return fallback
780
- }
781
-
782
- export default function AdminLoginPage() {
783
- const [email, setEmail] = useState('')
784
- const [password, setPassword] = useState('')
785
- const [error, setError] = useState('')
786
- const [loading, setLoading] = useState(false)
787
-
788
- async function handleSubmit(e: React.FormEvent) {
789
- e.preventDefault()
790
- setError('')
791
- setLoading(true)
792
- try {
793
- const res = await fetch('/api/v1/auth/login', {
794
- method: 'POST',
795
- headers: { 'Content-Type': 'application/json' },
796
- body: JSON.stringify({ email, password }),
797
- })
798
- const body = await res.json()
799
- if (!res.ok) {
800
- setError(body.error?.message ?? 'Login failed')
801
- return
802
- }
803
- if (!body.data?.user || !body.data?.accessToken) {
804
- setError('This account requires an unsupported login step.')
805
- return
806
- }
807
- // The token is set by the server as an httpOnly cookie; we keep only the
808
- // (non-sensitive) user descriptor for rendering the chrome.
809
- setAuth(body.data.user)
810
- window.location.href = safeNext()
811
- } catch {
812
- setError('Network error — is the server running?')
813
- } finally {
814
- setLoading(false)
815
- }
816
- }
817
-
818
- return (
819
- <div className="kywi-admin-shell kywi-admin-login">
820
- <form onSubmit={handleSubmit} className="kywi-admin-login-card">
821
- <div className="kywi-admin-login-brand">
822
- <span aria-hidden="true" className="kywi-admin-login-dot" />
823
- <h1>Kywi Admin</h1>
824
- </div>
825
- <p className="kywi-admin-login-subtitle">Sign in to your workspace</p>
826
- {error && (
827
- <div className="kywi-admin-callout kywi-admin-callout-danger" role="alert">
828
- {error}
829
- </div>
830
- )}
831
- <label className="kywi-admin-login-field">
832
- <span className="kywi-admin-login-label">Email</span>
833
- <input type="email" className="kywi-input" value={email} onChange={(e) => setEmail(e.target.value)} required autoFocus />
834
- </label>
835
- <label className="kywi-admin-login-field">
836
- <span className="kywi-admin-login-label">Password</span>
837
- <input type="password" className="kywi-input" value={password} onChange={(e) => setPassword(e.target.value)} required />
838
- </label>
839
- <button type="submit" disabled={loading} className="kywi-btn kywi-btn-primary kywi-admin-login-submit">
840
- {loading ? 'Signing in…' : 'Sign in'}
841
- </button>
842
- </form>
843
- </div>
844
- )
845
- }
846
- `
847
- }
848
-
849
- /** Shared client helper (bundled into pages that import it): resolve a content
850
- * type by name from config + the built-ins. Emitted as a lib file. */
851
- function libContentTypes() {
852
- return `import type { ContentTypeConfig } from '@kywi-software/core/config'
853
- import config from './config'
854
-
855
- /** Built-in types always available in the editor even if not in kywi.config.ts. */
856
- export const BUILT_IN_CONTENT_TYPES: ContentTypeConfig[] = [
857
- { name: 'page', label: 'Page', baseType: 'content', fields: [] },
858
- { name: 'folder', label: 'Folder', baseType: 'folder', fields: [] },
859
- { name: 'link', label: 'Link', baseType: 'link', fields: [] },
860
- ]
861
-
862
- /** All content types the admin can author: config-defined win over built-ins by name. */
863
- export function allContentTypes(): ContentTypeConfig[] {
864
- const byName = new Map<string, ContentTypeConfig>()
865
- for (const ct of BUILT_IN_CONTENT_TYPES) byName.set(ct.name, ct)
866
- for (const ct of config.contentTypes) byName.set(ct.name, ct)
867
- return [...byName.values()]
868
- }
869
-
870
- export function resolveContentType(name: string): ContentTypeConfig | null {
871
- return allContentTypes().find((ct) => ct.name === name) ?? null
872
- }
873
- `
874
- }
875
-
876
- function adminContentLandingPage() {
877
- return `'use client'
878
-
879
- import React from 'react'
880
- import { useRouter } from 'next/navigation'
881
- import { AdminShell } from '../../../components/admin-shell'
882
- import { allContentTypes } from '../../../lib/content-types'
883
-
884
- // Pick a content type to browse/author. Types come from kywi.config.ts plus the
885
- // always-available built-ins (page/folder/link).
886
- export default function ContentLandingPage() {
887
- const router = useRouter()
888
- const types = allContentTypes()
889
-
890
- return (
891
- <AdminShell>
892
- <h1 style={{ margin: '0 0 1.5rem', fontSize: '1.25rem', fontWeight: 600 }}>Content</h1>
893
- <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: '1rem' }}>
894
- {types.map((ct) => (
895
- <button
896
- key={ct.name}
897
- onClick={() => router.push('/admin/content/' + ct.name)}
898
- style={{
899
- display: 'flex',
900
- flexDirection: 'column',
901
- alignItems: 'flex-start',
902
- padding: '1.25rem',
903
- background: '#fff',
904
- border: '1px solid #e2e8f0',
905
- borderRadius: 8,
906
- cursor: 'pointer',
907
- textAlign: 'left',
908
- }}
909
- >
910
- <span style={{ fontSize: '1rem', fontWeight: 600, color: '#1e293b' }}>{ct.label}</span>
911
- <span style={{ fontSize: '0.8125rem', color: '#64748b', marginTop: '0.25rem' }}>
912
- {ct.fields.length} custom field{ct.fields.length !== 1 ? 's' : ''}
913
- </span>
914
- </button>
915
- ))}
916
- </div>
917
- </AdminShell>
918
- )
919
- }
920
- `
921
- }
922
-
923
- function adminContentListPage() {
924
- return `'use client'
925
-
926
- import React, { useEffect, useState } from 'react'
927
- import { useParams, useRouter } from 'next/navigation'
928
- import { AdminShell } from '../../../../components/admin-shell'
929
- import { adminFetch } from '../../../../lib/admin-auth'
930
- import { resolveContentType } from '../../../../lib/content-types'
931
-
932
- interface Row {
933
- id: string
934
- title?: string
935
- slug?: string
936
- path?: string
937
- status?: string
938
- }
939
-
940
- export default function ContentListPage() {
941
- const params = useParams<{ type: string }>()
942
- const router = useRouter()
943
- const typeName = params.type
944
- const contentType = resolveContentType(typeName)
945
- const [rows, setRows] = useState<Row[] | null>(null)
946
-
947
- useEffect(() => {
948
- let cancelled = false
949
- adminFetch('/api/v1/content/' + typeName)
950
- .then((res) => (res.ok ? res.json() : { data: [] }))
951
- .then((body) => {
952
- if (!cancelled) setRows((body?.data ?? []) as Row[])
953
- })
954
- .catch(() => {
955
- if (!cancelled) setRows([])
956
- })
957
- return () => {
958
- cancelled = true
959
- }
960
- }, [typeName])
961
-
962
- return (
963
- <AdminShell>
964
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
965
- <h1 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 600 }}>{contentType?.label ?? typeName}</h1>
966
- <button className="kywi-btn kywi-btn-primary" onClick={() => router.push('/admin/content/' + typeName + '/new')}>
967
- + New {contentType?.label ?? typeName}
968
- </button>
969
- </div>
970
- {rows === null ? (
971
- <p style={{ color: '#64748b' }}>Loading…</p>
972
- ) : rows.length === 0 ? (
973
- <p style={{ color: '#64748b' }}>Nothing here yet. Create your first {contentType?.label ?? typeName}.</p>
974
- ) : (
975
- <div className="kywi-table-scroll" style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 8 }}>
976
- <table className="kywi-table" style={{ width: '100%' }}>
977
- <thead>
978
- <tr>
979
- <th>Title</th>
980
- <th>Slug</th>
981
- <th>Status</th>
982
- <th />
983
- </tr>
984
- </thead>
985
- <tbody>
986
- {rows.map((r) => (
987
- <tr key={r.id}>
988
- <td>
989
- <a href={'/admin/content/' + typeName + '/' + r.id}>{r.title || '(untitled)'}</a>
990
- </td>
991
- <td>{r.slug}</td>
992
- <td>
993
- {r.status && <span className={'kywi-status-badge kywi-status-' + r.status}>{r.status}</span>}
994
- </td>
995
- <td style={{ textAlign: 'right' }}>
996
- {r.status === 'published' && r.path && (
997
- <a href={r.path} target="_blank" rel="noreferrer" style={{ fontSize: '0.875rem' }}>
998
- View ↗
999
- </a>
1000
- )}
1001
- </td>
1002
- </tr>
1003
- ))}
1004
- </tbody>
1005
- </table>
1006
- </div>
1007
- )}
1008
- </AdminShell>
1009
- )
1010
- }
1011
- `
1012
- }
1013
-
1014
- function adminContentNewPage() {
1015
- return `'use client'
1016
-
1017
- import React, { useState } from 'react'
1018
- import { useParams, useRouter } from 'next/navigation'
1019
- import { AdminShell } from '../../../../../components/admin-shell'
1020
- import { ContentEditForm, buildWritablePayload } from '@kywi-software/core/admin'
1021
- import { adminFetch } from '../../../../../lib/admin-auth'
1022
- import { resolveContentType } from '../../../../../lib/content-types'
1023
-
1024
- export default function ContentNewPage() {
1025
- const params = useParams<{ type: string }>()
1026
- const router = useRouter()
1027
- const typeName = params.type
1028
- const contentType = resolveContentType(typeName)
1029
- const [isSubmitting, setIsSubmitting] = useState(false)
1030
- const [error, setError] = useState<string | null>(null)
1031
-
1032
- if (!contentType) {
1033
- return (
1034
- <AdminShell>
1035
- <h1>Content type "{typeName}" not found</h1>
1036
- <p>It is not defined in kywi.config.ts.</p>
1037
- </AdminShell>
1038
- )
1039
- }
1040
-
1041
- const fieldNames = contentType.fields.map((f) => f.name)
1042
-
1043
- async function handleSubmit(data: Record<string, unknown>) {
1044
- setIsSubmitting(true)
1045
- setError(null)
1046
- try {
1047
- const payload = buildWritablePayload(data, fieldNames)
1048
- const res = await adminFetch('/api/v1/content/' + typeName, {
1049
- method: 'POST',
1050
- body: JSON.stringify(payload),
1051
- })
1052
- if (!res.ok) {
1053
- const body = await res.json().catch(() => ({}))
1054
- throw new Error(body.error?.message ?? 'HTTP ' + res.status)
1055
- }
1056
- const created = await res.json()
1057
- const id = created.data?.id ?? created.id
1058
- router.push(id ? '/admin/content/' + typeName + '/' + id : '/admin/content/' + typeName)
1059
- } catch (err) {
1060
- setError(err instanceof Error ? err.message : 'Failed to create content')
1061
- setIsSubmitting(false)
1062
- }
1063
- }
1064
-
1065
- return (
1066
- <AdminShell>
1067
- <div style={{ marginBottom: '1rem' }}>
1068
- <button className="kywi-btn kywi-btn-ghost kywi-btn-sm" onClick={() => router.push('/admin/content/' + typeName)}>
1069
- ← {contentType.label}
1070
- </button>
1071
- </div>
1072
- {error && (
1073
- <div className="kywi-admin-callout kywi-admin-callout-danger" style={{ marginBottom: '1rem' }}>
1074
- {error}
1075
- </div>
1076
- )}
1077
- <ContentEditForm
1078
- contentType={contentType}
1079
- mode="create"
1080
- isSubmitting={isSubmitting}
1081
- onSubmit={handleSubmit}
1082
- onCancel={() => router.push('/admin/content/' + typeName)}
1083
- />
1084
- </AdminShell>
1085
- )
1086
- }
1087
- `
1088
- }
1089
-
1090
- function adminContentEditPage() {
1091
- return `'use client'
1092
-
1093
- import React, { useEffect, useState } from 'react'
1094
- import { useParams, useRouter } from 'next/navigation'
1095
- import { AdminShell } from '../../../../../components/admin-shell'
1096
- import { ContentEditForm, buildWritablePayload, mapContentToInitialValues } from '@kywi-software/core/admin'
1097
- import { adminFetch } from '../../../../../lib/admin-auth'
1098
- import { resolveContentType } from '../../../../../lib/content-types'
1099
-
1100
- export default function ContentEditPage() {
1101
- const params = useParams<{ type: string; id: string }>()
1102
- const router = useRouter()
1103
- const typeName = params.type
1104
- const id = params.id
1105
- const contentType = resolveContentType(typeName)
1106
- const [node, setNode] = useState<Record<string, unknown> | null>(null)
1107
- const [loaded, setLoaded] = useState(false)
1108
- const [isSubmitting, setIsSubmitting] = useState(false)
1109
- const [error, setError] = useState<string | null>(null)
1110
- const [notice, setNotice] = useState<string | null>(null)
1111
-
1112
- useEffect(() => {
1113
- let cancelled = false
1114
- adminFetch('/api/v1/content/' + typeName + '/' + id)
1115
- .then((res) => (res.ok ? res.json() : null))
1116
- .then((body) => {
1117
- if (cancelled) return
1118
- setNode((body?.data ?? null) as Record<string, unknown> | null)
1119
- setLoaded(true)
1120
- })
1121
- .catch(() => {
1122
- if (!cancelled) setLoaded(true)
1123
- })
1124
- return () => {
1125
- cancelled = true
1126
- }
1127
- }, [typeName, id])
1128
-
1129
- if (!contentType) {
1130
- return (
1131
- <AdminShell>
1132
- <h1>Content type "{typeName}" not found</h1>
1133
- </AdminShell>
1134
- )
1135
- }
1136
-
1137
- if (!loaded) {
1138
- return (
1139
- <AdminShell>
1140
- <p style={{ color: '#64748b' }}>Loading…</p>
1141
- </AdminShell>
1142
- )
1143
- }
1144
-
1145
- if (!node) {
1146
- return (
1147
- <AdminShell>
1148
- <h1>Not found</h1>
1149
- <p>This content no longer exists.</p>
1150
- </AdminShell>
1151
- )
1152
- }
1153
-
1154
- const fieldNames = contentType.fields.map((f) => f.name)
1155
- const status = String(node['status'] ?? 'draft')
1156
- const path = node['path'] as string | undefined
1157
-
1158
- async function patch(payload: Record<string, unknown>): Promise<Record<string, unknown> | null> {
1159
- const res = await adminFetch('/api/v1/content/' + typeName + '/' + id, {
1160
- method: 'PATCH',
1161
- body: JSON.stringify(payload),
1162
- })
1163
- if (!res.ok) {
1164
- const body = await res.json().catch(() => ({}))
1165
- throw new Error(body.error?.message ?? 'HTTP ' + res.status)
1166
- }
1167
- const body = await res.json().catch(() => ({}))
1168
- return (body?.data ?? null) as Record<string, unknown> | null
650
+ import kywiConfig from '../../../lib/config'
651
+
652
+ export default async function AdminCatchAll({
653
+ params,
654
+ searchParams,
655
+ }: {
656
+ params: Promise<{ admin?: string[] }>
657
+ searchParams: Promise<Record<string, string | string[] | undefined>>
658
+ }) {
659
+ const { admin = [] } = await params
660
+ const sp = await searchParams
661
+ const next = typeof sp.next === 'string' ? sp.next : null
662
+
663
+ const runtime = toAdminRuntimeConfig(kywiConfig)
664
+
665
+ // Hard, server-side config ceiling → real HTTP 404. The login segment is not a
666
+ // surface (chrome-less middleware exception); the bare index resolves to the
667
+ // dashboard, so both skip the 404 pre-check.
668
+ const isLogin = admin.length === 1 && admin[0] === 'login'
669
+ if (!isLogin && resolveSurface(admin, runtime.admin) === null) {
670
+ notFound()
1169
671
  }
1170
672
 
1171
- async function handleSubmit(data: Record<string, unknown>) {
1172
- setIsSubmitting(true)
1173
- setError(null)
1174
- setNotice(null)
1175
- try {
1176
- const updated = await patch(buildWritablePayload(data, fieldNames))
1177
- if (updated) setNode(updated)
1178
- setNotice('Saved.')
1179
- } catch (err) {
1180
- setError(err instanceof Error ? err.message : 'Failed to save')
1181
- } finally {
1182
- setIsSubmitting(false)
1183
- }
1184
- }
1185
-
1186
- // Publish/unpublish directly, so it works regardless of how you fill the form.
1187
- async function togglePublish() {
1188
- setError(null)
1189
- setNotice(null)
1190
- try {
1191
- const next = status === 'published' ? 'draft' : 'published'
1192
- const updated = await patch({ status: next })
1193
- if (updated) setNode(updated)
1194
- setNotice(next === 'published' ? 'Published.' : 'Unpublished.')
1195
- } catch (err) {
1196
- setError(err instanceof Error ? err.message : 'Failed to change status')
1197
- }
1198
- }
1199
-
1200
- return (
1201
- <AdminShell>
1202
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem', gap: '1rem' }}>
1203
- <button className="kywi-btn kywi-btn-ghost kywi-btn-sm" onClick={() => router.push('/admin/content/' + typeName)}>
1204
- ← {contentType.label}
1205
- </button>
1206
- <div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
1207
- <span className={'kywi-status-badge kywi-status-' + status}>{status}</span>
1208
- {status === 'published' && path && (
1209
- <a href={path} target="_blank" rel="noreferrer" className="kywi-btn kywi-btn-secondary kywi-btn-sm">
1210
- View ↗
1211
- </a>
1212
- )}
1213
- <button className="kywi-btn kywi-btn-primary kywi-btn-sm" onClick={togglePublish}>
1214
- {status === 'published' ? 'Unpublish' : 'Publish'}
1215
- </button>
1216
- </div>
1217
- </div>
1218
- {error && (
1219
- <div className="kywi-admin-callout kywi-admin-callout-danger" style={{ marginBottom: '1rem' }}>
1220
- {error}
1221
- </div>
1222
- )}
1223
- {notice && (
1224
- <div className="kywi-admin-callout" style={{ marginBottom: '1rem' }}>
1225
- {notice}
1226
- </div>
1227
- )}
1228
- <ContentEditForm
1229
- contentType={contentType}
1230
- mode="edit"
1231
- initialValues={mapContentToInitialValues(node)}
1232
- isSubmitting={isSubmitting}
1233
- onSubmit={handleSubmit}
1234
- onCancel={() => router.push('/admin/content/' + typeName)}
1235
- />
1236
- </AdminShell>
1237
- )
673
+ return <KywiAdminApp runtime={runtime} segments={admin} loginNext={next} />
1238
674
  }
1239
675
  `
1240
676
  }
@@ -1279,18 +715,38 @@ pnpm seed # prints the generated superadmin credentials
1279
715
 
1280
716
  # 4. Start the dev server
1281
717
  pnpm dev # http://localhost:3000
718
+ # # use another port with: pnpm dev -p <port>
1282
719
  \`\`\`
1283
720
 
1284
721
  Then open:
1285
- - \`http://localhost:3000/admin\` — the admin UI. Sign in with the credentials
1286
- \`pnpm seed\` printed (default \`admin@kywi.dev\` / \`admin123\` unless you set
1287
- \`KYWI_ADMIN_EMAIL\` / \`KYWI_ADMIN_PASSWORD\`).
722
+ - \`http://localhost:3000/admin\` — the **full admin UI**. Sign in with the
723
+ credentials \`pnpm seed\` printed (default \`admin@kywi.dev\` / \`admin123\`
724
+ unless you set \`KYWI_ADMIN_EMAIL\` / \`KYWI_ADMIN_PASSWORD\`). Every surface is
725
+ there out of the box — dashboard, site tree, media library, users, forms,
726
+ categories, tags, audiences, settings, and more.
1288
727
  ${publicLine}
1289
728
  ${decoupledBlock}
729
+ ### The admin
730
+
731
+ The entire admin at \`/admin\` is **Kywi's own admin app** (\`KywiAdminApp\`),
732
+ mounted by one host page (\`app/admin/[[...admin]]/page.tsx\`). You did not have to
733
+ build any of it, and you upgrade the whole thing — new surfaces, fixes, polish —
734
+ with:
735
+
736
+ \`\`\`bash
737
+ npm update @kywi-software/core # (or pnpm/yarn up)
738
+ \`\`\`
739
+
740
+ **Turn surfaces off per deployment.** Add \`admin.features\` to \`kywi.config.ts\`
741
+ to disable surfaces you don't want in this deployment (e.g. hide Forms or
742
+ Audiences). SuperAdmins can also toggle surfaces at runtime from the
743
+ **Admin Features** surface inside the admin — no redeploy needed.
744
+
1290
745
  ### Create your first page
1291
746
 
1292
- In the admin: **Content Page + New Page**, fill in a title and slug, save,
1293
- then **Publish**.${a.mode === 'coupled' ? ' Open its slug (e.g. `/about`) to see it live.' : ' Fetch it from `/api/v1/content/page`.'}
747
+ In the admin, open **Site Tree** (or **Content**), create a new **Page**, enter a
748
+ title (the slug is auto-generated from it or type your own), then click
749
+ **Create**. Open the new page and click **Publish**.${a.mode === 'coupled' ? ' The published page lives at `/<slug>` (e.g. a page with slug `about` is served at `/about`) — open it to see it live.' : ' Fetch it from `/api/v1/content/page`.'}
1294
750
 
1295
751
  ## Notes
1296
752
 
@@ -1298,20 +754,19 @@ then **Publish**.${a.mode === 'coupled' ? ' Open its slug (e.g. `/about`) to see
1298
754
  \`NOTICE: identifier "..." will be truncated\` lines for a few long foreign-key
1299
755
  names. They are informational — the migration still applies cleanly.
1300
756
  - **Config lives in \`kywi.config.ts\`** — sites, themes, content types, auth
1301
- providers and the deployment mode. Edit it (add content types, etc.) and
1302
- re-run \`pnpm migrate\`, then restart the dev server.
757
+ providers, the deployment mode, and \`admin.features\`. Edit it (add content
758
+ types, etc.) and re-run \`pnpm migrate\`, then restart the dev server.
1303
759
 
1304
760
  ## Project layout
1305
761
 
1306
762
  \`\`\`
1307
- kywi.config.ts your config: sites, themes, content types, auth, mode
763
+ kywi.config.ts your config: sites, themes, content types, auth, mode, admin.features
1308
764
  middleware.ts auth gate + session refresh + cookie→bearer bridge
1309
765
  next.config.mjs required Next config to consume @kywi-software/core
1310
766
  lib/kywi.ts server runtime (DB, API handler, content scope)
1311
- lib/admin-auth.ts client admin helpers (re-exported from core)
767
+ lib/config.ts single import path for kywi.config.ts
1312
768
  app/api/v1/[...kywi]/route.ts the versioned API (delegates to core)
1313
- app/admin/… the admin UI (login + content authoring)
1314
- components/admin-shell.tsx your admin chrome${a.mode === 'coupled' ? '\napp/(site)/… your public site (renders published pages)' : '\napp/page.tsx returns 404 (no public rendering in this mode)'}
769
+ app/admin/[[...admin]]/page.tsx mounts the FULL core admin (all surfaces) at /admin${a.mode === 'coupled' ? '\napp/(site)/… your public site (renders published pages)' : '\napp/page.tsx returns 404 (no public rendering in this mode)'}
1315
770
  \`\`\`
1316
771
  `
1317
772
  }
@@ -1346,26 +801,17 @@ export function buildFileSet(answers) {
1346
801
  '.env.example': envExample(),
1347
802
  '.gitignore': gitignore(),
1348
803
  'README.md': readme(answers),
1349
- 'lib/dompurify-stub.js': dompurifyStub(),
1350
- // server runtime + client helpers
804
+ // server runtime + config
1351
805
  'lib/kywi.ts': libKywi(),
1352
806
  'lib/config.ts': libConfig(),
1353
- 'lib/admin-auth.ts': libAdminAuth(),
1354
- 'lib/content-types.ts': libContentTypes(),
1355
807
  // host wiring (thin, over @kywi-software/core/host)
1356
808
  'middleware.ts': middleware(),
1357
809
  'app/api/v1/[...kywi]/route.ts': apiRoute(),
1358
810
  // root
1359
811
  'app/layout.tsx': rootLayout(),
1360
- // admin (all modes)
1361
- 'components/admin-shell.tsx': adminShellComponent(),
1362
- 'app/admin/layout.tsx': adminLayout(),
1363
- 'app/admin/page.tsx': adminIndexPage(),
1364
- 'app/admin/login/page.tsx': adminLoginPage(),
1365
- 'app/admin/content/page.tsx': adminContentLandingPage(),
1366
- 'app/admin/content/[type]/page.tsx': adminContentListPage(),
1367
- 'app/admin/content/[type]/new/page.tsx': adminContentNewPage(),
1368
- 'app/admin/content/[type]/[id]/page.tsx': adminContentEditPage(),
812
+ 'app/icon.svg': faviconSvg(),
813
+ // admin (all modes): the FULL core admin (all surfaces) via one catch-all.
814
+ 'app/admin/[[...admin]]/page.tsx': adminCatchAllPage(),
1369
815
  }
1370
816
 
1371
817
  if (answers.mode === 'coupled') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-kywi-app",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Scaffold a new Kywi CMS project — npx create-kywi-app my-site",
5
5
  "type": "module",
6
6
  "homepage": "https://github.com/Kywi-Software/kywi-cms#readme",