create-kywi-app 0.2.0 → 0.3.0

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 +25 -29
  2. package/lib/templates.mjs +1203 -40
  3. package/package.json +1 -1
package/lib/templates.mjs CHANGED
@@ -147,6 +147,31 @@ export default defineKywiConfig({
147
147
  { name: 'main', label: 'Main Content' },
148
148
  { name: 'footer', label: 'Footer' },
149
149
  ],
150
+ // Design tokens flow to the public site as \`:root { --kywi-* }\` CSS
151
+ // variables (the site layout emits them via \`themeTokenStyleBlock\`), and
152
+ // \`@kywi-software/core/site/styles.css\` styles every rendered element from
153
+ // them. Edit a value here → the whole site restyles, no CSS to touch.
154
+ // Naming: colors → --kywi-color-*, spacing → --kywi-spacing-*,
155
+ // typography → --kywi-font-*, borderRadius → --kywi-border-radius-*.
156
+ // See README → "Theming" for the full pipeline and \`key@breakpoint\` syntax.
157
+ tokens: {
158
+ colors: {
159
+ primary: '#2563eb',
160
+ 'primary-contrast': '#ffffff',
161
+ text: '#0f172a',
162
+ heading: '#0f172a',
163
+ muted: '#64748b',
164
+ background: '#ffffff',
165
+ surface: '#f8fafc',
166
+ border: '#e2e8f0',
167
+ },
168
+ spacing: { xs: '0.25rem', sm: '0.5rem', md: '1rem', lg: '1.5rem', xl: '2.5rem' },
169
+ borderRadius: { sm: '6px', md: '10px', lg: '16px' },
170
+ typography: {
171
+ 'family-base': 'system-ui, -apple-system, "Segoe UI", sans-serif',
172
+ 'family-heading': 'system-ui, -apple-system, "Segoe UI", sans-serif',
173
+ },
174
+ },
150
175
  }),
151
176
  ],
152
177
 
@@ -241,6 +266,10 @@ function envExample() {
241
266
 
242
267
  # PostgreSQL connection string. Create the database first: createdb my_db
243
268
  # Postgres 14 or newer works.
269
+ # No user:pass@ needed for local trust/peer auth (the default on most local
270
+ # Postgres installs). If your Postgres requires a password (hosted DBs,
271
+ # Docker images with POSTGRES_PASSWORD, etc.), add user:pass@, e.g.:
272
+ # postgres://user:pass@localhost:5432/mydb
244
273
  DATABASE_URL=postgres://localhost:5432/CHANGE_ME
245
274
 
246
275
  # 32+ char random string. Generate: openssl rand -base64 32
@@ -334,6 +363,419 @@ export default config
334
363
  `
335
364
  }
336
365
 
366
+ // ── public-render helpers (lib/site.ts) ───────────────────────────────────────
367
+
368
+ /**
369
+ * Server-side helpers for the coupled public site: path- and locale-aware content
370
+ * resolution (#30, #51), the feed-hydration resolver (#28) and the component
371
+ * resolver (#46) for the layout engine, plus small SEO/media utilities. Kept out
372
+ * of the page so the route stays a thin render over these.
373
+ */
374
+ function libSite() {
375
+ return `import { headers, cookies } from 'next/headers'
376
+ import type {
377
+ LayoutDocument,
378
+ RegionNode,
379
+ ModuleNode,
380
+ FeedItemsResolver,
381
+ ComponentResolver,
382
+ PersonalizationState,
383
+ } from '@kywi-software/core/layout'
384
+ import { isLayoutSection, applyPageVariant } from '@kywi-software/core/layout'
385
+ import { resolveContentByPath, normalizePath } from '@kywi-software/core/nav'
386
+ import { getFeedBySlug, getComponentById, resolveLocaleFromRequest } from '@kywi-software/core'
387
+ import { resolveComponentDefinition } from '@kywi-software/core/admin/server'
388
+ import {
389
+ evaluateActiveAudiences,
390
+ getSelfIdWidgetConfig,
391
+ listSelfIdFields,
392
+ COOKIE_NAMES,
393
+ } from '@kywi-software/core/audiences'
394
+ import type {
395
+ Audience,
396
+ VisitorSignals,
397
+ SelfIdField,
398
+ SelfIdWidgetConfig,
399
+ SelfIdTrigger,
400
+ } from '@kywi-software/core/audiences/types'
401
+ import { listRunningExperiments, resolveExperimentForContainer } from '@kywi-software/core/experiments'
402
+ import { getKywi, type KywiRuntime } from './kywi'
403
+ import config from './config'
404
+
405
+ /** A resolved content node is a flat record of its columns (base + custom fields). */
406
+ export type ContentNode = Record<string, unknown>
407
+
408
+ // Absolute base for media/OG URLs. Set NEXT_PUBLIC_SITE_URL in production so
409
+ // crawlers and structured data get absolute image URLs.
410
+ const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'
411
+
412
+ /** Public URL for a media file served by the API handler, or null. */
413
+ export function mediaUrl(id: unknown): string | null {
414
+ return typeof id === 'string' && id ? \`\${SITE_URL}/api/v1/media/\${id}/file\` : null
415
+ }
416
+
417
+ /** Absolute origin of the current request, for structured-data (JSON-LD) URLs. */
418
+ export async function requestBaseUrl(): Promise<string> {
419
+ const h = await headers()
420
+ const host = h.get('host') ?? 'localhost'
421
+ const isLocal = host.startsWith('localhost') || host.startsWith('127.') || host.startsWith('0.0.0.0')
422
+ const proto = h.get('x-forwarded-proto') ?? (isLocal ? 'http' : 'https')
423
+ return \`\${proto}://\${host}\`
424
+ }
425
+
426
+ // ─── Locale routing (#51) ────────────────────────────────────────────────────
427
+
428
+ /** The active site's configured locales (the default is always included first). */
429
+ export function siteLocales(): { defaultLocale: string; locales: string[] } {
430
+ const site = config.sites[0]
431
+ const defaultLocale = site?.defaultLocale ?? 'en'
432
+ const configured = site?.locales && site.locales.length > 0 ? site.locales : [defaultLocale]
433
+ const locales = configured.includes(defaultLocale) ? configured : [defaultLocale, ...configured]
434
+ return { defaultLocale, locales }
435
+ }
436
+
437
+ /**
438
+ * Split a URL slug path into its active locale and the remaining slug segments.
439
+ * A leading segment matching a CONFIGURED locale is treated as a locale prefix
440
+ * (\`/es/pricing\` → locale 'es', path ['pricing']); otherwise Accept-Language
441
+ * selects a supported locale, else the site default applies (the whole path is
442
+ * the slug). Locale prefixes are only honoured for locales in \`site.locales\`.
443
+ */
444
+ export function resolveRequestLocale(
445
+ slugSegments: string[],
446
+ acceptLanguage?: string | null,
447
+ ): { locale: string; slugPath: string[] } {
448
+ const { defaultLocale, locales } = siteLocales()
449
+ const pathname = '/' + slugSegments.join('/')
450
+ const locale = resolveLocaleFromRequest(pathname, acceptLanguage ?? null, defaultLocale, locales)
451
+ const first = slugSegments[0]
452
+ const slugPath = first && locales.includes(first) ? slugSegments.slice(1) : slugSegments
453
+ return { locale, slugPath }
454
+ }
455
+
456
+ /**
457
+ * hreflang alternates for a page across the site's configured locales, or
458
+ * undefined for a single-locale site. Feeds Next's \`alternates.languages\`.
459
+ */
460
+ export function localeAlternates(slugSegments: string[]): Record<string, string> | undefined {
461
+ const { defaultLocale, locales } = siteLocales()
462
+ if (locales.length <= 1) return undefined
463
+ const { slugPath } = resolveRequestLocale(slugSegments, null)
464
+ const rel = slugPath.join('/')
465
+ const out: Record<string, string> = {}
466
+ for (const loc of locales) {
467
+ const base = loc === defaultLocale ? \`/\${rel}\` : \`/\${loc}/\${rel}\`
468
+ out[loc] = base.length > 1 ? base.replace(/\\/$/, '') : base
469
+ }
470
+ return out
471
+ }
472
+
473
+ // ─── Content resolution (#30 full path + #51 locale) ─────────────────────────
474
+
475
+ /**
476
+ * Resolve a public content node from a URL slug path, honouring the Site Tree
477
+ * hierarchy: the FULL materialized path is matched, so \`/a/b/<slug>\` serves only
478
+ * the node actually at \`/a/b/<slug>\` — never a top-level \`<slug>\` (#30). The
479
+ * locale (URL prefix › Accept-Language › default) is resolved first and preferred,
480
+ * with a default-locale fallback (#51). Returns the node regardless of status;
481
+ * the caller enforces \`status === 'published'\`.
482
+ */
483
+ export async function resolvePublicContent(
484
+ slugSegments: string[],
485
+ opts?: { acceptLanguage?: string | null },
486
+ ): Promise<ContentNode | null> {
487
+ const { scope, db, siteId } = await getKywi()
488
+ const { defaultLocale } = siteLocales()
489
+ const { locale, slugPath } = resolveRequestLocale(slugSegments, opts?.acceptLanguage)
490
+
491
+ const bySlug = async (slug: string): Promise<ContentNode | null> => {
492
+ let node = await scope.content.getBySlug(slug, siteId, { locale })
493
+ if (!node && locale !== defaultLocale) {
494
+ node = await scope.content.getBySlug(slug, siteId, { locale: defaultLocale })
495
+ }
496
+ return (node as ContentNode | null) ?? null
497
+ }
498
+
499
+ // Home ("/"): the seeded root node (slug "home", path "/").
500
+ if (slugPath.length === 0) return bySlug('home')
501
+
502
+ // Structural resolution by the full path enforces the parent chain (#30) and
503
+ // picks the locale-correct row that shares that path (#51).
504
+ const fullPath = normalizePath('/' + slugPath.join('/'))
505
+ let structural = await resolveContentByPath(db, fullPath, siteId, { locale })
506
+ if (!structural && locale !== defaultLocale) {
507
+ structural = await resolveContentByPath(db, fullPath, siteId, { locale: defaultLocale })
508
+ }
509
+ if (structural) {
510
+ return (await scope.content.getById(structural.id)) as ContentNode | null
511
+ }
512
+
513
+ // Flat-slug fallback ONLY for a single-segment URL whose target has no parent
514
+ // chain (a top-level node whose \`path\` column was never materialized). A nested
515
+ // node is never reachable at a wrong prefix — that is the #30 fix.
516
+ if (slugPath.length === 1) {
517
+ const node = await bySlug(slugPath[0]!)
518
+ if (node && !node['parentId']) return node
519
+ }
520
+ return null
521
+ }
522
+
523
+ // ─── Feed hydration (#28) ────────────────────────────────────────────────────
524
+
525
+ /**
526
+ * Build the {@link FeedItemsResolver} \`hydrateLayoutFeeds\` calls for each Feed
527
+ * Display module: resolve the saved feed by slug, run its query (published-only,
528
+ * per-module limit) through the feed engine, and pre-map each row's media id to a
529
+ * URL so the (client) module can render images. Core owns the walk + item shape;
530
+ * this owns the data access.
531
+ */
532
+ export function buildFeedResolver(runtime: KywiRuntime): FeedItemsResolver {
533
+ const { scope, db, siteId } = runtime
534
+ return async (feedSlug, { limit }) => {
535
+ const feed = await getFeedBySlug(db, feedSlug, siteId)
536
+ if (!feed) return []
537
+ const result = await scope.feeds.query({
538
+ ...(feed.query as Record<string, unknown>),
539
+ siteId,
540
+ status: 'published',
541
+ limit,
542
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
543
+ } as any)
544
+ return result.items.map((row) => ({
545
+ ...row,
546
+ image: mediaUrl(row['featuredImageId']) ?? undefined,
547
+ }))
548
+ }
549
+ }
550
+
551
+ // ─── Component resolver (#46) ────────────────────────────────────────────────
552
+
553
+ function* moduleNodesInRegions(regions: Record<string, RegionNode[]>): Generator<ModuleNode> {
554
+ for (const nodes of Object.values(regions)) {
555
+ for (const node of nodes) {
556
+ if (isLayoutSection(node)) {
557
+ for (const col of node.columns) for (const m of col.nodes) yield m
558
+ } else {
559
+ for (const s of node.defaultSections) for (const col of s.columns) for (const m of col.nodes) yield m
560
+ for (const v of node.variants) for (const s of v.sections) for (const col of s.columns) for (const m of col.nodes) yield m
561
+ }
562
+ }
563
+ }
564
+ }
565
+
566
+ /**
567
+ * Prefetch every connected-component (\`componentId\`) referenced by the layout and
568
+ * return a synchronous {@link ComponentResolver} over that snapshot — the
569
+ * renderer resolves components during render, so they must be resolved up front.
570
+ * Returns undefined when the layout references no components (skip the prop).
571
+ */
572
+ export async function buildComponentResolver(
573
+ layout: LayoutDocument,
574
+ runtime: KywiRuntime,
575
+ ): Promise<ComponentResolver | undefined> {
576
+ const ids = new Set<string>()
577
+ for (const node of moduleNodesInRegions(layout.regions)) {
578
+ if (node.componentId) ids.add(node.componentId)
579
+ }
580
+ if (ids.size === 0) return undefined
581
+ const { db, siteId } = runtime
582
+ const entries = await Promise.all(
583
+ [...ids].map(async (id) => [id, await getComponentById(db, id, siteId)] as const),
584
+ )
585
+ const map = new Map(entries)
586
+ return (componentId) => resolveComponentDefinition(map.get(componentId) ?? null)
587
+ }
588
+
589
+ // ─── Personalization + experiments (#50) ─────────────────────────────────────
590
+
591
+ /** Server-resolved personalization for one public request. */
592
+ export interface PublicPersonalization {
593
+ /** Threaded into <KywiLayout personalization=…> for variantContainer nodes. */
594
+ personalization: PersonalizationState
595
+ /** Winning audience id (null = default / opted out / no match). */
596
+ audienceId: string | null
597
+ /** Stable visitor id (middleware cookie/header); keys A/B assignment. */
598
+ visitorId: string
599
+ /** Active audiences evaluated — handed to the client runtime for re-eval. */
600
+ audiences: Audience[]
601
+ /** Server-resolved signals — handed to the client runtime as serverSignals. */
602
+ signals: Partial<VisitorSignals>
603
+ }
604
+
605
+ /** Is the winning audience id one of the currently active audiences? */
606
+ function isActiveAudience(id: string | null, audiences: Audience[]): boolean {
607
+ return id != null && audiences.some((a) => a.id === id)
608
+ }
609
+
610
+ /**
611
+ * Evaluate the site's active audiences for this request, exactly once, and pick
612
+ * the visitor's A/B arms — the two server seams that make page variants,
613
+ * variantContainers and experiments resolve per-visitor with NO client runtime
614
+ * (kywi-cms#50). The request the audience engine reads (URL + cookies + referrer)
615
+ * is reconstructed from next/headers, so this is safe from any (site) Server
616
+ * Component. A \`kywi_preview_init=<audienceId>\` cookie (set by the admin preview
617
+ * link) forces that audience, so an editor previews it without matching its live
618
+ * conditions.
619
+ */
620
+ export async function resolvePersonalization(
621
+ runtime: KywiRuntime,
622
+ layout: LayoutDocument | null | undefined,
623
+ ): Promise<PublicPersonalization> {
624
+ const [h, cookieStore] = await Promise.all([headers(), cookies()])
625
+ const visitorId =
626
+ h.get('x-kywi-visitor') ?? cookieStore.get(COOKIE_NAMES.VISITOR)?.value ?? 'vis-anon'
627
+ const host = h.get('host') ?? 'localhost'
628
+ const url = h.get('x-kywi-url') ?? \`http://\${host}/\`
629
+
630
+ const reqHeaders = new Headers()
631
+ h.forEach((value, key) => reqHeaders.set(key, value))
632
+ const request = new Request(url, { headers: reqHeaders })
633
+
634
+ const { db, siteId } = runtime
635
+ const { result, audiences } = await evaluateActiveAudiences(db, siteId, request)
636
+
637
+ const previewId = cookieStore.get(COOKIE_NAMES.PREVIEW_INIT)?.value || null
638
+ const audienceId = isActiveAudience(previewId, audiences) ? previewId : result.winningAudienceId
639
+
640
+ const experimentAssignments = await resolveExperimentAssignments(runtime, layout, audienceId, visitorId)
641
+
642
+ return {
643
+ personalization: { resolvedAudienceId: audienceId, experimentAssignments },
644
+ audienceId,
645
+ visitorId,
646
+ audiences,
647
+ signals: result.signals,
648
+ }
649
+ }
650
+
651
+ function* variantContainersInRegions(regions: Record<string, RegionNode[]>): Generator<RegionNode> {
652
+ for (const nodes of Object.values(regions)) {
653
+ for (const node of nodes) {
654
+ if (!isLayoutSection(node)) yield node
655
+ }
656
+ }
657
+ }
658
+
659
+ /**
660
+ * Deterministically assign this visitor to a variant of every running A/B
661
+ * experiment the (audience-resolved) layout actually shows, and record the
662
+ * exposure. Stable on visitorId → the visitor keeps the same arm across requests;
663
+ * the write is idempotent on (experiment, visitor). Returns experimentId →
664
+ * chosen variant key for <KywiLayout personalization.experimentAssignments>.
665
+ */
666
+ async function resolveExperimentAssignments(
667
+ runtime: KywiRuntime,
668
+ layout: LayoutDocument | null | undefined,
669
+ audienceId: string | null,
670
+ visitorId: string,
671
+ ): Promise<Record<string, string | null>> {
672
+ if (!layout) return {}
673
+ const resolved = applyPageVariant(layout, audienceId)
674
+ const experimentIds = new Set<string>()
675
+ for (const node of variantContainersInRegions(resolved.regions)) {
676
+ if (!isLayoutSection(node) && node.mode === 'ab_test' && !node.winnerId && node.experimentId) {
677
+ experimentIds.add(node.experimentId)
678
+ }
679
+ }
680
+ if (experimentIds.size === 0) return {}
681
+
682
+ const running = await listRunningExperiments(runtime.db, runtime.siteId)
683
+ const byId = new Map(running.map((e) => [e.id, e]))
684
+ const assignments: Record<string, string | null> = {}
685
+ for (const id of experimentIds) {
686
+ const exp = byId.get(id)
687
+ if (!exp) continue
688
+ const res = await resolveExperimentForContainer(runtime.db, exp, visitorId)
689
+ assignments[id] = res.variantKey
690
+ }
691
+ return assignments
692
+ }
693
+
694
+ /**
695
+ * Return the audience-resolved layout: page variants (audience → whole-page
696
+ * region override) applied, and \`pageVariants\`/\`abExperiments\` stripped so no
697
+ * other audience's content is serialized to this visitor. variantContainer arms
698
+ * still resolve at render time from the threaded PersonalizationState.
699
+ */
700
+ export function personalizeLayout(
701
+ layout: LayoutDocument,
702
+ audienceId: string | null,
703
+ ): LayoutDocument {
704
+ return applyPageVariant(layout, audienceId)
705
+ }
706
+
707
+ /** Is the @kywi-software/js client runtime enabled for this site's theme? */
708
+ export function clientRuntimeEnabled(): boolean {
709
+ const themeName = config.sites[0]?.theme
710
+ const theme = config.themes.find((t) => t.name === themeName) ?? config.themes[0]
711
+ return theme?.personalization?.clientRuntime === true
712
+ }
713
+
714
+ // ─── Self-ID widget (#50) ────────────────────────────────────────────────────
715
+
716
+ /** The self-ID widget config in the serializable shape the client runtime reads. */
717
+ export interface PublicSelfIdWidget {
718
+ fields: Array<{ id: string; label: string; type: 'select'; options?: string[]; required?: boolean }>
719
+ headline: string
720
+ subheadline?: string
721
+ submitLabel: string
722
+ skipLabel?: string
723
+ displayMode: 'modal' | 'inline' | 'slide-in'
724
+ frequency: 'once' | 'session' | 'always'
725
+ trigger: SelfIdTrigger
726
+ }
727
+
728
+ function mapDisplayMode(mode: SelfIdWidgetConfig['displayMode']): PublicSelfIdWidget['displayMode'] {
729
+ if (mode === 'modal') return 'modal'
730
+ if (mode === 'inline') return 'inline'
731
+ return 'slide-in' // hello bars + drawer both animate in from an edge
732
+ }
733
+
734
+ function mapFrequency(freq: SelfIdWidgetConfig['frequency']): PublicSelfIdWidget['frequency'] {
735
+ if (freq === 'once_visitor') return 'once'
736
+ if (freq === 'once_session') return 'session'
737
+ return 'always'
738
+ }
739
+
740
+ /**
741
+ * Resolve the site's stored self-ID widget config into the runtime shape, its
742
+ * picklist fields hydrated. Returns null when there is no config or it references
743
+ * no resolvable fields, so the caller can skip mounting the widget entirely.
744
+ */
745
+ export async function resolveSelfIdWidget(runtime: KywiRuntime): Promise<PublicSelfIdWidget | null> {
746
+ const [widget, allFields] = await Promise.all([
747
+ getSelfIdWidgetConfig(runtime.db, runtime.siteId),
748
+ listSelfIdFields(runtime.db, runtime.siteId),
749
+ ])
750
+ if (!widget) return null
751
+
752
+ const byId = new Map<string, SelfIdField>(allFields.map((f) => [f.id, f]))
753
+ const fields = widget.fieldIds
754
+ .map((id) => byId.get(id))
755
+ .filter((f): f is SelfIdField => f != null)
756
+ .map((f) => ({
757
+ id: f.id,
758
+ label: f.label,
759
+ type: 'select' as const,
760
+ options: f.picklist.map((p) => p.value),
761
+ required: f.required,
762
+ }))
763
+ if (fields.length === 0) return null
764
+
765
+ return {
766
+ fields,
767
+ headline: widget.headline,
768
+ ...(widget.subheadline ? { subheadline: widget.subheadline } : {}),
769
+ submitLabel: widget.submitLabel,
770
+ ...(widget.skipLabel ? { skipLabel: widget.skipLabel } : {}),
771
+ displayMode: mapDisplayMode(widget.displayMode),
772
+ frequency: mapFrequency(widget.frequency),
773
+ trigger: widget.trigger,
774
+ }
775
+ }
776
+ `
777
+ }
778
+
337
779
  // ── middleware.ts (thin framework wiring over @kywi-software/core/host) ───────
338
780
 
339
781
  function middleware() {
@@ -380,6 +822,32 @@ function isAuthEndpoint(pathname: string): boolean {
380
822
  !pathname.startsWith('/api/v1/auth/api-keys')
381
823
  }
382
824
 
825
+ // Anonymous visitor identity for personalization/experiments. A/B assignment is
826
+ // deterministic on this id, so persisting it (2y cookie) is what gives a visitor
827
+ // a STABLE experiment arm across requests without any client runtime (#50). Name
828
+ // matches @kywi-software/core/audiences COOKIE_NAMES.VISITOR; kept as a literal
829
+ // here so the edge middleware never imports the (server-only) audiences module.
830
+ const VISITOR_COOKIE = 'kywi_visitor'
831
+ const VISITOR_MAX_AGE = 60 * 60 * 24 * 365 * 2 // 2 years
832
+
833
+ /**
834
+ * Public (non-admin, non-API) request: never auth-gated. Ensure a stable
835
+ * \`kywi_visitor\` id — mint one on first visit — and forward it to the render as
836
+ * \`x-kywi-visitor\` so the page sees it on this very request (the freshly set
837
+ * cookie is not yet readable via cookies()). Everything else passes through.
838
+ */
839
+ function handlePublicRequest(req: NextRequest): NextResponse {
840
+ const existing = req.cookies.get(VISITOR_COOKIE)?.value
841
+ const visitorId = existing ?? crypto.randomUUID()
842
+ const headers = new Headers(req.headers)
843
+ headers.set('x-kywi-visitor', visitorId)
844
+ const res = NextResponse.next({ request: { headers } })
845
+ if (!existing) {
846
+ res.cookies.set(VISITOR_COOKIE, visitorId, { path: '/', maxAge: VISITOR_MAX_AGE, sameSite: 'lax' })
847
+ }
848
+ return res
849
+ }
850
+
383
851
  async function fetchFreshAccessToken(origin: string, refreshToken: string): Promise<string | undefined> {
384
852
  try {
385
853
  const res = await fetch(origin + '/api/v1/auth/refresh', {
@@ -400,6 +868,11 @@ export async function middleware(req: NextRequest): Promise<NextResponse> {
400
868
  const { pathname } = req.nextUrl
401
869
  if (isAuthEndpoint(pathname)) return NextResponse.next()
402
870
 
871
+ // Public pages are never auth-gated — just give them a stable visitor id.
872
+ const isAdmin = pathname === '/admin' || pathname.startsWith('/admin/')
873
+ const isApi = pathname.startsWith('/api/v1/')
874
+ if (!isAdmin && !isApi) return handlePublicRequest(req)
875
+
403
876
  const accessToken = req.cookies.get(ACCESS_COOKIE)?.value
404
877
  const refreshToken = req.cookies.get(REFRESH_COOKIE)?.value
405
878
  const status = await classifyAccessToken(accessToken, AUTH_SECRET)
@@ -443,7 +916,14 @@ export async function middleware(req: NextRequest): Promise<NextResponse> {
443
916
  }
444
917
 
445
918
  export const config = {
446
- matcher: ['/admin/:path*', '/api/v1/:path*'],
919
+ matcher: [
920
+ '/admin/:path*',
921
+ '/api/v1/:path*',
922
+ // Public pages, for the visitor-id cookie above. Excludes Next internals and
923
+ // any path with a file extension (static assets, /favicon.ico, /kywi.js, and
924
+ // the AX files /robots.txt, /sitemap.xml, /llms*.txt).
925
+ '/((?!_next/|.*\\\\..*).*)',
926
+ ],
447
927
  }
448
928
  `
449
929
  }
@@ -471,24 +951,48 @@ export default function RootLayout({ children }: { children: React.ReactNode })
471
951
  /** @param {Answers} a — coupled public site shell. */
472
952
  function siteLayout(a) {
473
953
  return `import React from 'react'
954
+ import { themeTokenStyleBlock } from '@kywi-software/core/layout'
955
+ // Neutral, token-driven defaults for everything Kywi renders (prose, the layout
956
+ // grid + modules, forms, the edit overlay). Restyle via theme tokens, not by
957
+ // editing this — see README → "Theming".
958
+ import '@kywi-software/core/site/styles.css'
959
+ // This app's OWN chrome (header / footer / page wrapper). Yours to edit freely.
960
+ import './site.css'
961
+ import config from '../../kywi.config'
474
962
 
475
963
  /**
476
- * Public site shell. The header carries this project's name; edit freely — this
477
- * is your app's own layer over the DB-backed content that ${'app/(site)/[[...slug]]'} renders.
964
+ * Public site shell. Header + footer carry this project's brand; edit them (and
965
+ * site.css) freely — this is your app's own layer over the DB-backed content
966
+ * that ${'app/(site)/[[...slug]]'} renders.
967
+ *
968
+ * The active site's theme tokens (kywi.config.ts → themes[].tokens) are flattened
969
+ * into a \`:root { --kywi-* }\` block by \`themeTokenStyleBlock\` and injected below,
970
+ * so kywi.config.ts is the single source of truth for the palette and spacing and
971
+ * both site.css and core's default styles resolve against those variables.
478
972
  */
973
+ const siteTheme =
974
+ config.themes.find((t) => t.name === config.sites[0]?.theme) ?? config.themes[0]
975
+ const themeVars = themeTokenStyleBlock(siteTheme?.tokens)
976
+
479
977
  export default function SiteLayout({ children }: { children: React.ReactNode }) {
480
978
  return (
481
- <div>
482
- <header style={{ borderBottom: '1px solid #e2e8f0', background: '#fff' }}>
483
- <div style={{ maxWidth: 960, margin: '0 auto', padding: '1rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
484
- <a href="/" style={{ fontWeight: 700, fontSize: '1.125rem', color: '#0f172a', textDecoration: 'none' }}>${escapeJsxText(a.projectName)}</a>
485
- <a href="/admin" style={{ color: '#2563eb', textDecoration: 'none', fontSize: '0.9375rem' }}>Admin →</a>
979
+ <div className="site-shell">
980
+ {themeVars ? <style dangerouslySetInnerHTML={{ __html: themeVars }} /> : null}
981
+
982
+ <header className="site-header">
983
+ <div className="site-header__inner">
984
+ <a className="site-brand" href="/">${escapeJsxText(a.projectName)}</a>
985
+ <nav className="site-nav" aria-label="Primary">
986
+ <a className="site-nav__admin" href="/admin">Admin →</a>
987
+ </nav>
486
988
  </div>
487
989
  </header>
488
- <main style={{ maxWidth: 720, margin: '0 auto', padding: '2.5rem 1rem' }}>{children}</main>
489
- <footer style={{ borderTop: '1px solid #e2e8f0', marginTop: '3rem' }}>
490
- <div style={{ maxWidth: 960, margin: '0 auto', padding: '1.5rem 1rem', color: '#64748b', fontSize: '0.875rem' }}>
491
- Powered by <a href="https://kywi.dev" style={{ color: '#64748b' }}>Kywi CMS</a>
990
+
991
+ <main className="site-main">{children}</main>
992
+
993
+ <footer className="site-footer">
994
+ <div className="site-footer__inner">
995
+ Powered by <a href="https://kywi.dev">Kywi CMS</a>
492
996
  </div>
493
997
  </footer>
494
998
  </div>
@@ -497,41 +1001,489 @@ export default function SiteLayout({ children }: { children: React.ReactNode })
497
1001
  `
498
1002
  }
499
1003
 
500
- /** Coupled catch-all: renders home ("/") and any published page at its slug. */
1004
+ /** @param {Answers} a the app's OWN public-site chrome CSS (header/footer/page). */
1005
+ function siteStyles(a) {
1006
+ return `/*
1007
+ * ${a.projectName} — public-site chrome.
1008
+ *
1009
+ * This is YOUR stylesheet: the site header, footer, and page wrapper. It is a
1010
+ * thin layer over @kywi-software/core/site/styles.css (imported alongside it in
1011
+ * app/(site)/layout.tsx), which already styles everything Kywi RENDERS (prose,
1012
+ * layout modules, forms, the edit overlay).
1013
+ *
1014
+ * Everything here reads the theme's --kywi-* design tokens (kywi.config.ts →
1015
+ * themes[].tokens) with a neutral fallback, so editing a token restyles the
1016
+ * whole site. Prefer changing a token over hardcoding a value here.
1017
+ */
1018
+
1019
+ .site-shell {
1020
+ min-height: 100vh;
1021
+ display: flex;
1022
+ flex-direction: column;
1023
+ color: var(--kywi-color-text, #0f172a);
1024
+ background: var(--kywi-color-background, #ffffff);
1025
+ font-family: var(--kywi-font-family-base, system-ui, -apple-system, sans-serif);
1026
+ }
1027
+
1028
+ /* Header */
1029
+ .site-header {
1030
+ border-bottom: 1px solid var(--kywi-color-border, #e2e8f0);
1031
+ background: var(--kywi-color-background, #ffffff);
1032
+ }
1033
+ .site-header__inner {
1034
+ max-width: 960px;
1035
+ margin: 0 auto;
1036
+ padding: var(--kywi-spacing-md, 1rem);
1037
+ display: flex;
1038
+ align-items: center;
1039
+ justify-content: space-between;
1040
+ }
1041
+ .site-brand {
1042
+ font-weight: 700;
1043
+ font-size: 1.125rem;
1044
+ color: var(--kywi-color-heading, #0f172a);
1045
+ text-decoration: none;
1046
+ }
1047
+ .site-nav__admin {
1048
+ color: var(--kywi-color-primary, #2563eb);
1049
+ text-decoration: none;
1050
+ font-size: 0.9375rem;
1051
+ }
1052
+
1053
+ /* Main content column */
1054
+ .site-main {
1055
+ flex: 1;
1056
+ width: 100%;
1057
+ max-width: 760px;
1058
+ margin: 0 auto;
1059
+ padding: var(--kywi-spacing-xl, 2.5rem) var(--kywi-spacing-md, 1rem);
1060
+ }
1061
+
1062
+ /* A page rendered from the DB (title + body, or the full layout). */
1063
+ .page__title {
1064
+ font-size: 2rem;
1065
+ line-height: 1.15;
1066
+ margin: 0 0 var(--kywi-spacing-lg, 1.5rem);
1067
+ color: var(--kywi-color-heading, #0f172a);
1068
+ }
1069
+ .page__featured {
1070
+ display: block;
1071
+ width: 100%;
1072
+ height: auto;
1073
+ border-radius: var(--kywi-border-radius-md, 10px);
1074
+ margin: 0 0 var(--kywi-spacing-lg, 1.5rem);
1075
+ }
1076
+ .page__empty { color: var(--kywi-color-muted, #64748b); }
1077
+ /* When a page is designed in the Layout tab it spans the full column. */
1078
+ .page--layout { max-width: none; }
1079
+
1080
+ /* Footer */
1081
+ .site-footer {
1082
+ border-top: 1px solid var(--kywi-color-border, #e2e8f0);
1083
+ margin-top: var(--kywi-spacing-xl, 2.5rem);
1084
+ }
1085
+ .site-footer__inner {
1086
+ max-width: 960px;
1087
+ margin: 0 auto;
1088
+ padding: var(--kywi-spacing-lg, 1.5rem) var(--kywi-spacing-md, 1rem);
1089
+ color: var(--kywi-color-muted, #64748b);
1090
+ font-size: 0.875rem;
1091
+ }
1092
+ .site-footer__inner a { color: var(--kywi-color-muted, #64748b); }
1093
+ `
1094
+ }
1095
+
1096
+ /** Coupled catch-all: renders home ("/") and any published page at its path. */
501
1097
  function siteSlugPage() {
502
- return `import React from 'react'
1098
+ return `import React, { cache } from 'react'
1099
+ import type { Metadata } from 'next'
503
1100
  import { notFound } from 'next/navigation'
504
- import { KywiBody } from '@kywi-software/core/scope-client'
1101
+ import { cookies, headers } from 'next/headers'
1102
+ import { KywiBody, KywiEditableAttribute, KywiEditableRegion } from '@kywi-software/core/scope-client'
1103
+ import { KywiLayout, KywiRegion, AudienceMetaTags, hydrateLayoutFeeds, type LayoutDocument } from '@kywi-software/core/layout'
1104
+ import { KywiJsonLd } from '@kywi-software/core/scope'
1105
+ import { ACCESS_COOKIE, canAccessContent, readSessionClaims } from '@kywi-software/core/host'
1106
+ import config from '../../../lib/config'
505
1107
  import { getKywi } from '../../../lib/kywi'
1108
+ import {
1109
+ resolvePublicContent,
1110
+ resolvePersonalization,
1111
+ personalizeLayout,
1112
+ resolveSelfIdWidget,
1113
+ clientRuntimeEnabled,
1114
+ buildFeedResolver,
1115
+ buildComponentResolver,
1116
+ localeAlternates,
1117
+ mediaUrl,
1118
+ requestBaseUrl,
1119
+ } from '../../../lib/site'
1120
+ import { moduleComponents } from '../../../lib/modules'
1121
+ import { PersonalizationRuntime } from '../../../components/personalization-runtime'
1122
+ import { KywiFrontEdit } from '../kywi-front-edit'
506
1123
 
507
1124
  // Every page comes from the database, so this route is always dynamic.
508
1125
  export const dynamic = 'force-dynamic'
509
1126
 
510
1127
  type Params = { params: Promise<{ slug?: string[] }> }
511
-
512
- // "/" resolves the seeded Home node; any other path resolves the published node
513
- // whose slug is the last URL segment. Draft / missing content 404s.
514
- export default async function PublicPage({ params }: Params) {
1128
+ type Search = { searchParams: Promise<Record<string, string | string[] | undefined>> }
1129
+
1130
+ // One resolution shared by generateMetadata and the page render (React.cache
1131
+ // dedupes within a request). Keyed on stable primitives so the two calls dedupe:
1132
+ // the slug segments joined (segments never contain "/") and the Accept-Language
1133
+ // header, which selects the locale variant.
1134
+ const getNode = cache((key: string, acceptLanguage: string | null) =>
1135
+ resolvePublicContent(key ? key.split('/') : [], { acceptLanguage }),
1136
+ )
1137
+
1138
+ // SEO: map the admin's SEO-tab fields — Meta Title / Description / Keywords, the
1139
+ // OG Image (metaImageId, falling back to the page's Featured Image), Canonical,
1140
+ // Robots index/follow, and (on a multi-locale site) hreflang alternates — onto
1141
+ // Next's Metadata. The sitemap-only fields (changeFreq, sitemapPriority) are not
1142
+ // <head> metadata.
1143
+ export async function generateMetadata({ params }: Params): Promise<Metadata> {
515
1144
  const { slug } = await params
516
- const { scope, siteId } = await getKywi()
517
- const target = slug && slug.length > 0 ? slug[slug.length - 1]! : 'home'
1145
+ const acceptLanguage = (await headers()).get('accept-language')
1146
+ const node = await getNode((slug ?? []).join('/'), acceptLanguage)
1147
+ if (!node || node['status'] !== 'published') return {}
1148
+
1149
+ const title = (node['metaTitle'] as string) || String(node['title'] ?? 'Untitled')
1150
+ const description = (node['metaDescription'] as string) || undefined
1151
+ const keywords = (node['metaKeywords'] as string) || undefined
1152
+ const image = mediaUrl(node['metaImageId']) ?? mediaUrl(node['featuredImageId'])
1153
+ const images = image ? [image] : undefined
1154
+ const canonical = (node['canonicalUrl'] as string) || undefined
1155
+ const languages = localeAlternates(slug ?? [])
1156
+ const alternates =
1157
+ canonical || languages
1158
+ ? { ...(canonical ? { canonical } : {}), ...(languages ? { languages } : {}) }
1159
+ : undefined
1160
+
1161
+ return {
1162
+ title,
1163
+ description,
1164
+ keywords,
1165
+ ...(alternates ? { alternates } : {}),
1166
+ robots: { index: node['robotsIndex'] !== 'noindex', follow: node['robotsFollow'] !== 'nofollow' },
1167
+ openGraph: { title, description, images, type: 'website' },
1168
+ twitter: { card: images ? 'summary_large_image' : 'summary', title, description, images },
1169
+ }
1170
+ }
1171
+
1172
+ // True once the Layout editor has placed at least one section into any region.
1173
+ // A page with an empty layout document falls back to its Body rich text.
1174
+ function hasRenderableLayout(layout: LayoutDocument | null | undefined): layout is LayoutDocument {
1175
+ if (!layout || typeof layout !== 'object' || !layout.regions) return false
1176
+ return Object.values(layout.regions).some(
1177
+ (sections) => Array.isArray(sections) && sections.length > 0,
1178
+ )
1179
+ }
518
1180
 
519
- const node = (await scope.content.getBySlug(target, siteId)) as Record<string, unknown> | null
1181
+ // Is the current visitor a signed-in admin who may edit? The public route is
1182
+ // outside the middleware's auth matcher, so verify the httpOnly session cookie
1183
+ // here and derive the content permissions the overlay needs. Returns null for
1184
+ // anyone who cannot edit — no edit DOM is emitted for them.
1185
+ async function resolveEditPermissions() {
1186
+ const token = (await cookies()).get(ACCESS_COOKIE)?.value
1187
+ const claims = await readSessionClaims(token, config.auth.secret)
1188
+ if (!claims || !canAccessContent(claims.role, 'write')) return null
1189
+ return { canEdit: true, canPublish: canAccessContent(claims.role, 'publish') }
1190
+ }
1191
+
1192
+ // "/" resolves the seeded Home node; any other URL resolves the published node at
1193
+ // that FULL Site Tree path (locale-aware), so /a/b/<slug> cannot serve a
1194
+ // top-level <slug> (#30, #51). Draft / missing content 404s.
1195
+ export default async function PublicPage({ params, searchParams }: Params & Search) {
1196
+ const { slug } = await params
1197
+ const acceptLanguage = (await headers()).get('accept-language')
1198
+ const node = await getNode((slug ?? []).join('/'), acceptLanguage)
520
1199
  if (!node || node['status'] !== 'published') notFound()
521
1200
 
1201
+ const runtime = await getKywi()
1202
+ const contentId = String(node['id'] ?? '')
1203
+ const contentType = String(node['contentTypeName'] ?? 'page')
522
1204
  const title = String(node['title'] ?? 'Untitled')
523
1205
  const body = (node['body'] as string) || ''
1206
+ const featured = mediaUrl(node['featuredImageId'])
1207
+ const layout = node['layout'] as LayoutDocument | null | undefined
1208
+
1209
+ // ?kywi-edit=1 opts an authenticated admin into the front-of-site overlay.
1210
+ const editRequested = (await searchParams)['kywi-edit'] === '1'
1211
+ const perms = editRequested ? await resolveEditPermissions() : null
1212
+
1213
+ // Server-side personalization (#50), evaluated once: the winning audience (or a
1214
+ // kywi_preview_init preview) drives page variants + variantContainers, and this
1215
+ // visitor's A/B arms are assigned deterministically. Opted-out / anonymous
1216
+ // visitors resolve to the default experience.
1217
+ const perso = await resolvePersonalization(runtime, layout)
1218
+ const selfIdWidget = await resolveSelfIdWidget(runtime)
1219
+
1220
+ // <head> injections (React hoists these): per-page JSON-LD gated on the
1221
+ // AX/JSON-LD setting, mapping the SAME node so page and schema agree (#56); the
1222
+ // Comments module's content-id anchor (#29); the audience/visitor ids for
1223
+ // client tooling (#50); and — when the theme opts in — the optional client
1224
+ // runtime that mounts the self-ID widget and re-evaluates audiences.
1225
+ const baseUrl = await requestBaseUrl()
1226
+ const head = (
1227
+ <>
1228
+ <meta name="kywi:content-id" content={contentId} />
1229
+ <KywiJsonLd node={node} config={runtime.config} baseUrl={baseUrl} siteId={runtime.siteId} />
1230
+ <AudienceMetaTags audienceId={perso.audienceId} visitorId={perso.visitorId} />
1231
+ {clientRuntimeEnabled() && perso.audiences.length > 0 ? (
1232
+ <PersonalizationRuntime
1233
+ audiences={perso.audiences}
1234
+ serverSignals={perso.signals}
1235
+ selfIdWidget={selfIdWidget}
1236
+ />
1237
+ ) : null}
1238
+ </>
1239
+ )
1240
+
1241
+ // When the Layout tab has designed the page, render its region-based document
1242
+ // through core's own layout renderer (KywiLayout / KywiRegion). Before that:
1243
+ // hydrate every Feed Display module (#28), resolve connected reusable
1244
+ // components (#46), and supply the custom-module renderers (#48). Otherwise
1245
+ // fall back to the built-in Body rich text (+ any Featured Image).
1246
+ let content: React.ReactNode
1247
+ if (hasRenderableLayout(layout)) {
1248
+ // Apply the audience's page variant (and strip the other variants), then
1249
+ // hydrate feeds and resolve components on the layout THIS visitor sees.
1250
+ // variantContainer arms resolve at render time from \`personalization\`.
1251
+ const personalized = personalizeLayout(layout, perso.audienceId)
1252
+ const hydrated = await hydrateLayoutFeeds(personalized, buildFeedResolver(runtime))
1253
+ const componentResolver = await buildComponentResolver(hydrated, runtime)
1254
+ content = (
1255
+ <article className="page page--layout" data-kywi-content-id={contentId}>
1256
+ {head}
1257
+ <KywiLayout
1258
+ layout={hydrated}
1259
+ personalization={perso.personalization}
1260
+ moduleComponents={moduleComponents}
1261
+ {...(componentResolver ? { componentResolver } : {})}
1262
+ >
1263
+ {Object.keys(hydrated.regions).map((name) => (
1264
+ <KywiRegion key={name} name={name} />
1265
+ ))}
1266
+ </KywiLayout>
1267
+ </article>
1268
+ )
1269
+ } else {
1270
+ content = (
1271
+ <article className="page" data-kywi-content-id={contentId}>
1272
+ {head}
1273
+ {featured ? <img className="page__featured" src={featured} alt="" /> : null}
1274
+ <h1 className="page__title">{title}</h1>
1275
+ {perms ? (
1276
+ <KywiEditableRegion region="main">
1277
+ <KywiEditableAttribute field="body" type="richtext" contentId={contentId}>
1278
+ {body ? <KywiBody content={body} /> : <p className="page__empty">This page has no content yet.</p>}
1279
+ </KywiEditableAttribute>
1280
+ </KywiEditableRegion>
1281
+ ) : body ? (
1282
+ <KywiBody content={body} />
1283
+ ) : (
1284
+ <p className="page__empty">This page has no content yet.</p>
1285
+ )}
1286
+ </article>
1287
+ )
1288
+ }
1289
+
1290
+ // Mount the overlay (client) only for an authenticated admin who asked for it.
1291
+ if (perms) {
1292
+ return (
1293
+ <KywiFrontEdit
1294
+ canEdit={perms.canEdit}
1295
+ canPublish={perms.canPublish}
1296
+ contentId={contentId}
1297
+ pageTitle={title}
1298
+ pageStatus={String(node['status'] ?? '')}
1299
+ adminHref={\`/admin/content/\${contentType}/\${contentId}\`}
1300
+ >
1301
+ {content}
1302
+ </KywiFrontEdit>
1303
+ )
1304
+ }
1305
+
1306
+ return content
1307
+ }
1308
+ `
1309
+ }
1310
+
1311
+ /**
1312
+ * Front-of-site edit overlay (client). Mounted by the public page ONLY for an
1313
+ * authenticated admin who opened the page with ?kywi-edit=1 (the page verifies
1314
+ * the session cookie server-side first). Surfaces core's KywiEditToolbar and
1315
+ * enters edit mode, which outlines the editable regions core already marks with
1316
+ * the `data-kywi-*` protocol. Real content edits happen in the full admin editor
1317
+ * (the toolbar's Admin link deep-links to this page there); Publish posts to the
1318
+ * API for admins with publish permission.
1319
+ */
1320
+ function frontEditOverlay() {
1321
+ return `'use client'
1322
+ import React from 'react'
1323
+ import { KywiEditToolbar, useKywiEditMode } from '@kywi-software/core/scope-client'
1324
+ import { adminFetch } from '@kywi-software/core/host-client'
1325
+
1326
+ export interface KywiFrontEditProps {
1327
+ canEdit: boolean
1328
+ canPublish: boolean
1329
+ contentId: string
1330
+ pageTitle: string
1331
+ pageStatus: string
1332
+ /** Deep link to this page in the full admin editor. */
1333
+ adminHref: string
1334
+ children: React.ReactNode
1335
+ }
1336
+
1337
+ /**
1338
+ * Wraps the public page with the front-of-site edit affordance. The page only
1339
+ * renders this for a signed-in admin who asked to edit (?kywi-edit=1), so the
1340
+ * toolbar's own permission gate (canEdit) is always satisfied here.
1341
+ */
1342
+ export function KywiFrontEdit({
1343
+ canEdit,
1344
+ canPublish,
1345
+ contentId,
1346
+ pageTitle,
1347
+ pageStatus,
1348
+ adminHref,
1349
+ children,
1350
+ }: KywiFrontEditProps) {
1351
+ const edit = useKywiEditMode({ canEdit, canPublish })
1352
+
1353
+ // ?kywi-edit=1 means "enter edit mode now" — flip it on once after mount.
1354
+ const { startEdit } = edit
1355
+ React.useEffect(() => {
1356
+ startEdit()
1357
+ }, [startEdit])
1358
+
1359
+ const handlePublish = React.useCallback(async () => {
1360
+ const res = await adminFetch(\`/api/v1/content/by-id/\${contentId}/publish\`, { method: 'POST' })
1361
+ if (res.ok) window.location.reload()
1362
+ }, [contentId])
524
1363
 
525
1364
  return (
526
- <article>
527
- <h1 style={{ fontSize: '2rem', marginBottom: '1rem', color: '#0f172a' }}>{title}</h1>
528
- {body ? <KywiBody content={body} /> : <p style={{ color: '#64748b' }}>This page has no content yet.</p>}
529
- </article>
1365
+ <>
1366
+ <KywiEditToolbar
1367
+ isEditMode={edit.isEditMode}
1368
+ canEdit={edit.canEdit}
1369
+ canPublish={edit.canPublish}
1370
+ onToggleEdit={edit.toggleEdit}
1371
+ onPublish={handlePublish}
1372
+ pageTitle={pageTitle}
1373
+ pageStatus={pageStatus}
1374
+ adminHref={adminHref}
1375
+ />
1376
+ {/* Toggling this attribute drives the editable-region outlines shipped in
1377
+ @kywi-software/core/site/styles.css. */}
1378
+ <div data-kywi-editing={edit.isEditMode ? '' : undefined}>{children}</div>
1379
+ </>
530
1380
  )
531
1381
  }
532
1382
  `
533
1383
  }
534
1384
 
1385
+ /**
1386
+ * Client personalization runtime (kywi-cms#50). The SERVER already resolved and
1387
+ * rendered the correct audience/experiment variant and set the ids in <head>;
1388
+ * this optional client layer adds live re-evaluation, the self-ID widget, the
1389
+ * transparency bar, and behavioral-signal collection by booting the
1390
+ * @kywi-software/js browser bundle from /kywi.js. It is best-effort and mounted
1391
+ * only when the theme opts in (theme.personalization.clientRuntime), so the site
1392
+ * stays dependency-free by default: drop the built @kywi-software/js bundle at
1393
+ * public/kywi.js to enable it (see README → "Personalization"). Without it the
1394
+ * server-rendered variant is exactly what every visitor sees.
1395
+ */
1396
+ function personalizationRuntime() {
1397
+ return `'use client'
1398
+
1399
+ import React, { useEffect } from 'react'
1400
+ import type { Audience, VisitorSignals } from '@kywi-software/core/audiences/types'
1401
+ import type { PublicSelfIdWidget } from '../lib/site'
1402
+
1403
+ /* eslint-disable @typescript-eslint/no-explicit-any */
1404
+ declare global {
1405
+ interface Window {
1406
+ Kywi?: any
1407
+ kywi?: any
1408
+ }
1409
+ }
1410
+
1411
+ interface PersonalizationRuntimeProps {
1412
+ /** Active audiences the client re-evaluates (rule definitions, no PII). */
1413
+ audiences: Audience[]
1414
+ /** Server-resolved signals (UTM / referrer / identity / opt-out) for parity. */
1415
+ serverSignals: Partial<VisitorSignals>
1416
+ /** Resolved self-ID widget config; when present its trigger/frequency mount it. */
1417
+ selfIdWidget?: PublicSelfIdWidget | null
1418
+ }
1419
+
1420
+ /**
1421
+ * Loads /kywi.js and boots the audience data-layer. bootAudienceEngine reads the
1422
+ * opt-out cookie itself and resolves opted-out visitors to the default
1423
+ * experience, so opt-out is respected end to end. Rendered only when
1424
+ * theme.personalization.clientRuntime is on.
1425
+ */
1426
+ export function PersonalizationRuntime({ audiences, serverSignals, selfIdWidget }: PersonalizationRuntimeProps) {
1427
+ useEffect(() => {
1428
+ let cancelled = false
1429
+ const ready = () => typeof window.Kywi?.bootAudienceEngine === 'function'
1430
+
1431
+ function boot(): void {
1432
+ if (cancelled || !ready()) return
1433
+ const currentPath = window.location.pathname
1434
+ window.Kywi
1435
+ .bootAudienceEngine({
1436
+ audiences,
1437
+ currentPath,
1438
+ serverSignals,
1439
+ ...(selfIdWidget
1440
+ ? { selfIdWidget: { ...selfIdWidget, audiences: audiences.map((a) => a.id), currentPath } }
1441
+ : {}),
1442
+ })
1443
+ .catch(() => {
1444
+ /* client personalization is best-effort; the server already rendered defaults */
1445
+ })
1446
+ }
1447
+
1448
+ function whenReady(): void {
1449
+ if (ready()) return boot()
1450
+ let tries = 0
1451
+ const timer = setInterval(() => {
1452
+ tries += 1
1453
+ if (ready()) {
1454
+ clearInterval(timer)
1455
+ boot()
1456
+ } else if (tries > 50) {
1457
+ clearInterval(timer)
1458
+ }
1459
+ }, 60)
1460
+ }
1461
+
1462
+ const existing = document.querySelector<HTMLScriptElement>('script[data-kywi-js]')
1463
+ if (existing) {
1464
+ if (ready()) boot()
1465
+ else existing.addEventListener('load', whenReady)
1466
+ } else {
1467
+ const script = document.createElement('script')
1468
+ script.src = '/kywi.js'
1469
+ script.async = true
1470
+ script.dataset.kywiJs = 'true'
1471
+ script.addEventListener('load', whenReady)
1472
+ document.body.appendChild(script)
1473
+ }
1474
+
1475
+ return () => {
1476
+ cancelled = true
1477
+ }
1478
+ // Boot once on mount; audiences/serverSignals are per-request stable.
1479
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1480
+ }, [])
1481
+
1482
+ return null
1483
+ }
1484
+ `
1485
+ }
1486
+
535
1487
  /** @param {Answers} a — headless/decoupled root page (no public rendering). */
536
1488
  function headlessHomePage(a) {
537
1489
  const note =
@@ -548,6 +1500,33 @@ export default function HomePage() {
548
1500
  `
549
1501
  }
550
1502
 
1503
+ // ── AX root routes (llms.txt / sitemap.xml / robots.txt / llms-full.txt) ──────
1504
+
1505
+ /**
1506
+ * @param {string} axPath — the AX filename core's handler serves (e.g. 'llms.txt').
1507
+ * A thin root-level Route Handler that delegates to the SAME core AX generator
1508
+ * the /api/v1/[axPath] catch-all uses, so the *core* generators stay the single,
1509
+ * cache-aware, hook-invalidated source of truth. The Agent Experience surface
1510
+ * advertises these at the ROOT (its robots.txt even emits `Sitemap:` / `# LLM
1511
+ * index:` lines pointing at root URLs), but the scaffold previously mounted them
1512
+ * only under /api/v1/* — so every advertised root URL 404'd (kywi-cms#55). These
1513
+ * routes close that gap. `force-dynamic` because core owns caching (no
1514
+ * double-caching at the edge); decoupled/headless consumers may still hit
1515
+ * /api/v1/[axPath] directly.
1516
+ */
1517
+ function axRootRoute(axPath) {
1518
+ return `import { getKywiHandler } from '../../lib/kywi'
1519
+
1520
+ /** Root-level \`/${axPath}\` — delegates to the core AX generator (kywi-cms#55). */
1521
+ export const dynamic = 'force-dynamic'
1522
+
1523
+ export async function GET(req: Request): Promise<Response> {
1524
+ const handler = await getKywiHandler()
1525
+ return handler.handle(req, ['${axPath}'])
1526
+ }
1527
+ `
1528
+ }
1529
+
551
1530
  // ── API route (thin proxy over @kywi-software/core/host) ──────────────────────
552
1531
 
553
1532
  function apiRoute() {
@@ -631,11 +1610,15 @@ function adminCatchAllPage() {
631
1610
  * design-system tokens/inputs live there, so without it every surface (and the
632
1611
  * login page) renders unstyled. Do not remove it.
633
1612
  *
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 === [])
1613
+ * Server-side 404 (#58): the segments are resolved with the three-state
1614
+ * resolveSurfaceState helper. Next's notFound() fires for a real HTTP 404 ONLY
1615
+ * on a genuinely unknown path (status 'not-found'). A KNOWN surface held off by
1616
+ * the config ceiling (status 'config-disabled') is NOT hard-404'd here it
1617
+ * falls through to KywiAdminApp, which renders the same in-shell "disabled"
1618
+ * message a runtime-disabled surface gets, so both disable paths present
1619
+ * consistently instead of hard-404 vs soft-hide. The \`login\` segment is exempt
1620
+ * (not a registry surface — the middleware's auth exception renders it
1621
+ * chrome-less through KywiAdminApp); the bare /admin index (segments === [])
639
1622
  * resolves to the dashboard, so it never 404s.
640
1623
  *
641
1624
  * You should not need to edit this file. To disable surfaces per deployment, set
@@ -644,10 +1627,13 @@ function adminCatchAllPage() {
644
1627
  */
645
1628
  import { notFound } from 'next/navigation'
646
1629
  import { KywiAdminApp } from '@kywi-software/core/admin/app'
647
- import { toAdminRuntimeConfig, resolveSurface } from '@kywi-software/core/admin/server'
1630
+ import { toAdminRuntimeConfig, resolveSurfaceState } from '@kywi-software/core/admin/server'
648
1631
  // CRITICAL: load the admin design system once for the whole mount.
649
1632
  import '@kywi-software/core/admin/styles.css'
650
1633
  import kywiConfig from '../../../lib/config'
1634
+ // Custom (defineModule) renderers, shared with the public layout so the Layout
1635
+ // editor canvas renders them the same as the live site (#48). Empty by default.
1636
+ import { moduleComponents } from '../../../lib/modules'
651
1637
 
652
1638
  export default async function AdminCatchAll({
653
1639
  params,
@@ -662,15 +1648,75 @@ export default async function AdminCatchAll({
662
1648
 
663
1649
  const runtime = toAdminRuntimeConfig(kywiConfig)
664
1650
 
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.
1651
+ // Real HTTP 404 only for a genuinely unknown admin path. A config-disabled
1652
+ // surface is a known route held off by the ceiling it renders the consistent
1653
+ // in-shell "disabled" message via KywiAdminApp rather than a hard 404 (#58).
1654
+ // The login segment is not a surface (chrome-less middleware exception); the
1655
+ // bare index resolves to the dashboard, so both skip the 404 pre-check.
668
1656
  const isLogin = admin.length === 1 && admin[0] === 'login'
669
- if (!isLogin && resolveSurface(admin, runtime.admin) === null) {
1657
+ if (!isLogin && resolveSurfaceState(admin, runtime.admin).status === 'not-found') {
670
1658
  notFound()
671
1659
  }
672
1660
 
673
- return <KywiAdminApp runtime={runtime} segments={admin} loginNext={next} />
1661
+ return (
1662
+ <KywiAdminApp
1663
+ runtime={runtime}
1664
+ segments={admin}
1665
+ loginNext={next}
1666
+ moduleComponents={moduleComponents}
1667
+ />
1668
+ )
1669
+ }
1670
+ `
1671
+ }
1672
+
1673
+ // ── custom module renderers (lib/modules.tsx) ─────────────────────────────────
1674
+
1675
+ /**
1676
+ * The shared custom-module component map (kywi-cms#48). Imported by BOTH the
1677
+ * admin host page (→ KywiAdminApp) and the public layout renderer (→ KywiLayout)
1678
+ * so a `defineModule`-registered module resolves to the same component in the
1679
+ * editor canvas and on the live site. Ships empty (built-ins need no entry) with
1680
+ * a worked example, so the extension path is discoverable without docs-diving.
1681
+ */
1682
+ function libModules() {
1683
+ return `import type { ModuleComponentMap } from '@kywi-software/core/layout'
1684
+
1685
+ /**
1686
+ * Custom module renderers (kywi-cms#48).
1687
+ *
1688
+ * A module you register with \`defineModule\` in kywi.config.ts is authorable in
1689
+ * the Layout editor, but the PUBLIC site (and the editor canvas) still need the
1690
+ * React component that RENDERS it. Map each custom module's \`name\` → its
1691
+ * component here; this map is passed to BOTH the admin (KywiAdminApp) and the
1692
+ * public layout renderer (KywiLayout), so the module resolves to your component
1693
+ * instead of an "Unknown module" placeholder everywhere. Built-in modules (hero,
1694
+ * cards, CTA, testimonials, feed display, …) need no entry; an entry that reuses
1695
+ * a built-in \`name\` overrides that built-in.
1696
+ *
1697
+ * Example — register a module in kywi.config.ts and render it here:
1698
+ *
1699
+ * // kywi.config.ts
1700
+ * import { defineModule } from '@kywi-software/core/config'
1701
+ * // …inside defineKywiConfig({ … }):
1702
+ * // modules: [
1703
+ * // defineModule({ name: 'pricingTable', label: 'Pricing Table', component: 'pricingTable' }),
1704
+ * // ],
1705
+ *
1706
+ * // lib/pricing-table.tsx
1707
+ * 'use client'
1708
+ * export function PricingTable({ props }: { props: Record<string, unknown> }) {
1709
+ * return <div className="pricing-table">{String(props.heading ?? '')}</div>
1710
+ * }
1711
+ *
1712
+ * // here:
1713
+ * import { PricingTable } from './pricing-table'
1714
+ * export const moduleComponents: ModuleComponentMap = {
1715
+ * pricingTable: PricingTable,
1716
+ * }
1717
+ */
1718
+ export const moduleComponents: ModuleComponentMap = {
1719
+ // Add custom module renderers here, keyed by the \`name\` you gave defineModule.
674
1720
  }
675
1721
  `
676
1722
  }
@@ -693,6 +1739,94 @@ function readme(a) {
693
1739
  a.mode === 'decoupled'
694
1740
  ? `\n### Separate frontend\n\nInstall \`@kywi-software/sdk\` in your frontend project and point it at this API:\n\n\`\`\`ts\nimport { createKywiSdk } from '@kywi-software/sdk'\nconst kywi = createKywiSdk({ baseUrl: 'http://localhost:3000/api/v1' })\n\`\`\`\n`
695
1741
  : ''
1742
+ // Theming only applies where THIS app renders the public site (coupled mode).
1743
+ const themingBlock =
1744
+ a.mode === 'coupled'
1745
+ ? `
1746
+ ## Theming
1747
+
1748
+ The public site is styled entirely from **design tokens** you set on a theme in
1749
+ \`kywi.config.ts\`. No CSS editing required — change a token and the whole site
1750
+ restyles.
1751
+
1752
+ \`\`\`ts
1753
+ defineTheme({
1754
+ name: 'default',
1755
+ regions: [/* … */],
1756
+ tokens: {
1757
+ colors: { primary: '#2563eb', text: '#0f172a', border: '#e2e8f0', /* … */ },
1758
+ spacing: { xs: '0.25rem', sm: '0.5rem', md: '1rem', lg: '1.5rem', xl: '2.5rem' },
1759
+ borderRadius: { sm: '6px', md: '10px', lg: '16px' },
1760
+ typography: { 'family-base': 'system-ui, sans-serif' },
1761
+ // Responsive override: a \`key@breakpoint\` suffix emits an @media rule.
1762
+ // e.g. spacing: { 'lg@mobile': '1rem' }
1763
+ },
1764
+ })
1765
+ \`\`\`
1766
+
1767
+ **How it flows.** \`app/(site)/layout.tsx\` calls \`themeTokenStyleBlock(theme.tokens)\`
1768
+ and injects the result as a \`:root { --kywi-* }\` block. Every token becomes a CSS
1769
+ custom property under a predictable name:
1770
+
1771
+ | Token category | CSS variable | Example |
1772
+ | --------------- | ----------------------- | --------------------------- |
1773
+ | \`colors\` | \`--kywi-color-*\` | \`--kywi-color-primary\` |
1774
+ | \`spacing\` | \`--kywi-spacing-*\` | \`--kywi-spacing-lg\` |
1775
+ | \`typography\` | \`--kywi-font-*\` | \`--kywi-font-family-base\` |
1776
+ | \`borderRadius\` | \`--kywi-border-radius-*\`| \`--kywi-border-radius-md\` |
1777
+ | \`shadow\` | \`--kywi-shadow-*\` | \`--kywi-shadow-card\` |
1778
+
1779
+ Two stylesheets consume those variables (both imported in \`app/(site)/layout.tsx\`):
1780
+
1781
+ - \`@kywi-software/core/site/styles.css\` — neutral, element-level defaults for
1782
+ **everything Kywi renders**: prose, the layout-engine grid and modules (hero,
1783
+ cards, CTA, testimonials, feed display, …), and embedded forms. Restyle it by
1784
+ overriding tokens rather than editing rules.
1785
+ - \`app/(site)/site.css\` — **your** site chrome (header, footer, page wrapper).
1786
+ Yours to edit; it reads the same \`--kywi-*\` variables with fallbacks.
1787
+
1788
+ **Responsive tokens.** Suffix a token key with \`@breakpoint\` (\`mobile\`, \`tablet\`,
1789
+ \`desktop\`, or \`sm\`/\`md\`/\`lg\`/\`xl\`) and \`themeTokenStyleBlock\` emits the value inside
1790
+ the matching \`@media\` query.
1791
+
1792
+ **Layout renderer.** A page designed in the admin's **Layout** tab is rendered on
1793
+ the public site through core's layout engine (\`KywiLayout\`/\`KywiRegion\`, already
1794
+ wired in \`app/(site)/[[...slug]]/page.tsx\`). Pages without a layout fall back to
1795
+ their Body rich text. Reach for the Layout tab when a page needs sections,
1796
+ columns, or modules; use the Body for simple prose.
1797
+
1798
+ **Front-of-site editor.** Signed in as an admin, append \`?kywi-edit=1\` to any
1799
+ public page to get an edit toolbar (the \`app/(site)/kywi-front-edit.tsx\` overlay).
1800
+
1801
+ ## Personalization, A/B testing & self-ID
1802
+
1803
+ Audiences, experiments and the self-ID widget you configure in the admin resolve
1804
+ **server-side** on every public request, with no extra setup:
1805
+
1806
+ - **Audience-targeted content** — a page's audience variant (Layout tab) and any
1807
+ audience \`variantContainer\` render for the winning audience.
1808
+ - **A/B experiments** — each visitor is assigned a variant arm deterministically
1809
+ and the exposure is recorded. The assignment is stable because the
1810
+ \`middleware.ts\` gives every visitor a persistent \`kywi_visitor\` id.
1811
+ - **Preview links** — an admin preview link sets \`kywi_preview_init\` and the page
1812
+ honours it, so you can preview an audience's experience.
1813
+
1814
+ The **self-ID widget**, live re-evaluation, the transparency bar and behavioral
1815
+ signals are an optional *client* layer. Enable it by setting
1816
+ \`personalization: { clientRuntime: true }\` on your theme in \`kywi.config.ts\` and
1817
+ dropping the built \`@kywi-software/js\` browser bundle at \`public/kywi.js\`; the
1818
+ generated \`components/personalization-runtime.tsx\` loads it best-effort. Without
1819
+ it, the server-rendered variant is what every visitor sees.
1820
+
1821
+ ## Locales
1822
+
1823
+ Set \`locales\` on your site in \`kywi.config.ts\` (e.g. \`locales: ['en', 'es']\`),
1824
+ create translations in the admin, and they are reachable at a locale-prefixed URL
1825
+ (\`/es/pricing\`). Resolution order is URL prefix › \`Accept-Language\` › the site's
1826
+ \`defaultLocale\`, with a fallback to the default-locale row; \`generateMetadata\`
1827
+ emits \`hreflang\` alternates. A single-locale site needs no prefix.
1828
+ `
1829
+ : ''
696
1830
  return `# ${a.projectName}
697
1831
 
698
1832
  A [Kywi CMS](https://kywi.dev) project (\`${a.mode}\` mode). ${modeLine}
@@ -742,12 +1876,20 @@ to disable surfaces you don't want in this deployment (e.g. hide Forms or
742
1876
  Audiences). SuperAdmins can also toggle surfaces at runtime from the
743
1877
  **Admin Features** surface inside the admin — no redeploy needed.
744
1878
 
1879
+ **Custom modules.** Register your own Layout-editor module with \`defineModule\`
1880
+ in \`kywi.config.ts\` (\`modules: [defineModule({ name: 'pricingTable', … })]\`),
1881
+ then map its \`name\` to the React component that renders it in \`lib/modules.tsx\`.
1882
+ That one map is passed to both the admin editor and the public site, so a custom
1883
+ module renders identically in the canvas and live (it shows an "Unknown module"
1884
+ placeholder until you add the entry). See the worked example in \`lib/modules.tsx\`.
1885
+
745
1886
  ### Create your first page
746
1887
 
747
1888
  In the admin, open **Site Tree** (or **Content**), create a new **Page**, enter a
748
1889
  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`.'}
750
-
1890
+ **Create**. Open the new page, switch to the **Publishing** tab, set **Status**
1891
+ to **Published**, and click **Save**.${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`.'}
1892
+ ${themingBlock}
751
1893
  ## Notes
752
1894
 
753
1895
  - **Migration NOTICEs are expected.** \`pnpm migrate\` prints PostgreSQL
@@ -766,7 +1908,9 @@ next.config.mjs required Next config to consume @kywi-software
766
1908
  lib/kywi.ts server runtime (DB, API handler, content scope)
767
1909
  lib/config.ts single import path for kywi.config.ts
768
1910
  app/api/v1/[...kywi]/route.ts the versioned API (delegates to core)
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)'}
1911
+ app/admin/[[...admin]]/page.tsx mounts the FULL core admin (all surfaces) at /admin
1912
+ lib/modules.tsx custom (defineModule) module renderers (admin + public)
1913
+ app/{llms,robots,sitemap,…} root AX routes (llms.txt, robots.txt, sitemap.xml, …)${a.mode === 'coupled' ? '\napp/(site)/layout.tsx public shell: theme tokens + your header/footer\napp/(site)/site.css your site chrome styles (edit freely)\napp/(site)/[[...slug]]/page.tsx renders published pages (layout + SEO + JSON-LD + i18n)\nlib/site.ts public-render helpers: path/locale resolution, feeds, personalization\ncomponents/personalization-runtime.tsx optional client runtime (self-ID widget, live re-eval)\napp/(site)/kywi-front-edit.tsx front-of-site edit overlay (?kywi-edit=1)' : '\napp/page.tsx returns 404 (no public rendering in this mode)'}
770
1914
  \`\`\`
771
1915
  `
772
1916
  }
@@ -804,6 +1948,8 @@ export function buildFileSet(answers) {
804
1948
  // server runtime + config
805
1949
  'lib/kywi.ts': libKywi(),
806
1950
  'lib/config.ts': libConfig(),
1951
+ // custom (defineModule) module renderers, shared by admin + public layout (#48)
1952
+ 'lib/modules.tsx': libModules(),
807
1953
  // host wiring (thin, over @kywi-software/core/host)
808
1954
  'middleware.ts': middleware(),
809
1955
  'app/api/v1/[...kywi]/route.ts': apiRoute(),
@@ -812,13 +1958,30 @@ export function buildFileSet(answers) {
812
1958
  'app/icon.svg': faviconSvg(),
813
1959
  // admin (all modes): the FULL core admin (all surfaces) via one catch-all.
814
1960
  'app/admin/[[...admin]]/page.tsx': adminCatchAllPage(),
1961
+ // AX layer served at the ROOT paths the Agent Experience surface advertises
1962
+ // (a crawler/agent follows /llms.txt, /sitemap.xml, /robots.txt — not the
1963
+ // /api/v1/* prefix). Delegate to the same core generators (#55). Present in
1964
+ // every mode: these are agent-facing projections of published content, not
1965
+ // public HTML rendering.
1966
+ 'app/llms.txt/route.ts': axRootRoute('llms.txt'),
1967
+ 'app/llms-full.txt/route.ts': axRootRoute('llms-full.txt'),
1968
+ 'app/sitemap.xml/route.ts': axRootRoute('sitemap.xml'),
1969
+ 'app/robots.txt/route.ts': axRootRoute('robots.txt'),
815
1970
  }
816
1971
 
817
1972
  if (answers.mode === 'coupled') {
818
1973
  // Public site: an optional catch-all renders "/" (home) and every published
819
1974
  // page at its slug. More-specific /admin and /api routes take precedence.
820
1975
  files['app/(site)/layout.tsx'] = siteLayout(answers)
1976
+ files['app/(site)/site.css'] = siteStyles(answers)
821
1977
  files['app/(site)/[[...slug]]/page.tsx'] = siteSlugPage()
1978
+ // Public-render helpers: path/locale resolution, feed + component resolvers,
1979
+ // personalization + experiments.
1980
+ files['lib/site.ts'] = libSite()
1981
+ // Optional client personalization runtime (self-ID widget, live re-eval). #50
1982
+ files['components/personalization-runtime.tsx'] = personalizationRuntime()
1983
+ // Front-of-site edit overlay (?kywi-edit=1), mounted by the page above.
1984
+ files['app/(site)/kywi-front-edit.tsx'] = frontEditOverlay()
822
1985
  } else {
823
1986
  // headless + decoupled: no public rendering.
824
1987
  files['app/page.tsx'] = headlessHomePage(answers)