create-kywi-app 0.18.0 → 0.20.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.
package/lib/templates.mjs CHANGED
@@ -32,6 +32,7 @@
32
32
  * ships session fixes without the app hand-maintaining crypto.
33
33
  */
34
34
 
35
+ import { createHash } from 'node:crypto'
35
36
  import { readFileSync, existsSync } from 'node:fs'
36
37
  import { dirname, join } from 'node:path'
37
38
  import { fileURLToPath } from 'node:url'
@@ -44,12 +45,15 @@ const CORE_RANGE = (v) => `^${v}`
44
45
 
45
46
  /** @param {Answers} a */
46
47
  function packageJson(a) {
47
- // Coupled mode's front-of-site editor (kywi-front-edit.tsx) needs the admin
48
- // design system's stylesheet available at a plain, fetchable URL
49
- // (/kywi-admin.css) — it is injected via a runtime <link> tag rather than a
50
- // JS import (kywi-cms#130 follow-up: an import, even a dynamic one, still
51
- // ships the stylesheet on every public route — see ensureAdminStylesheet in
52
- // kywi-front-edit.tsx). Unlike public/kywi.js (opt-in, manually placed when
48
+ // Coupled mode's front-of-site editor (core's `KywiFrontEdit`, from
49
+ // @kywi-software/core/next/client) needs the admin design system's stylesheet
50
+ // available at a plain, fetchable URL (/kywi-admin.css) — it is injected via a
51
+ // runtime <link> tag rather than a JS import (kywi-cms#130 follow-up: an
52
+ // import, even a dynamic one, still ships the stylesheet on every public route
53
+ // — see `ensureAdminStylesheet` in core's front-edit.tsx). The SYNC stays
54
+ // project-owned even though the overlay is core's: core fetches
55
+ // /kywi-admin.css.version at runtime, and only the app can put a file in its
56
+ // own public/. Unlike public/kywi.js (opt-in, manually placed when
53
57
  // personalization.clientRuntime is enabled — see README), this is a CORE
54
58
  // feature every coupled-mode app has, so the sync is automatic.
55
59
  const syncScripts =
@@ -333,9 +337,11 @@ function tsconfig() {
333
337
  * Ambient declaration for stylesheet imports.
334
338
  *
335
339
  * WHY THIS FILE EXISTS (found by the scaffold compile gate, kywi-cms#147 T6;
336
- * history below). `app/(site)/kywi-front-edit.tsx` originally pulled the
337
- * admin design system in via `await import('@kywi-software/core/admin/
338
- * styles.css')` inside `next/dynamic`'s factory. TypeScript treats a
340
+ * history below). The front-edit overlay — then emitted as
341
+ * `app/(site)/kywi-front-edit.tsx`, since plan E1 core's own
342
+ * `@kywi-software/core/next/client` — originally pulled the admin design system
343
+ * in via `await import('@kywi-software/core/admin/styles.css')` inside
344
+ * `next/dynamic`'s factory. TypeScript treats a
339
345
  * *dynamic* import of a non-code file as an ordinary module import and
340
346
  * reports TS2307 for it, unlike a top-level `import 'x.css'` statement, which
341
347
  * it lets through as a resource import. Next type-checks during `next
@@ -344,11 +350,15 @@ function tsconfig() {
344
350
  * reference app imported that stylesheet in statement form and the
345
351
  * scaffold's own output had never been type-checked at all.
346
352
  *
353
+ * (The overlay moved into core in plan E1; the reasoning is kept here because
354
+ * this declaration is still what makes a stylesheet import type-check in a
355
+ * generated app.)
356
+ *
347
357
  * kywi-cms#130 follow-up: that dynamic import turned out to ship the
348
358
  * stylesheet to every public route regardless of the `next/dynamic`
349
359
  * wrapping (Next's App Router CSS collection bundles every css import it can
350
360
  * see in the reachable module graph, dynamic or not) — see
351
- * `ensureAdminStylesheet` in `frontEditOverlay()`, which replaced it with a
361
+ * `ensureAdminStylesheet` in core's `next/client/front-edit.tsx`, which replaced it with a
352
362
  * runtime `<link>` tag. This ambient declaration is no longer load-bearing
353
363
  * for that one case, but it costs nothing to keep as a general-purpose `*.css`
354
364
  * declaration for any future dynamic stylesheet import.
@@ -359,11 +369,12 @@ function tsconfig() {
359
369
  function cssTypes() {
360
370
  return `// Stylesheet imports. CSS Modules are typed by Next itself — the more
361
371
  // specific pattern wins. (History: originally required for a dynamic
362
- // \`await import('…/styles.css')\` in app/(site)/kywi-front-edit.tsx, which
363
- // TypeScript checks like any other module — see cssTypes' doc comment in
364
- // templates.mjs. That import was replaced by a runtime \`<link>\` tag,
365
- // kywi-cms#130 follow-up, so nothing in this generated app currently needs
366
- // this declaration — kept for any future dynamic stylesheet import.)
372
+ // \`await import('…/styles.css')\` in the front-edit overlay, which TypeScript
373
+ // checks like any other module — see cssTypes' doc comment in templates.mjs.
374
+ // That import was replaced by a runtime \`<link>\` tag, kywi-cms#130 follow-up,
375
+ // and the overlay itself now lives in @kywi-software/core/next, so nothing in
376
+ // this generated app currently needs this declaration — kept for any future
377
+ // dynamic stylesheet import.)
367
378
  declare module '*.css'
368
379
  `
369
380
  }
@@ -455,927 +466,505 @@ public/kywi-admin.css.version
455
466
  `
456
467
  }
457
468
 
458
- // ── server runtime (lib/kywi.ts) ──────────────────────────────────────────────
459
-
460
- function libKywi() {
461
- return `import { cache } from 'react'
462
- import { headers } from 'next/headers'
463
- import {
464
- createDb,
465
- createKywiApiHandler,
466
- createStorageProvider,
467
- resolveDatabaseUrl,
468
- resolveAuthSecret,
469
- } from '@kywi-software/core/server'
470
- import type { KywiApiHandler, KywiDb } from '@kywi-software/core/server'
471
- import { createKywiScope } from '@kywi-software/core/scope'
472
- import type { KywiScope } from '@kywi-software/core/scope'
473
- import { resolveActiveSite } from '@kywi-software/core/host'
474
- import config from '../kywi.config'
469
+ // ── the generated render path: version stamp + landmarks ─────────────────────
475
470
 
476
471
  /**
477
- * Server-side Kywi singletons — config, DB, API handler and the DB-backed scope,
478
- * all sharing one connection pool. Memoised so it initialises once per process.
479
- */
480
- export interface KywiRuntime {
481
- handler: KywiApiHandler
482
- scope: KywiScope
483
- db: KywiDb
484
- /**
485
- * DB UUID of the DEFAULT site (\`config.sites[0]\`), resolved once at init.
486
- *
487
- * This is a process-level fallback, NOT the site a request is for. Public
488
- * render code must use \`getActiveSite()\` below, which resolves from the
489
- * request's Host header — keying a public query on this instead is what makes
490
- * a multi-site app serve the first site's content on every domain.
491
- */
492
- siteId: string
493
- config: typeof config
494
- }
495
-
496
- let _runtime: KywiRuntime | null = null
497
- let _init: Promise<KywiRuntime> | null = null
498
-
499
- async function init(): Promise<KywiRuntime> {
500
- const { url: dbUrl } = resolveDatabaseUrl(config.db.url)
501
- const db = createDb(dbUrl, config.db.provider)
502
- const authSecret = resolveAuthSecret(config.auth.secret)
503
- const storage = createStorageProvider(config.media ?? { provider: 'local', localPath: './uploads' })
504
- const handler = await createKywiApiHandler({ config, db, authSecret, storage })
505
- const scope = createKywiScope(config, db, undefined, { mediaBaseUrl: '/api/v1' })
506
-
507
- // Resolve the DB UUID for the DEFAULT configured site (its \`id\` is the slug).
508
- // Per-request site resolution lives in \`getActiveSite()\`, not here.
509
- const sites = await scope.site.list()
510
- const match = sites.find((s) => (s as { slug?: string }).slug === config.sites[0]?.id) // DEFAULT-SITE FALLBACK: boot-time default, not a per-request resolution
511
- const siteId = (match?.['id'] as string | undefined) ?? config.sites[0]?.id ?? 'default' // DEFAULT-SITE FALLBACK: boot-time default, not a per-request resolution
512
-
513
- return { handler, scope, db, siteId, config }
514
- }
515
-
516
- export async function getKywi(): Promise<KywiRuntime> {
517
- if (_runtime) return _runtime
518
- if (!_init) _init = init().then((r) => (_runtime = r))
519
- return _init
520
- }
521
-
522
- export async function getKywiHandler(): Promise<KywiApiHandler> {
523
- return (await getKywi()).handler
524
- }
525
-
526
- // ─── Host → site resolution ─────────────────────────────────────────────────
527
-
528
- /** One configured site (config uses the slug as \`id\`). */
529
- export type SiteConfig = (typeof config.sites)[number]
530
-
531
- /** The site the current public request is for: config entry + its DB UUID. */
532
- export interface ActiveSite {
533
- runtime: KywiRuntime
534
- /** Resolved config site (matched by request Host, else the default site). */
535
- site: SiteConfig
536
- /** DB UUID of that site (what the scope/queries key on). */
537
- siteId: string
538
- }
539
-
540
- // Config-slug → DB-UUID map, cached for the process. Sites are reconciled at
541
- // boot and static thereafter; a miss falls back to the slug.
542
- let _slugToUuid: Record<string, string> | null = null
543
- async function slugToUuid(runtime: KywiRuntime): Promise<Record<string, string>> {
544
- if (_slugToUuid) return _slugToUuid
545
- const rows = (await runtime.scope.site.list()) as Array<{ id?: string; slug?: string }>
546
- const map: Record<string, string> = {}
547
- for (const r of rows) if (r.slug && r.id) map[r.slug] = r.id
548
- _slugToUuid = map
549
- return map
550
- }
551
-
552
- /**
553
- * Which configured site is this request for? Resolved from the request's HOST
554
- * header, so \`docs.example.com\` serves the docs site and \`example.com\` serves
555
- * the marketing site from one app and one database.
472
+ * Version of the GENERATED RENDER PATH's SHAPE — the eleven generated files
473
+ * {@link RENDER_LANDMARK_PATHS} names, independent of the package version. Bump
474
+ * it when their shape changes in a way an existing project should pick up, so a
475
+ * project can compare its stamp against a newer create-kywi-app and know its
476
+ * render path is stale.
556
477
  *
557
- * Wrapped in React \`cache()\` so every helper on a single render shares one
558
- * lookup. The process-level singletons in \`getKywi()\` are unaffected — only the
559
- * per-request Host differs.
478
+ * **v2 (plan E3 Task 1)** adds an INTEGRITY HASH to the stamp and extends the
479
+ * stamped set from five files to eleven. A v1 stamp says only "create-kywi-app
480
+ * X emitted a v1-shaped file here"; a v2 stamp additionally proves whether the
481
+ * file is still byte-for-byte that emission. That is the distinction
482
+ * `create-kywi-app upgrade` (plan E3) needs: an UNTOUCHED generated file can be
483
+ * replaced silently, an EDITED one must never be clobbered, and before v2 the
484
+ * two were indistinguishable without diffing against every historical emission.
560
485
  *
561
- * DO NOT read \`x-kywi-url\` here: in dev it carries the server's own bound origin
562
- * (localhost), which would defeat host-based routing entirely.
486
+ * Exactly the {@link GUIDANCE_VERSION} / {@link guidanceStamp} convention, one
487
+ * namespace over: `kywi-render` where the guidance files say
488
+ * `kywi-agent-guidance`. `layered-architecture DESIGN.md` §6 names that
489
+ * mechanism as the one `create-kywi-app upgrade` extends to these files.
563
490
  */
564
- export const getActiveSite = cache(async (): Promise<ActiveSite> => {
565
- const runtime = await getKywi()
566
- const host = (await headers()).get('host')
567
- const map = await slugToUuid(runtime)
568
- const { site, siteId } = resolveActiveSite(runtime.config.sites, map, host)
569
- return { runtime, site, siteId }
570
- })
571
- `
572
- }
573
-
574
- function libConfig() {
575
- return `/** Re-export the Kywi config for use throughout the app (single import path). */
576
- import config from '../kywi.config'
577
- export default config
578
- `
579
- }
580
-
581
- // ── public-render helpers (lib/site.ts) ───────────────────────────────────────
491
+ export const RENDER_VERSION = 2
582
492
 
583
493
  /**
584
- * Server-side helpers for the coupled public site: path- and locale-aware content
585
- * resolution (#30, #51), the feed-hydration resolver (#28) and the component
586
- * resolver (#46) for the layout engine, plus small SEO/media utilities. Kept out
587
- * of the page so the route stays a thin render over these.
494
+ * The integrity half of the stamp: the first 12 hex characters of the sha256 of
495
+ * the file body that FOLLOWS the stamp line — the exact bytes, with no
496
+ * normalisation of whitespace or line endings, so any edit at all changes it.
497
+ * 48 bits is far more than enough to catch an edit (this is a tamper-EVIDENCE
498
+ * check, not a security boundary) and keeps the stamp line readable.
499
+ * @param {string} body everything after the stamp line's newline
500
+ * @returns {string} 12 lowercase hex characters
588
501
  */
589
- function libSite() {
590
- return `import { headers, cookies } from 'next/headers'
591
- import type {
592
- LayoutDocument,
593
- FeedItemsResolver,
594
- PersonalizationState,
595
- } from '@kywi-software/core/layout'
596
- import {
597
- collectComponentRefs,
598
- componentDetachSource,
599
- resolveComponentPlacements,
600
- applyPageVariant,
601
- collectLayoutExperimentIds,
602
- } from '@kywi-software/core/layout'
603
- import { resolveContentByPath, normalizePath } from '@kywi-software/core/nav'
604
- import { getFeedBySlug, getComponentById, resolveLocaleFromRequest } from '@kywi-software/core'
605
- import {
606
- evaluateActiveAudiences,
607
- getSelfIdWidgetConfig,
608
- listSelfIdFields,
609
- COOKIE_NAMES,
610
- } from '@kywi-software/core/audiences'
611
- import { cookieAllowedByHeader } from '@kywi-software/core/audiences/consent'
612
- import type {
613
- Audience,
614
- VisitorSignals,
615
- SelfIdField,
616
- SelfIdWidgetConfig,
617
- SelfIdTrigger,
618
- } from '@kywi-software/core/audiences/types'
619
- import { listRunningExperiments, resolveExperimentForContainer } from '@kywi-software/core/experiments'
620
- import { getActiveSite, type KywiRuntime, type SiteConfig } from './kywi'
621
-
622
- /** A resolved content node is a flat record of its columns (base + custom fields). */
623
- export type ContentNode = Record<string, unknown>
624
-
625
- // Absolute base for media/OG URLs. Set NEXT_PUBLIC_SITE_URL in production so
626
- // crawlers and structured data get absolute image URLs.
627
- const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'
628
-
629
- /** Public URL for a media file served by the API handler, or null. */
630
- export function mediaUrl(id: unknown): string | null {
631
- return typeof id === 'string' && id ? \`\${SITE_URL}/api/v1/media/\${id}/file\` : null
632
- }
633
-
634
- /** Absolute origin of the current request, for structured-data (JSON-LD) URLs. */
635
- export async function requestBaseUrl(): Promise<string> {
636
- const h = await headers()
637
- const host = h.get('host') ?? 'localhost'
638
- const isLocal = host.startsWith('localhost') || host.startsWith('127.') || host.startsWith('0.0.0.0')
639
- const proto = h.get('x-forwarded-proto') ?? (isLocal ? 'http' : 'https')
640
- return \`\${proto}://\${host}\`
641
- }
642
-
643
- // ─── Locale routing (#51) ────────────────────────────────────────────────────
502
+ export function hashRenderBody(body) {
503
+ return createHash('sha256').update(body, 'utf8').digest('hex').slice(0, 12)
504
+ }
644
505
 
645
506
  /**
646
- * The active site's configured locales (the default is always included first).
647
- *
648
- * \`activeSite\` is the site the REQUEST resolved to (\`getActiveSite().site\`).
649
- * Omitting it falls back to the default site, which is right only for a
650
- * single-site app — a second site would otherwise be served site one's locales.
507
+ * The staleness + integrity stamp every generated render-path file opens with. A
508
+ * `//` comment rather than the guidance files' `<!-- … -->` (these are
509
+ * .ts/.tsx), so it is inert to the compiler but greppable by tooling and by an
510
+ * agent asking "is this project's render path current, and untouched?".
511
+ * @param {string} body the file body the stamp will precede
512
+ * @returns {string}
651
513
  */
652
- export function siteLocales(
653
- runtime: KywiRuntime,
654
- activeSite?: SiteConfig,
655
- ): { defaultLocale: string; locales: string[] } {
656
- const site = activeSite ?? runtime.config.sites[0] // DEFAULT-SITE FALLBACK: Host matched no site
657
- const defaultLocale = site?.defaultLocale ?? 'en'
658
- const configured = site?.locales && site.locales.length > 0 ? site.locales : [defaultLocale]
659
- const locales = configured.includes(defaultLocale) ? configured : [defaultLocale, ...configured]
660
- return { defaultLocale, locales }
514
+ export function renderStamp(body) {
515
+ return `// kywi-render v${RENDER_VERSION} (create-kywi-app ${packageVersion()}) sha256:${hashRenderBody(body)}`
661
516
  }
662
517
 
663
518
  /**
664
- * Split a URL slug path into its active locale and the remaining slug segments.
665
- * A leading segment matching a CONFIGURED locale is treated as a locale prefix
666
- * (\`/es/pricing\` → locale 'es', path ['pricing']); otherwise Accept-Language
667
- * selects a supported locale, else the site default applies (the whole path is
668
- * the slug). Locale prefixes are only honoured for locales in \`site.locales\`.
519
+ * A body plus its own stamp — what every render-path emitter returns.
520
+ * @param {string} body
521
+ * @returns {string}
669
522
  */
670
- export function resolveRequestLocale(
671
- runtime: KywiRuntime,
672
- slugSegments: string[],
673
- acceptLanguage?: string | null,
674
- activeSite?: SiteConfig,
675
- ): { locale: string; slugPath: string[] } {
676
- const { defaultLocale, locales } = siteLocales(runtime, activeSite)
677
- const pathname = '/' + slugSegments.join('/')
678
- const locale = resolveLocaleFromRequest(pathname, acceptLanguage ?? null, defaultLocale, locales)
679
- const first = slugSegments[0]
680
- const slugPath = first && locales.includes(first) ? slugSegments.slice(1) : slugSegments
681
- return { locale, slugPath }
523
+ export function stamped(body) {
524
+ return `${renderStamp(body)}\n${body}`
682
525
  }
683
526
 
684
527
  /**
685
- * hreflang alternates for a page across the site's configured locales, or
686
- * undefined for a single-locale site. Feeds Next's \`alternates.languages\`.
528
+ * Splits a stamped file back into `{ stamp, body }`. The body is the text with
529
+ * its first line removed WHEN that line is a stamp; an unstamped file comes back
530
+ * whole with `stamp: null`, so a caller can hash the same bytes either way.
531
+ * @param {string} text
532
+ * @returns {{ stamp: string|null, body: string }}
687
533
  */
688
- export async function localeAlternates(
689
- slugSegments: string[],
690
- ): Promise<Record<string, string> | undefined> {
691
- // Async because the alternates belong to the site THIS request is for: a
692
- // two-locale docs site and a single-locale marketing site share this app, and
693
- // reading the default site's locales here would emit the wrong hreflang set
694
- // (or none) on the other host.
695
- const { runtime, site } = await getActiveSite()
696
- const { defaultLocale, locales } = siteLocales(runtime, site)
697
- if (locales.length <= 1) return undefined
698
- const { slugPath } = resolveRequestLocale(runtime, slugSegments, null, site)
699
- const rel = slugPath.join('/')
700
- const out: Record<string, string> = {}
701
- for (const loc of locales) {
702
- const base = loc === defaultLocale ? \`/\${rel}\` : \`/\${loc}/\${rel}\`
703
- out[loc] = base.length > 1 ? base.replace(/\\/$/, '') : base
704
- }
705
- return out
534
+ export function splitRenderStamp(text) {
535
+ const newline = text.indexOf('\n')
536
+ const firstLine = newline === -1 ? text : text.slice(0, newline)
537
+ if (!RENDER_STAMP_RE.test(firstLine)) return { stamp: null, body: text }
538
+ return { stamp: firstLine, body: newline === -1 ? '' : text.slice(newline + 1) }
706
539
  }
707
540
 
708
- // ─── Content resolution (#30 full path + #51 locale) ─────────────────────────
709
-
710
541
  /**
711
- * Resolve a public content node from a URL slug path, honouring the Site Tree
712
- * hierarchy: the FULL materialized path is matched, so \`/a/b/<slug>\` serves only
713
- * the node actually at \`/a/b/<slug>\` — never a top-level \`<slug>\` (#30). The
714
- * locale (URL prefix › Accept-Language › default) is resolved first and preferred,
715
- * with a default-locale fallback (#51). Returns the node regardless of status;
716
- * the caller enforces \`status === 'published'\`.
542
+ * Parses a {@link renderStamp} back out of a file's text:
543
+ * `[, renderVersion, cliVersion, bodyHash]`. Anchored to a whole line so a
544
+ * mention of the stamp inside prose cannot match. The hash group is OPTIONAL:
545
+ * a project scaffolded before v2 carries a hashless stamp, and `upgrade` must
546
+ * still be able to read its version rather than treating it as hand-written.
717
547
  */
718
- export async function resolvePublicContent(
719
- slugSegments: string[],
720
- opts?: { acceptLanguage?: string | null },
721
- ): Promise<ContentNode | null> {
722
- // Every query below is keyed on the site the REQUEST is for, not on the
723
- // default site: this is the seam that decides whose \`/about\` a visitor gets.
724
- const { runtime, site, siteId } = await getActiveSite()
725
- const { scope, db } = runtime
726
- const { defaultLocale } = siteLocales(runtime, site)
727
- const { locale, slugPath } = resolveRequestLocale(runtime, slugSegments, opts?.acceptLanguage, site)
728
-
729
- const bySlug = async (slug: string): Promise<ContentNode | null> => {
730
- let node = await scope.content.getBySlug(slug, siteId, { locale })
731
- if (!node && locale !== defaultLocale) {
732
- node = await scope.content.getBySlug(slug, siteId, { locale: defaultLocale })
733
- }
734
- return (node as ContentNode | null) ?? null
735
- }
548
+ export const RENDER_STAMP_RE = /^\/\/ kywi-render v(\d+) \(create-kywi-app ([^)]+)\)(?: sha256:([0-9a-f]{12}))?$/m
736
549
 
737
- // Home ("/"): the seeded root node (slug "home", path "/").
738
- if (slugPath.length === 0) return bySlug('home')
550
+ /** @typedef {'runtime'|'middleware'|'siteLayout'|'sitePage'|'layers'|'apiRoute'|'axLlms'|'axLlmsFull'|'axSitemap'|'axRobots'|'adminHost'} RenderLandmark */
739
551
 
740
- // Structural resolution by the full path enforces the parent chain (#30) and
741
- // picks the locale-correct row that shares that path (#51).
742
- const fullPath = normalizePath('/' + slugPath.join('/'))
743
- let structural = await resolveContentByPath(db, fullPath, siteId, { locale })
744
- if (!structural && locale !== defaultLocale) {
745
- structural = await resolveContentByPath(db, fullPath, siteId, { locale: defaultLocale })
746
- }
747
- if (structural) {
748
- return (await scope.content.getById(structural.id)) as ContentNode | null
749
- }
750
-
751
- // Flat-slug fallback ONLY for a single-segment URL whose target has no parent
752
- // chain (a top-level node whose \`path\` column was never materialized). A nested
753
- // node is never reachable at a wrong prefix — that is the #30 fix.
754
- if (slugPath.length === 1) {
755
- const node = await bySlug(slugPath[0]!)
756
- if (node && !node['parentId']) return node
757
- }
758
- return null
552
+ /**
553
+ * Landmark → its relative path, for the RENDER PATH: one entry per generated
554
+ * file the scaffold emits at a path Next demands and stamps with {@link
555
+ * renderStamp}. Same role as {@link AGENTS_LANDMARK_PATHS} — one source of
556
+ * truth for the path strings — shared by the emitter ({@link buildFileSet}),
557
+ * the E8 drift guard (`__tests__/render-drift.test.mjs`) and, in plan E3, the
558
+ * `upgrade` command.
559
+ *
560
+ * ALL ELEVEN generated render files are listed, not just the thin delegates
561
+ * (plan E3 Task 1). The membership rule is "create-kywi-app wrote it and owns
562
+ * its shape", because that is what `upgrade` must be able to find and — when
563
+ * the stamp's hash proves the file untouched — replace. The API route, the four
564
+ * AX root routes and the admin host page qualify on exactly that basis; the
565
+ * project-owned files a site actually edits (`kywi.config.ts`, `site.css`,
566
+ * `lib/modules.tsx`, `components/site-nav.tsx`, the `sites/` layers) do not,
567
+ * and stay unstamped.
568
+ * @type {Record<RenderLandmark, string>}
569
+ */
570
+ export const RENDER_LANDMARK_PATHS = {
571
+ runtime: 'lib/kywi.ts',
572
+ middleware: 'middleware.ts',
573
+ siteLayout: 'app/(site)/layout.tsx',
574
+ sitePage: 'app/(site)/[[...slug]]/page.tsx',
575
+ layers: 'kywi.layers.ts',
576
+ apiRoute: 'app/api/v1/[...kywi]/route.ts',
577
+ axLlms: 'app/llms.txt/route.ts',
578
+ axLlmsFull: 'app/llms-full.txt/route.ts',
579
+ axSitemap: 'app/sitemap.xml/route.ts',
580
+ axRobots: 'app/robots.txt/route.ts',
581
+ adminHost: 'app/admin/[[...admin]]/page.tsx',
759
582
  }
760
583
 
761
- // ─── Feed hydration (#28) ────────────────────────────────────────────────────
584
+ /**
585
+ * The render-path files a PRE-E1 project still carries and a current one must
586
+ * not: their contents moved into `@kywi-software/core/next`. One list, so the
587
+ * E8 drift guard (which fails if the scaffold emits any of them) and plan E3's
588
+ * `upgrade` (which deletes them from an existing project) cannot disagree about
589
+ * the set.
590
+ * @type {string[]}
591
+ */
592
+ export const LEGACY_RENDER_FILES = [
593
+ 'lib/site.ts',
594
+ 'lib/config.ts',
595
+ 'lib/kywi-js-loader.ts',
596
+ 'components/kywi-js-loader.tsx',
597
+ 'components/personalization-runtime.tsx',
598
+ 'app/(site)/kywi-front-edit.tsx',
599
+ ]
762
600
 
763
601
  /**
764
- * Build the {@link FeedItemsResolver} \`hydrateLayoutFeeds\` calls for each Feed
765
- * Display module: resolve the saved feed by slug, run its query (published-only,
766
- * per-module limit) through the feed engine, and pre-map each row's media id to a
767
- * URL so the (client) module can render images. Core owns the walk + item shape;
768
- * this owns the data access.
602
+ * Which render-path files a FRESH scaffold of the given mode emits — derived
603
+ * statically from the mode, never from the filesystem ({@link
604
+ * scaffoldLandmarks}'s counterpart for the render path). The wiring runtime, the
605
+ * middleware, the layer registry, the API route, the four AX root routes and the
606
+ * admin host page exist in every mode (the admin and the API are identical
607
+ * across all three, and the AX routes are agent-facing projections of published
608
+ * content rather than public HTML rendering); only the two `app/(site)` routes
609
+ * are coupled mode only.
610
+ * @param {'coupled'|'headless'|'decoupled'} mode
611
+ * @returns {Record<RenderLandmark, boolean>}
769
612
  */
770
- export function buildFeedResolver(runtime: KywiRuntime, siteId: string): FeedItemsResolver {
771
- const { scope, db } = runtime
772
- return async (feedSlug, { limit }) => {
773
- const feed = await getFeedBySlug(db, feedSlug, siteId)
774
- if (!feed) return []
775
- const result = await scope.feeds.query({
776
- ...(feed.query as Record<string, unknown>),
777
- siteId,
778
- status: 'published',
779
- limit,
780
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
781
- } as any)
782
- return result.items.map((row) => ({
783
- ...row,
784
- image: mediaUrl(row['featuredImageId']) ?? undefined,
785
- }))
613
+ export function renderLandmarks(mode) {
614
+ const coupled = mode === 'coupled'
615
+ return {
616
+ runtime: true,
617
+ middleware: true,
618
+ siteLayout: coupled,
619
+ sitePage: coupled,
620
+ layers: true,
621
+ apiRoute: true,
622
+ axLlms: true,
623
+ axLlmsFull: true,
624
+ axSitemap: true,
625
+ axRobots: true,
626
+ adminHost: true,
786
627
  }
787
628
  }
788
629
 
789
- // ─── Linked components: server-side pre-resolution (#46, #69, #147) ──────────
790
-
791
- /** Fetch a set of component rows once and index them by id. */
792
- async function loadComponents(ids: string[], runtime: KywiRuntime, siteId: string) {
793
- const { db } = runtime
794
- const entries = await Promise.all(
795
- ids.map(async (id) => [id, await getComponentById(db, id, siteId)] as const),
796
- )
797
- return new Map(entries)
630
+ /**
631
+ * Read the render-path stamps out of an EXISTING project. Mirrors {@link
632
+ * detectAgentsLandmarks} (presence on disk) and adds the parsed stamp, because
633
+ * a render file is REPLACEABLE where a guidance file is only skip-or-force: a
634
+ * file carrying a stamp is create-kywi-app's own output and safe to overwrite,
635
+ * while an unstamped one was hand-written or predates the layered shape
636
+ * (`layered-architecture DESIGN.md` §8) and must be ejected, not clobbered.
637
+ * E1 only emits and detects; E3 acts on it.
638
+ *
639
+ * `intact` is the v2 addition and the field `upgrade` actually branches on:
640
+ * - `true` — the stamp's hash matches the body on disk: untouched
641
+ * create-kywi-app output, safe to replace.
642
+ * - `false` — a hash is present and does NOT match: the file was edited after
643
+ * it was generated, so replacing it would destroy work.
644
+ * - `null` — integrity is UNKNOWABLE: the file is absent or unstamped, or it
645
+ * carries a hashless v1 stamp from a project scaffolded before
646
+ * plan E3. Not the same claim as `false`, and callers must not
647
+ * collapse the two.
648
+ * @param {string} projectDir absolute path to the project root
649
+ * @returns {Record<string, { path: string, present: boolean, version: number|null, cliVersion: string|null, hash: string|null, intact: boolean|null }>}
650
+ */
651
+ export function detectRenderLandmarks(projectDir) {
652
+ /** @type {Record<string, { path: string, present: boolean, version: number|null, cliVersion: string|null, hash: string|null, intact: boolean|null }>} */
653
+ const out = {}
654
+ for (const [key, rel] of Object.entries(RENDER_LANDMARK_PATHS)) {
655
+ const abs = join(projectDir, rel)
656
+ const present = existsSync(abs)
657
+ const text = present ? readFileSync(abs, 'utf8') : null
658
+ const { stamp, body } = text === null ? { stamp: null, body: '' } : splitRenderStamp(text)
659
+ const match = stamp === null ? null : RENDER_STAMP_RE.exec(stamp)
660
+ const hash = match ? (match[3] ?? null) : null
661
+ out[key] = {
662
+ path: rel,
663
+ present,
664
+ version: match ? Number(match[1]) : null,
665
+ cliVersion: match ? match[2] : null,
666
+ hash,
667
+ intact: hash === null ? null : hash === hashRenderBody(body),
668
+ }
669
+ }
670
+ return out
798
671
  }
799
672
 
800
673
  /**
801
- * Resolve every linked component (\`componentId\`) the layout places, ON THE
802
- * SERVER, before the document reaches the renderer.
674
+ * UNSTAMPED emissions from a published create-kywi-app version that
675
+ * `create-kywi-app upgrade` must still recognise as its own output, even
676
+ * though they carry no {@link renderStamp}.
677
+ *
678
+ * WHY THIS EXISTS. create-kywi-app 0.19.0 (git tag `v0.19.0`, commit
679
+ * `cc8259d`) emitted six of the eleven {@link RENDER_LANDMARK_PATHS} files —
680
+ * the API route, the four AX root routes and the admin host page — via
681
+ * `apiRoute()`, `axRootRoute(axPath)` and `adminCatchAllPage()`, and none of
682
+ * those three functions wrapped their return value in {@link stamped}; only
683
+ * the other five emitters (`libKywi`, `middleware`, `siteLayout`, `sitePage`,
684
+ * `kywiLayers`) did. So a project scaffolded by 0.19.0 has six generated
685
+ * files `detectRenderLandmarks` reports as `present: true, version: null` —
686
+ * indistinguishable, by the stamp alone, from a hand-written file, which is
687
+ * why `upgrade` refused them and demanded `--force` even though nothing of
688
+ * the project owner's is in them.
803
689
  *
804
- * This is the ONLY way a Server Component can render linked components.
805
- * \`KywiLayout\` is a client component and its \`componentResolver\` /
806
- * \`sectionComponentResolver\` / \`variantContainerComponentResolver\` props are
807
- * FUNCTIONS: passing them from here throws "Functions cannot be passed directly
808
- * to Client Components" and the page 500s. Pre-resolution hands the renderer a
809
- * plain document instead — only data crosses the boundary — and produces exactly
810
- * the markup the resolvers were meant to produce, \`data-component-id\` included.
690
+ * Those six emissions were ANSWER-INDEPENDENT at 0.19.0 (none of the three
691
+ * functions reads `answers`), so their exact bytes are knowable, and their
692
+ * WHOLE-FILE hash ({@link hashRenderBody} over the entire text, since there
693
+ * is no stamp line to split off first) identifies them just as reliably as a
694
+ * stamp's hash identifies an intact stamped file. `create-kywi-app upgrade`
695
+ * checks an UNSTAMPED present landmark's whole-content hash against this
696
+ * table before refusing it as `unstamped`; a match is classified `replace`
697
+ * exactly as a stamped-and-intact file would be.
811
698
  *
812
- * \`collectComponentRefs\` is the engine's own link walker, the same one the
813
- * instance index and detach-all use, so this never falls behind on where a link
814
- * may live. It also knows that a connected section's inline columns are fallback
815
- * copy rather than placements, so links buried in one are not fetched — they
816
- * only render when that section's own component has gone missing.
699
+ * HOW THESE HASHES WERE COMPUTED (to verify or recompute them):
700
+ * 1. `git show v0.19.0:packages/create-kywi-app/lib/templates.mjs >
701
+ * packages/create-kywi-app/lib/.baseline-0.19.0.mjs` — written INSIDE
702
+ * `lib/` so the historical module's own relative `../package.json` and
703
+ * `../assets/*` reads resolve against the real package.
704
+ * 2. Import that module and call `buildFileSet({ projectName: 'kywi-app',
705
+ * dbProvider: 'postgresql', authProviders: ['credentials'], mode:
706
+ * 'coupled', kywiVersion: '0.19.0' })`. `mode: 'coupled'` because it is
707
+ * the superset (all eleven landmarks); the six paths below are
708
+ * answer-independent, so a headless/decoupled build would hash
709
+ * identically.
710
+ * 3. For each of the six paths below, `hashRenderBody(files[path])` — the
711
+ * WHOLE file, not a body-after-stamp, since 0.19.0 wrote no stamp line
712
+ * for these six.
713
+ * 4. Delete the baseline copy — it must never be committed.
817
714
  *
818
- * Definitions are read per request with no cache, so editing a component is live
819
- * on the next page view.
715
+ * THIS TABLE IS CLOSED AT SIX ENTRIES. It grows only if some future
716
+ * create-kywi-app release ships an unstamped render-landmark emission again —
717
+ * which the stamp discipline this file now enforces ({@link RENDER_VERSION},
718
+ * every {@link RENDER_LANDMARK_PATHS} emitter routing through {@link
719
+ * stamped}) forbids from happening again. There is no code path left in
720
+ * `buildFileSet` that can produce a seventh unstamped landmark from a current
721
+ * release, so a new entry here would mean that guarantee broke.
722
+ * @type {Record<string, Record<string, string>>}
820
723
  */
821
- export async function resolveLayoutComponents(
822
- layout: LayoutDocument,
823
- runtime: KywiRuntime,
824
- siteId: string,
825
- ): Promise<LayoutDocument> {
826
- const refs = collectComponentRefs(layout)
827
- if (refs.length === 0) return layout
828
- const map = await loadComponents([...new Set(refs.map((ref) => ref.componentId))], runtime, siteId)
829
- return resolveComponentPlacements(layout, (componentId) =>
830
- componentDetachSource(map.get(componentId) ?? null),
831
- )
724
+ export const KNOWN_UNSTAMPED_EMISSIONS = {
725
+ 'app/api/v1/[...kywi]/route.ts': { '370fca738a32': 'create-kywi-app 0.19.0' },
726
+ 'app/llms.txt/route.ts': { '9228324de82e': 'create-kywi-app 0.19.0' },
727
+ 'app/llms-full.txt/route.ts': { 'c4ec73448df6': 'create-kywi-app 0.19.0' },
728
+ 'app/sitemap.xml/route.ts': { 'f7d60cf68c99': 'create-kywi-app 0.19.0' },
729
+ 'app/robots.txt/route.ts': { 'd0e1a402e5d0': 'create-kywi-app 0.19.0' },
730
+ 'app/admin/[[...admin]]/page.tsx': { '929264b9d714': 'create-kywi-app 0.19.0' },
832
731
  }
833
732
 
834
- // ─── Personalization + experiments (#50) ─────────────────────────────────────
835
-
836
- /** Server-resolved personalization for one public request. */
837
- export interface PublicPersonalization {
838
- /** Threaded into <KywiLayout personalization=…> for variantContainer nodes. */
839
- personalization: PersonalizationState
840
- /** Winning audience id (null = default / opted out / no match). */
841
- audienceId: string | null
842
- /** Stable visitor id (middleware cookie/header); keys A/B assignment. */
843
- visitorId: string
844
- /**
845
- * True when \`visitorId\` is a durable identity this visitor consented to
846
- * (kywi-cms#91). False means it was minted for this request alone — nothing
847
- * may be written against it, including an A/B exposure.
848
- */
849
- tracked: boolean
850
- /** Active audiences evaluated — handed to the client runtime for re-eval. */
851
- audiences: Audience[]
852
- /** Server-resolved signals — handed to the client runtime as serverSignals. */
853
- signals: Partial<VisitorSignals>
854
- }
855
-
856
- /** Is the winning audience id one of the currently active audiences? */
857
- function isActiveAudience(id: string | null, audiences: Audience[]): boolean {
858
- return id != null && audiences.some((a) => a.id === id)
859
- }
733
+ // ── the wiring runtime (lib/kywi.ts) ──────────────────────────────────────────
860
734
 
861
735
  /**
862
- * Evaluate the site's active audiences for this request, exactly once, and pick
863
- * the visitor's A/B arms — the two server seams that make page variants,
864
- * variantContainers and experiments resolve per-visitor with NO client runtime
865
- * (kywi-cms#50). The request the audience engine reads (URL + cookies + referrer)
866
- * is reconstructed from next/headers, so this is safe from any (site) Server
867
- * Component. A \`kywi_preview_init=<audienceId>\` cookie (set by the admin preview
868
- * link) forces that audience, so an editor previews it without matching its live
869
- * conditions.
736
+ * The app's ONE config seam, and the only render-path file that exists in every
737
+ * mode. Before plan E1 this was 111 lines of connection-pool, singleton and
738
+ * Host→site-resolution code copied into every project; it is now four lines
739
+ * over `createKywiNextRuntime`, which core owns and `pnpm up` upgrades.
740
+ *
741
+ * It cannot collapse further: core cannot import an app's `kywi.config.ts`
742
+ * (there is no import path from a published package back into the app that
743
+ * installed it, and a config may pull in project plugins), so the app hands the
744
+ * config in — see `packages/core/src/next/runtime.ts`'s header.
870
745
  */
871
- export async function resolvePersonalization(
872
- runtime: KywiRuntime,
873
- layout: LayoutDocument | null | undefined,
874
- ): Promise<PublicPersonalization> {
875
- const [h, cookieStore] = await Promise.all([headers(), cookies()])
876
- // Evaluate against the HOST-resolved site, so a visitor on site B is matched
877
- // only against site B's audiences and gated by site B's theme flags.
878
- const { site, siteId } = await getActiveSite()
879
- // One resolution of \`theme.personalization.requireConsent\` for the whole
880
- // request, handed to every gate below — the visitor-id read AND the audience
881
- // engine's own collectors. They read the same cookies; disagreeing about
882
- // whether consent is required would personalize half a page.
883
- const gate = { requireConsent: requireConsentEnabled(runtime, site) }
884
- // \`kywi_visitor\` is a \`personalization\` cookie, so without consent it is
885
- // neither written nor read (kywi-cms#91) — the middleware hands this request a
886
- // THROWAWAY id instead, freshly minted and different on the next request.
887
- // \`tracked\` carries that fact to everything downstream that would otherwise
888
- // write the id to the database.
889
- const tracked = cookieAllowedByHeader(COOKIE_NAMES.VISITOR, h.get('cookie'), gate)
890
- // The stored cookie is read THROUGH that gate too, not just written through
891
- // it: a visitor who accepted last month and has since rejected still carries
892
- // one, and honouring it would re-identify them under the identity they
893
- // revoked. The header bridge is unaffected — the middleware already applied
894
- // the same rule when it minted the id this request carries.
895
- const visitorId =
896
- h.get('x-kywi-visitor') ??
897
- (tracked ? cookieStore.get(COOKIE_NAMES.VISITOR)?.value : undefined) ??
898
- 'vis-anon'
899
- const host = h.get('host') ?? 'localhost'
900
- const url = h.get('x-kywi-url') ?? \`http://\${host}/\`
901
-
902
- const reqHeaders = new Headers()
903
- h.forEach((value, key) => reqHeaders.set(key, value))
904
- const request = new Request(url, { headers: reqHeaders })
905
-
906
- const { db } = runtime
907
- // \`gate\` reaches the engine's server collectors, so a site whose consent is
908
- // owned by an external CMP (\`requireConsent: false\`) has its stored UTM /
909
- // visitor / known / pinned-audience cookies read here too, not just by the
910
- // middleware and the runtime routes.
911
- const { result, audiences } = await evaluateActiveAudiences(db, siteId, request, gate)
912
-
913
- const previewId = cookieStore.get(COOKIE_NAMES.PREVIEW_INIT)?.value || null
914
- const audienceId = isActiveAudience(previewId, audiences) ? previewId : result.winningAudienceId
915
-
916
- const experimentAssignments = await resolveExperimentAssignments(
917
- runtime,
918
- siteId,
919
- layout,
920
- audienceId,
921
- visitorId,
922
- tracked,
923
- )
746
+ function libKywi() {
747
+ return stamped(`// This app's ONE Kywi wiring point. The render path lives in
748
+ // @kywi-software/core/next and reads kywi.config.ts THROUGH this runtime — a
749
+ // package cannot import your config itself. Regenerated by
750
+ // \`create-kywi-app upgrade\`: make your changes in kywi.config.ts, not here.
751
+ import { createKywiNextRuntime } from '@kywi-software/core/next'
752
+ import config from '../kywi.config'
924
753
 
925
- return {
926
- personalization: { resolvedAudienceId: audienceId, experimentAssignments },
927
- audienceId,
928
- visitorId,
929
- tracked,
930
- audiences,
931
- signals: result.signals,
932
- }
933
- }
754
+ export const kywi = createKywiNextRuntime(config)
934
755
 
935
- /**
936
- * Deterministically assign this visitor to a variant of every running A/B
937
- * experiment the (audience-resolved) layout actually shows, and record the
938
- * exposure. Stable on visitorId → the visitor keeps the same arm across requests;
939
- * the write is idempotent on (experiment, visitor). Returns experimentId →
940
- * chosen variant key for <KywiLayout personalization.experimentAssignments>.
941
- *
942
- * \`tracked\` is the consent gate (kywi-cms#91): false means \`visitorId\` is a
943
- * throwaway the middleware minted for this request alone, and the idempotent
944
- * write stops being idempotent — every anonymous page view would insert another
945
- * assignment row. The ARM IS STILL CHOSEN, so the page renders exactly as it
946
- * does for anyone else; only the counting stops.
947
- *
948
- * Experiment ids come from \`collectLayoutExperimentIds\` — the engine's own
949
- * walker, the same one \`<KywiLayout>\` and the reference app use. It descends
950
- * into every section's columns as well as the top-level regions, so a
951
- * module-level \`moduleVariantContainer\` (an ab_test placed inside a section,
952
- * not a bare section-level band) is bucketed exactly like one — a hand-rolled
953
- * walker here previously stopped at the top level and silently never assigned
954
- * those (kywi-cms#221).
955
- */
956
- async function resolveExperimentAssignments(
957
- runtime: KywiRuntime,
958
- siteId: string,
959
- layout: LayoutDocument | null | undefined,
960
- audienceId: string | null,
961
- visitorId: string,
962
- tracked: boolean,
963
- ): Promise<Record<string, string | null>> {
964
- if (!layout) return {}
965
- // Pre-resolve FIRST, then read the containers. A container component saved
966
- // with \`mode: 'ab_test'\` keeps its experiment in the DEFINITION; a placement
967
- // that carries only a \`componentId\` would otherwise look like a plain
968
- // container to this pass, no assignment would be made, and every visitor would
969
- // see the Default arm of a live experiment. After pre-resolution there are no
970
- // unresolved placements left to special-case — the containers here are the
971
- // containers that render.
972
- const resolved = await resolveLayoutComponents(applyPageVariant(layout, audienceId), runtime, siteId)
973
- const experimentIds = collectLayoutExperimentIds(resolved)
974
- if (experimentIds.length === 0) return {}
975
-
976
- const running = await listRunningExperiments(runtime.db, siteId)
977
- const byId = new Map(running.map((e) => [e.id, e]))
978
- const assignments: Record<string, string | null> = {}
979
- for (const id of experimentIds) {
980
- const exp = byId.get(id)
981
- if (!exp) continue
982
- const res = await resolveExperimentForContainer(runtime.db, exp, visitorId, { recordExposure: tracked })
983
- assignments[id] = res.variantKey
984
- }
985
- return assignments
756
+ // Named accessors for the API + AX route handlers, which want the handler
757
+ // rather than the whole runtime.
758
+ export const { getKywi, getKywiHandler, getActiveSite } = kywi
759
+ `)
986
760
  }
987
761
 
762
+ // ── middleware.ts (a re-export of core's own) ─────────────────────────────────
763
+
988
764
  /**
989
- * Return the audience-resolved layout: page variants (audience → whole-page
990
- * region override) applied, and \`pageVariants\`/\`abExperiments\` stripped so no
991
- * other audience's content is serialized to this visitor.
765
+ * The auth gate, transparent session refresh, cookie→bearer bridge, visitor /
766
+ * UTM / entry-page cookies and `.md` content negotiation were 242 lines of
767
+ * emitted code; they are now `@kywi-software/core/next/middleware`.
992
768
  *
993
- * This is only the PAGE-level half. Container-level arms (variantContainer /
994
- * moduleVariantContainer) are stripped by \`pruneLayoutToServedArms\` as the last
995
- * pass before \`<KywiLayout>\` — see the public page (#167). Run both, or the
996
- * losing arms are still readable in the page's RSC flight payload.
769
+ * It has its OWN core entry rather than living on the `/next` barrel because
770
+ * Next compiles this file for the EDGE runtime: the barrel reaches the
771
+ * database, sharp and the AWS SDK, and re-exporting the middleware from it
772
+ * would drag all of that into the edge bundle. Same precedent as core's
773
+ * `./host` split (see that file's header in core).
997
774
  */
998
- export function personalizeLayout(
999
- layout: LayoutDocument,
1000
- audienceId: string | null,
1001
- ): LayoutDocument {
1002
- return applyPageVariant(layout, audienceId)
775
+ function middleware() {
776
+ return stamped(`// Auth gate, session refresh, the cookie→bearer bridge, the visitor/UTM/entry
777
+ // cookies and .md content negotiation — all core's, all upgraded with the
778
+ // package. Imported from its OWN entry, never the \`/next\` barrel: that barrel
779
+ // reaches the database and sharp, which no edge bundle can carry.
780
+ export { middleware, config } from '@kywi-software/core/next/middleware'
781
+ `)
1003
782
  }
1004
783
 
1005
- /** Is the @kywi-software/js client runtime enabled for the ACTIVE site's theme? */
1006
- export function clientRuntimeEnabled(runtime: KywiRuntime, activeSite?: SiteConfig): boolean {
1007
- const themeName = (activeSite ?? runtime.config.sites[0])?.theme // DEFAULT-SITE FALLBACK: Host matched no site
1008
- const theme = runtime.config.themes.find((t) => t.name === themeName) ?? runtime.config.themes[0]
1009
- return theme?.personalization?.clientRuntime === true
1010
- }
784
+ // ── kywi.layers.ts + the sites/ layer files ──────────────────────────────────
1011
785
 
1012
786
  /**
1013
- * Is the global transparency surface (the automatic pill/panel showing which
1014
- * audience the page was personalized for, with a switcher and opt-out) enabled
1015
- * for this site's theme? \`personalization.transparencyNotice.enabled\` — unset
1016
- * defaults to true; only an explicit \`false\` suppresses the surface from
1017
- * appearing on its own. Even then a \`personalizationBadge\` module can still
1018
- * open it deliberately.
787
+ * WHICH sites and themes a project's layer registry enumerates. A fresh
788
+ * scaffold has exactly one of each; a real project grows more, and `upgrade`
789
+ * derives this from the project's own `kywi.config.ts` (`registryLayout` over
790
+ * `lib/config-scan.mjs`'s scan) rather than from this hard-wired default.
791
+ *
792
+ * The registry must match the config EXACTLY: core's `assertLayerContracts`
793
+ * (plan E2) throws at boot when a configured site — or its theme — has no
794
+ * layer, so a registry regenerated from anything but the config is a
795
+ * production outage waiting for the next deploy.
796
+ * @typedef {{ sites: Array<{ id: string, theme: string|null }>, themes: Array<{ name: string }> }} RegistryLayout
1019
797
  */
1020
- export function transparencyNoticeEnabled(runtime: KywiRuntime, activeSite?: SiteConfig): boolean {
1021
- const themeName = (activeSite ?? runtime.config.sites[0])?.theme // DEFAULT-SITE FALLBACK: Host matched no site
1022
- const theme = runtime.config.themes.find((t) => t.name === themeName) ?? runtime.config.themes[0]
1023
- return theme?.personalization?.transparencyNotice?.enabled !== false
1024
- }
1025
798
 
1026
799
  /**
1027
- * Must a visitor consent before this site writes a \`personalization\` cookie
1028
- * (\`kywi_visitor\`, \`kywi_signals\`, \`kywi_utm\`, \`kywi_audience\`)?
1029
- * \`personalization.requireConsent\` — unset defaults to TRUE; only an explicit
1030
- * \`false\` turns Kywi's consent gate off, for a site that collects consent with
1031
- * external tooling (kywi-cms#91).
1032
- *
1033
- * \`middleware.ts\` runs on the edge and cannot read this config, so it mirrors
1034
- * the same flag with \`KYWI_REQUIRE_CONSENT=false\`. Set both, or neither.
1035
- *
1036
- * The flag is resolved from the ACTIVE site's theme, but as of core 0.17.0 every
1037
- * configured site in one deployment must agree on it: the edge mirror is a
1038
- * single process-wide env var and cannot carry two answers, so
1039
- * \`createKywiApiHandler\` FAILS STARTUP on a divergence rather than letting a
1040
- * second site silently inherit the first site's privacy default. A per-host
1041
- * mirror map would lift that constraint and is deferred
1042
- * (\`docs-site DESIGN.md §3.4\`).
800
+ * What a fresh scaffold emits: one site, one theme, both named `default`.
801
+ * @type {RegistryLayout}
1043
802
  */
1044
- export function requireConsentEnabled(runtime: KywiRuntime, activeSite?: SiteConfig): boolean {
1045
- const themeName = (activeSite ?? runtime.config.sites[0])?.theme // DEFAULT-SITE FALLBACK: Host matched no site
1046
- const theme = runtime.config.themes.find((t) => t.name === themeName) ?? runtime.config.themes[0]
1047
- return theme?.personalization?.requireConsent !== false
1048
- }
1049
-
1050
- // ─── Self-ID widget (#50) ────────────────────────────────────────────────────
1051
-
1052
- /** The self-ID widget config in the serializable shape the client runtime reads. */
1053
- export interface PublicSelfIdWidget {
1054
- fields: Array<{
1055
- id: string
1056
- label: string
1057
- type: 'select'
1058
- options?: Array<{ value: string; label: string }>
1059
- required?: boolean
1060
- }>
1061
- headline: string
1062
- subheadline?: string
1063
- submitLabel: string
1064
- skipLabel?: string
1065
- displayMode: 'modal' | 'inline' | 'slide-in' | 'hello_bar_top' | 'hello_bar_bottom' | 'drawer'
1066
- frequency: 'once' | 'session' | 'always'
1067
- trigger: SelfIdTrigger
1068
- }
1069
-
1070
- // The admin's five display modes (SelfIdWidgetConfig['displayMode']) and the
1071
- // client runtime's (PublicSelfIdWidget['displayMode'], @kywi-software/js) are the
1072
- // same set — the runtime implements hello_bar_top/hello_bar_bottom/drawer as
1073
- // their own layouts, not folded into slide-in (kywi-cms#97) — so this is a
1074
- // passthrough. Kept as a named function (rather than assigning displayMode
1075
- // directly) so the two types stay checked against each other at compile time.
1076
- function mapDisplayMode(mode: SelfIdWidgetConfig['displayMode']): PublicSelfIdWidget['displayMode'] {
1077
- return mode
1078
- }
1079
-
1080
- function mapFrequency(freq: SelfIdWidgetConfig['frequency']): PublicSelfIdWidget['frequency'] {
1081
- if (freq === 'once_visitor') return 'once'
1082
- if (freq === 'once_session') return 'session'
1083
- return 'always'
803
+ export const DEFAULT_REGISTRY_LAYOUT = {
804
+ sites: [{ id: 'default', theme: 'default' }],
805
+ themes: [{ name: 'default' }],
1084
806
  }
1085
807
 
1086
808
  /**
1087
- * Resolve the site's stored self-ID widget config into the runtime shape, its
1088
- * picklist fields hydrated. Returns null when there is no config or it references
1089
- * no resolvable fields, so the caller can skip mounting the widget entirely.
809
+ * A {@link scanKywiConfig} result narrowed to what the emitters need. It drops
810
+ * NOTHING — a site whose theme the scanner could not read comes through with
811
+ * `theme: null`, because `upgrade` has to be able to tell the owner which site
812
+ * it could not generate a theme layer for, and a silently shortened list
813
+ * cannot.
814
+ * @param {{ sites?: Array<{ id: string, theme?: string|null }>, themes?: Array<{ name: string }> }} scan
815
+ * @returns {RegistryLayout}
1090
816
  */
1091
- export async function resolveSelfIdWidget(
1092
- runtime: KywiRuntime,
1093
- siteId: string,
1094
- ): Promise<PublicSelfIdWidget | null> {
1095
- const [widget, allFields] = await Promise.all([
1096
- getSelfIdWidgetConfig(runtime.db, siteId),
1097
- listSelfIdFields(runtime.db, siteId),
1098
- ])
1099
- if (!widget) return null
1100
-
1101
- const byId = new Map<string, SelfIdField>(allFields.map((f) => [f.id, f]))
1102
- const fields = widget.fieldIds
1103
- .map((id) => byId.get(id))
1104
- .filter((f): f is SelfIdField => f != null)
1105
- .map((f) => ({
1106
- id: f.id,
1107
- label: f.label,
1108
- type: 'select' as const,
1109
- // {value, label} pairs, not bare values — the client runtime renders the
1110
- // label and submits the value (kywi-cms#96); dropping the label here is
1111
- // what made every option render as its raw value.
1112
- options: f.picklist,
1113
- required: f.required,
1114
- }))
1115
- if (fields.length === 0) return null
1116
-
817
+ export function registryLayout(scan) {
1117
818
  return {
1118
- fields,
1119
- headline: widget.headline,
1120
- ...(widget.subheadline ? { subheadline: widget.subheadline } : {}),
1121
- submitLabel: widget.submitLabel,
1122
- ...(widget.skipLabel ? { skipLabel: widget.skipLabel } : {}),
1123
- displayMode: mapDisplayMode(widget.displayMode),
1124
- frequency: mapFrequency(widget.frequency),
1125
- trigger: widget.trigger,
819
+ sites: (scan?.sites ?? []).map(({ id, theme }) => ({ id, theme: theme ?? null })),
820
+ themes: (scan?.themes ?? []).map(({ name }) => ({ name })),
1126
821
  }
1127
822
  }
1128
- `
823
+
824
+ /** A bare `key: value` is legal only for an identifier-safe key; everything else is quoted. */
825
+ const IDENTIFIER_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/
826
+
827
+ /** @param {string} key */
828
+ function objectKey(key) {
829
+ return IDENTIFIER_RE.test(key) ? key : `'${key.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
1129
830
  }
1130
831
 
1131
- // ── middleware.ts (thin framework wiring over @kywi-software/core/host) ───────
832
+ /** `northwind-analytics-demo` → `northwindAnalyticsDemo`; the import alias stem for a site id. */
833
+ function camelIdentifier(id) {
834
+ const parts = String(id).split(/[^A-Za-z0-9]+/).filter(Boolean)
835
+ if (parts.length === 0) return 'site'
836
+ const head = parts[0].replace(/^[0-9]+/, '') || 'x'
837
+ return (
838
+ head[0].toLowerCase() +
839
+ head.slice(1) +
840
+ parts.slice(1).map((p) => p[0].toUpperCase() + p.slice(1)).join('')
841
+ )
842
+ }
1132
843
 
1133
- function middleware() {
1134
- return `import { NextResponse, type NextRequest } from 'next/server'
1135
- import {
1136
- ACCESS_COOKIE,
1137
- REFRESH_COOKIE,
1138
- assertProductionAuthSecret,
1139
- classifyAccessToken,
1140
- setAccessCookie,
1141
- clearSessionCookies,
1142
- } from '@kywi-software/core/host'
1143
- // Edge-safe leaf helper: builds the kywi_utm cookie value core's collectUtm reads
1144
- // back. Its own dependency-free entry (never the DB-backed audiences barrel), so
1145
- // it is importable from this edge middleware.
1146
- import { UTM_COOKIE, utmCookieValue } from '@kywi-software/core/audiences/utm-persistence'
1147
- // Same kind of edge-safe leaf: the kywi_entry writer, whose value core reads
1148
- // back as \`session.entryPage\` (kywi-cms#196).
1149
- import { ENTRY_COOKIE, entryPageCookieValue } from '@kywi-software/core/audiences/entry-page'
1150
- // Same kind of edge-safe leaf entry: the cookie-consent category map + gate. The
1151
- // visitor's decision (written by the \`cookieConsent\` layout module) decides
1152
- // whether the personalization cookies below may be persisted at all.
1153
- import { cookieAllowedByHeader } from '@kywi-software/core/audiences/consent'
844
+ /** Keeps alias collisions (`my-site` and `my_site` both camel to `mySite`) out of the emitted file. */
845
+ function uniqueAlias(base, used) {
846
+ let alias = base
847
+ for (let n = 2; used.has(alias); n++) alias = `${base}${n}`
848
+ used.add(alias)
849
+ return alias
850
+ }
1154
851
 
1155
852
  /**
1156
- * The edge mirror of \`theme.personalization.requireConsent\` (kywi-cms#91).
1157
- * Middleware runs on the edge and must not import \`kywi.config.ts\` (a config is
1158
- * free to pull in plugins and DB-backed code that cannot run there), so the one
1159
- * flag that lives in config is mirrored here as an env var.
1160
- * \`KYWI_REQUIRE_CONSENT=false\` turns the gate off for the two cookies this file
1161
- * writes. Set it together with the theme flag, never one alone —
1162
- * createKywiApiHandler THROWS at startup when the two disagree, so a mismatch
1163
- * fails the deploy instead of silently making the middleware and the API
1164
- * disagree about whether a visitor is tracked.
853
+ * Group a layout's sites into ONE registry entry per site id, each carrying its
854
+ * distinct themes with a deterministic import alias. Grouping (rather than
855
+ * emitting one entry per array element) is what stops a config that names a
856
+ * site twice from producing a file with a duplicate object key and two
857
+ * identical `import * as` lines — neither of which compiles.
1165
858
  */
1166
- const REQUIRE_CONSENT = process.env.KYWI_REQUIRE_CONSENT !== 'false'
859
+ function registryEntries(layout) {
860
+ /** @type {Map<string, { id: string, themes: string[] }>} */
861
+ const bySite = new Map()
862
+ for (const site of layout.sites ?? []) {
863
+ const entry = bySite.get(site.id) ?? { id: site.id, themes: [] }
864
+ if (site.theme && !entry.themes.includes(site.theme)) entry.themes.push(site.theme)
865
+ bySite.set(site.id, entry)
866
+ }
867
+
868
+ const used = new Set()
869
+ return [...bySite.values()].map(({ id, themes }) => {
870
+ const stem = camelIdentifier(id)
871
+ return {
872
+ id,
873
+ siteAlias: uniqueAlias(`${stem}Site`, used),
874
+ themes: themes.map((theme) => ({ theme, alias: uniqueAlias(`${stem}Theme`, used) })),
875
+ }
876
+ })
877
+ }
1167
878
 
1168
879
  /**
1169
- * Server-side auth enforcement + transparent session refresh for /admin and the
1170
- * versioned API. The security-critical logic (JWT verify, cookie contract) lives
1171
- * in @kywi-software/core/host; this file is only the Next.js wiring:
1172
- *
1173
- * 1. Refresh a stale access cookie from the 7-day refresh cookie, so an admin
1174
- * is never bounced to /admin/login mid-session.
1175
- * 2. Bridge the httpOnly access cookie onto \`Authorization: Bearer\` for
1176
- * /api/v1/* — core reads credentials only from that header, and the browser
1177
- * cannot attach it (the token is httpOnly).
1178
- * 3. Gate /admin/* (except /admin/login): no usable session → redirect to login.
1179
- *
1180
- * Must resolve the SAME secret handed to createKywiApiHandler (config.auth.secret
1181
- * === process.env.AUTH_SECRET ?? default), or it would reject tokens the API accepts.
880
+ * `kywi.layers.ts` — the build-time enumerable registry core's layer resolver
881
+ * reads. Every site in the project's config gets an entry; every (site, theme)
882
+ * pair gets an import, because a theme layer lives UNDER its site
883
+ * (`sites/<id>/themes/<theme>/`), so two sites sharing the name `default` are
884
+ * two different modules, not one.
885
+ * @param {RegistryLayout} layout
1182
886
  */
887
+ export function kywiLayers(layout = DEFAULT_REGISTRY_LAYOUT) {
888
+ const entries = registryEntries(layout)
889
+ const imports = [
890
+ `import type { KywiLayerRegistry } from '@kywi-software/core/next'`,
891
+ ...entries.map((e) => `import * as ${e.siteAlias} from './sites/${e.id}'`),
892
+ ...entries.flatMap((e) =>
893
+ e.themes.map((t) => `import * as ${t.alias} from './sites/${e.id}/themes/${t.theme}'`),
894
+ ),
895
+ ]
896
+ const body = entries.map((e) => {
897
+ const themes =
898
+ e.themes.length === 0
899
+ ? ' themes: {},'
900
+ : [' themes: {', ...e.themes.map((t) => ` ${objectKey(t.theme)}: ${t.alias},`), ' },'].join('\n')
901
+ return [` ${objectKey(e.id)}: {`, ` site: ${e.siteAlias},`, themes, ' },'].join('\n')
902
+ })
1183
903
 
1184
- // Refuse to run in production with a missing or dev-fallback AUTH_SECRET — this
1185
- // middleware verifies session JWTs, so a publicly-known default would make them
1186
- // forgeable. Runs at module load (edge bundle init), so a bad prod deploy fails
1187
- // loudly instead of silently accepting forged tokens. No-op in development.
1188
- assertProductionAuthSecret(process.env.AUTH_SECRET)
1189
-
1190
- const AUTH_SECRET = process.env.AUTH_SECRET ?? 'dev-secret-change-in-production'
904
+ return stamped(`// Generated registry for Kywi's layer chain. Regenerated by create-kywi-app
905
+ // upgrade; edit files under sites/<site>/ instead of editing this file.
906
+ ${imports.join('\n')}
1191
907
 
1192
- // Token/session endpoints are pass-through: never guarded, refreshed, or bridged
1193
- // (they establish a session rather than require one; the refresh call below is
1194
- // itself a POST to /auth/refresh and must not recurse).
1195
- function isAuthEndpoint(pathname: string): boolean {
1196
- return pathname.startsWith('/api/v1/auth/') &&
1197
- !pathname.startsWith('/api/v1/auth/oauth-clients') &&
1198
- !pathname.startsWith('/api/v1/auth/api-keys')
908
+ export const layers = {
909
+ ${body.join('\n')}
910
+ } satisfies KywiLayerRegistry
911
+ `)
1199
912
  }
1200
913
 
1201
- // Anonymous visitor identity for personalization/experiments. A/B assignment is
1202
- // deterministic on this id, so persisting it (2y cookie) is what gives a visitor
1203
- // a STABLE experiment arm across requests without any client runtime (#50). Name
1204
- // matches @kywi-software/core/audiences COOKIE_NAMES.VISITOR; kept as a literal
1205
- // here so the edge middleware never imports the (server-only) audiences module.
1206
- const VISITOR_COOKIE = 'kywi_visitor'
1207
- const VISITOR_MAX_AGE = 60 * 60 * 24 * 365 * 2 // 2 years
1208
-
1209
914
  /**
1210
- * Public (non-admin, non-API) request: never auth-gated. Ensure a stable
1211
- * \`kywi_visitor\` id — mint one on first visit — and forward it to the render as
1212
- * \`x-kywi-visitor\` so the page sees it on this very request (the freshly set
1213
- * cookie is not yet readable via cookies()). Also forward the full request URL
1214
- * as \`x-kywi-url\` (query string included) so the server render can read UTM
1215
- * params off the very first request — without it, campaign personalization
1216
- * would never fire on the landing page. Everything else passes through.
915
+ * `sites/<id>/index.ts` — identical for every site: the custom-module map is
916
+ * project-wide (one `lib/modules.tsx`), so what varies per site is the theme
917
+ * and the content, not this re-export.
1217
918
  */
1218
- function handlePublicRequest(req: NextRequest): NextResponse {
1219
- // Both cookies below are \`personalization\` cookies in core's consent map, so
1220
- // neither is written OR READ until the visitor accepts. The header bridge is
1221
- // unaffected — this request still gets a visitor id and still personalizes off
1222
- // its own URL — so a site with no consent banner still renders, it just serves
1223
- // default content instead of tracking anyone. Place the \`cookieConsent\`
1224
- // module to let visitors turn personalization on.
1225
- const cookieHeader = req.headers.get('cookie')
1226
- const gate = { requireConsent: REQUIRE_CONSENT }
1227
- const mayPersonalize = cookieAllowedByHeader(VISITOR_COOKIE, cookieHeader, gate)
1228
- // Without consent the stored id is IGNORED, not just left un-refreshed: a
1229
- // visitor who accepted last month and rejected today still carries the cookie,
1230
- // and reading it would keep them tracked under the identity they just revoked.
1231
- const existing = mayPersonalize ? req.cookies.get(VISITOR_COOKIE)?.value : undefined
1232
- const visitorId = existing ?? crypto.randomUUID()
1233
- const headers = new Headers(req.headers)
1234
- headers.set('x-kywi-visitor', visitorId)
1235
- headers.set('x-kywi-url', req.nextUrl.href)
1236
- const res = NextResponse.next({ request: { headers } })
1237
- if (!existing && mayPersonalize) {
1238
- res.cookies.set(VISITOR_COOKIE, visitorId, { path: '/', maxAge: VISITOR_MAX_AGE, sameSite: 'lax' })
1239
- }
1240
- // Persist campaign UTM params so audience matching survives internal
1241
- // navigation: core's collectUtm reads this cookie when the URL has no utm_*
1242
- // query string. Only written when the request carries utm_* params, so an
1243
- // ordinary page view never clobbers a persisted campaign (utmCookieValue
1244
- // returns null → the existing cookie is left in place) — and only once the
1245
- // visitor has consented, which utmCookieValue reads off the header we pass.
1246
- const utmValue = utmCookieValue(req.nextUrl.href, { cookieHeader, ...gate })
1247
- if (utmValue) {
1248
- res.cookies.set(UTM_COOKIE, utmValue, { path: '/', maxAge: 60 * 60 * 24 * 30, sameSite: 'lax' })
1249
- }
1250
- // Record the session's ENTRY page (path + query) so rules on
1251
- // \`session.entryPage\` keep matching once the visitor navigates on
1252
- // (kywi-cms#196). Written once, consent-gated like the two above, and with no
1253
- // maxAge — a new visit is a new entry page. The landing request needs no
1254
- // cookie: core falls back to the request's own path, so \`?from=marketer\`
1255
- // personalizes the page it links to on that very request.
1256
- // Sec-Fetch-Dest / Accept are passed so ONLY a document request can name the
1257
- // entry page: a subresource that happens to be the session's first request
1258
- // (\`/kywi.js\`, a font, a route handler) would otherwise record itself as the
1259
- // page the visitor arrived on, and every entry-page rule would match an asset.
1260
- const entryValue = entryPageCookieValue(req.nextUrl.href, {
1261
- cookieHeader,
1262
- secFetchDest: req.headers.get('sec-fetch-dest'),
1263
- accept: req.headers.get('accept'),
1264
- ...gate,
1265
- })
1266
- if (entryValue) {
1267
- res.cookies.set(ENTRY_COOKIE, entryValue, { path: '/', sameSite: 'lax' })
1268
- }
1269
- return res
1270
- }
1271
-
1272
- async function fetchFreshAccessToken(origin: string, refreshToken: string): Promise<string | undefined> {
1273
- try {
1274
- const res = await fetch(origin + '/api/v1/auth/refresh', {
1275
- method: 'POST',
1276
- headers: { 'content-type': 'application/json' },
1277
- body: JSON.stringify({ refreshToken }),
1278
- })
1279
- if (!res.ok) return undefined
1280
- const body = (await res.json().catch(() => null)) as { data?: { accessToken?: unknown } } | null
1281
- const token = body?.data?.accessToken
1282
- return typeof token === 'string' && token.length > 0 ? token : undefined
1283
- } catch {
1284
- return undefined
1285
- }
919
+ function siteLayer() {
920
+ return `// This site's layer: overrides core defaults and loses to theme overrides.
921
+ // The custom-module map stays authored in lib/modules.tsx (kywi-cms#48);
922
+ // this re-export is what wires it into the layer chain.
923
+ export { moduleComponents as modules } from '../../lib/modules'
924
+ `
1286
925
  }
1287
926
 
1288
- // Markdown content negotiation by URL suffix. Core serves markdown at
1289
- // \`/api/v1/ax/md/slug/<slug>\` and the AX layer enables it (ax.markdown), but the
1290
- // scaffold never wires the friendly \`/<path>.md\` URL the AX pitch (and the
1291
- // developer copy) names — so it 404s despite the feature being on. Rewrite any
1292
- // GET/HEAD for \`/<path>.md\` onto the core route so the named URL actually
1293
- // resolves. Nested paths keep their full slug (e.g. /docs/foo.md -> docs/foo).
1294
- // Never intercepts Next internals or the API — those never carry this suffix,
1295
- // but are excluded explicitly since the matcher's own exclusion is broad.
1296
- function handleMarkdownNegotiation(req: NextRequest): NextResponse | null {
1297
- const { pathname } = req.nextUrl
1298
- if (req.method !== 'GET' && req.method !== 'HEAD') return null
1299
- if (!pathname.endsWith('.md')) return null
1300
- if (pathname.startsWith('/api/') || pathname.startsWith('/_next/')) return null
1301
- const slug = pathname.replace(/^\\//, '').replace(/\\.md$/, '')
1302
- if (!slug) return null
1303
- const url = req.nextUrl.clone()
1304
- url.pathname = '/api/v1/ax/md/slug/' + slug
1305
- return NextResponse.rewrite(url)
1306
- }
1307
-
1308
- export async function middleware(req: NextRequest): Promise<NextResponse> {
1309
- const { pathname } = req.nextUrl
1310
- if (isAuthEndpoint(pathname)) return NextResponse.next()
1311
-
1312
- const md = handleMarkdownNegotiation(req)
1313
- if (md) return md
1314
-
1315
- // Public pages are never auth-gated — just give them a stable visitor id.
1316
- const isAdmin = pathname === '/admin' || pathname.startsWith('/admin/')
1317
- const isApi = pathname.startsWith('/api/v1/')
1318
- if (!isAdmin && !isApi) return handlePublicRequest(req)
1319
-
1320
- const accessToken = req.cookies.get(ACCESS_COOKIE)?.value
1321
- const refreshToken = req.cookies.get(REFRESH_COOKIE)?.value
1322
- const status = await classifyAccessToken(accessToken, AUTH_SECRET)
1323
-
1324
- let freshAccess: string | undefined
1325
- if (status !== 'valid' && refreshToken) {
1326
- freshAccess = await fetchFreshAccessToken(req.nextUrl.origin, refreshToken)
1327
- }
1328
- const usableToken = status === 'valid' ? accessToken : freshAccess
1329
-
1330
- // /api/v1/*: bridge cookie → Authorization header.
1331
- if (pathname.startsWith('/api/v1/')) {
1332
- if (usableToken && !req.headers.get('authorization')) {
1333
- const headers = new Headers(req.headers)
1334
- headers.set('authorization', 'Bearer ' + usableToken)
1335
- const res = NextResponse.next({ request: { headers } })
1336
- if (freshAccess) setAccessCookie(res, freshAccess)
1337
- return res
1338
- }
1339
- const res = NextResponse.next()
1340
- if (status !== 'valid' && refreshToken && !freshAccess) clearSessionCookies(res)
1341
- return res
1342
- }
927
+ /**
928
+ * `sites/<id>/themes/<theme>/index.tsx`. The four `../` are fixed, not derived:
929
+ * the path shape is always `sites/<id>/themes/<theme>/index.tsx`, four levels
930
+ * below the project root, whatever the site and theme are called.
931
+ * @param {Answers['mode']} mode - nav template only in coupled mode (components/site-nav.tsx is a coupled-only emit).
932
+ */
933
+ function themeLayer(mode) {
934
+ if (mode !== 'coupled') {
935
+ return `import type { KywiLayerModule } from '@kywi-software/core/next'
1343
936
 
1344
- // /admin/*: server-side gate. Login must stay reachable while anonymous.
1345
- if (pathname === '/admin/login') return NextResponse.next()
1346
- if (status === 'valid') return NextResponse.next()
1347
- if (freshAccess) {
1348
- const res = NextResponse.next()
1349
- setAccessCookie(res, freshAccess)
1350
- return res
937
+ export const templates = {} satisfies NonNullable<KywiLayerModule['templates']>
938
+ `
1351
939
  }
940
+ return `import { SiteNav } from '../../../../components/site-nav'
941
+ import type { KywiLayerModule } from '@kywi-software/core/next'
1352
942
 
1353
- const loginUrl = req.nextUrl.clone()
1354
- loginUrl.pathname = '/admin/login'
1355
- loginUrl.search = ''
1356
- loginUrl.searchParams.set('next', pathname + req.nextUrl.search)
1357
- const res = NextResponse.redirect(loginUrl)
1358
- clearSessionCookies(res)
1359
- return res
1360
- }
1361
-
1362
- export const config = {
1363
- matcher: [
1364
- '/admin/:path*',
1365
- '/api/v1/:path*',
1366
- // Public pages, for the visitor-id cookie above. Excludes Next internals and
1367
- // any path with a file extension (static assets, /favicon.ico, /kywi.js, and
1368
- // the AX files /robots.txt, /sitemap.xml, /llms*.txt).
1369
- '/((?!_next/|.*\\\\..*).*)',
1370
- // \`/<path>.md\` — the markdown-negotiation URL. The extension-excluding
1371
- // pattern above skips it, so it needs its own entry to reach
1372
- // handleMarkdownNegotiation.
1373
- '/((?!_next/|api/).*\\\\.md)',
1374
- ],
1375
- }
943
+ export const templates = {
944
+ nav: SiteNav,
945
+ } satisfies NonNullable<KywiLayerModule['templates']>
1376
946
  `
1377
947
  }
1378
948
 
949
+ /**
950
+ * The `sites/**` half of the layer chain: one index per site, one per
951
+ * (site, theme) pair. Paired with {@link kywiLayers} — the registry names
952
+ * exactly these files, so they are generated from the same layout or the
953
+ * project does not compile.
954
+ * @param {RegistryLayout} layout
955
+ * @param {Answers['mode']} mode
956
+ * @returns {Record<string, string>}
957
+ */
958
+ export function layerFilesFor(layout, mode) {
959
+ /** @type {Record<string, string>} */
960
+ const files = {}
961
+ for (const site of layout.sites ?? []) {
962
+ files[`sites/${site.id}/index.ts`] = siteLayer()
963
+ if (site.theme) files[`sites/${site.id}/themes/${site.theme}/index.tsx`] = themeLayer(mode)
964
+ }
965
+ return files
966
+ }
967
+
1379
968
  // ── root layout + public site ────────────────────────────────────────────────
1380
969
 
1381
970
  function rootLayout() {
@@ -1442,114 +1031,36 @@ export function SiteNav({ items, variant, ariaLabel, depth }: SiteNavProps) {
1442
1031
  `
1443
1032
  }
1444
1033
 
1445
- /** @param {Answers} a — coupled public site shell. */
1446
- function siteLayout(a) {
1447
- return `import React from 'react'
1448
- import { headers } from 'next/headers'
1449
- import { themeTokenStyleBlock } from '@kywi-software/core/layout'
1450
- import { navTreeToMenuItems, normalizePath } from '@kywi-software/core/nav'
1451
- // Neutral, token-driven defaults for everything Kywi renders (prose, the layout
1452
- // grid + modules, forms, the edit overlay). Restyle via theme tokens, not by
1453
- // editing this — see README → "Theming".
1454
- import '@kywi-software/core/site/styles.css'
1455
- // This app's OWN chrome (header / footer / page wrapper). Yours to edit freely.
1456
- import './site.css'
1457
- import { getActiveSite } from '../../lib/kywi'
1458
- import { SiteNav } from '../../components/site-nav'
1459
- import { KywiJsLoader } from '../../components/kywi-js-loader'
1460
-
1461
1034
  /**
1462
- * Public site shell. Header + footer carry this project's brand; edit them (and
1463
- * site.css) freely — this is your app's own layer over the DB-backed content
1464
- * that ${'app/(site)/[[...slug]]'} renders.
1465
- *
1466
- * The header/footer nav is DATA-DRIVEN, never hand-typed: it renders the
1467
- * owner-managed \`'main'\` menu (Admin → Menus) when one exists, falling back to
1468
- * the published Site Tree (\`isNav\` pages) so a fresh site has working nav out
1469
- * of the box — and the footer renders a \`'footer'\` menu when one exists. NEVER
1470
- * hardcode this list: a menu item's href resolves from its linked page and
1471
- * can never drift the way a pasted URL can (see AGENTS.md → Navigation).
1472
- * Rendered through core's shared nav renderer via the generated
1473
- * \`components/site-nav.tsx\` (a \`'use client'\` wrapper — see that file for why)
1474
- * rather than bespoke markup, so it gets the same CSS and \`@kywi-software/js\`
1475
- * enhancement as a \`navigation\` module placed in the page body.
1035
+ * The public site shell — a thin delegate over `createSiteLayout`.
1476
1036
  *
1477
- * The active site's theme tokens (kywi.config.ts → themes[].tokens) are flattened
1478
- * into a \`:root { --kywi-* }\` block by \`themeTokenStyleBlock\` and injected below,
1479
- * so kywi.config.ts is the single source of truth for the palette and spacing and
1480
- * both site.css and core's default styles resolve against those variables.
1037
+ * Three things stay in the generated file, and only three:
1481
1038
  *
1482
- * Everything it renders — the menus, the nav-tree fallback and the theme block —
1483
- * is keyed on the site the REQUEST resolved to, so one app serves each of its
1484
- * configured domains its own nav and its own palette.
1039
+ * 1. **Both CSS imports.** Next admits a global stylesheet only from a file
1040
+ * inside `app/`, and a published package cannot import the project's own
1041
+ * `site.css` at all (`layered-architecture DESIGN.md` §4.1).
1042
+ * 2. **The layer registry**, which gives core the active site/theme override
1043
+ * modules and templates while keeping this route a thin delegate.
1485
1044
  */
1045
+ function siteLayout() {
1046
+ return stamped(`// The public site shell — header, footer, data-driven nav and the active site's
1047
+ // theme tokens, all resolved per request. It lives in @kywi-software/core/next;
1048
+ // this file wires in THIS app's runtime and generated layer registry, and
1049
+ // imports the stylesheets (Next takes a global stylesheet only from a file in
1050
+ // app/, and a package cannot import yours). Restyle via site.css, theme tokens
1051
+ // (kywi.config.ts), sites/default/** and components/site-nav.tsx.
1052
+ import { createSiteLayout } from '@kywi-software/core/next'
1053
+ import '@kywi-software/core/site/styles.css'
1054
+ import './site.css'
1055
+ import { kywi } from '../../lib/kywi'
1056
+ import { layers } from '../../kywi.layers'
1486
1057
 
1487
- // Content edits (a renamed page, a reordered menu) must show up without a
1488
- // restart — same reasoning as the page route.
1489
- export const dynamic = 'force-dynamic'
1490
-
1491
- export default async function SiteLayout({ children }: { children: React.ReactNode }) {
1492
- // The site this request is FOR (resolved from its Host header), never the
1493
- // default site: the menus, the nav tree and the theme below are all keyed on
1494
- // it, so a second domain gets its own nav and its own palette.
1495
- const { runtime, site, siteId } = await getActiveSite()
1496
- const { scope } = runtime
1497
-
1498
- // Resolved PER REQUEST, not at module scope: a module-scope constant is
1499
- // evaluated once at import, so every host would emit the first site's tokens
1500
- // and a second site could not be themed at all.
1501
- const siteTheme =
1502
- runtime.config.themes.find((t) => t.name === site.theme) ?? runtime.config.themes[0]
1503
- const themeVars = themeTokenStyleBlock(siteTheme?.tokens)
1504
-
1505
- // The middleware forwards the full request URL as \`x-kywi-url\` (see
1506
- // middleware.ts) so this shared layout can resolve which page is current —
1507
- // without it, isActive/isAncestor on nav items would have nothing to match.
1508
- const h = await headers()
1509
- const url = h.get('x-kywi-url')
1510
- const currentPath = normalizePath(url ? new URL(url).pathname : '/')
1511
-
1512
- const mainMenu = await scope.menus.getResolved(siteId, 'main', currentPath)
1513
- const headerItems = mainMenu ?? navTreeToMenuItems(await scope.nav.getTree(siteId, currentPath))
1514
- const footerItems = await scope.menus.getResolved(siteId, 'footer', currentPath)
1058
+ const layout = createSiteLayout({ runtime: kywi, layers })
1515
1059
 
1516
- return (
1517
- <div className="site-shell">
1518
- {themeVars ? <style dangerouslySetInnerHTML={{ __html: themeVars }} /> : null}
1519
- {/* /kywi.js for the nav's hover-intent/keyboard/viewport-flip enhancement
1520
- (kywi-cms#114) — independent of personalization, gated on the exact
1521
- same condition <SiteNav> renders on below (a non-empty header menu),
1522
- so it loads whenever a \`[data-kywi-nav]\` root actually exists. Shares
1523
- its dedup with <PersonalizationRuntime> (lib/kywi-js-loader.ts), so a
1524
- page with both nav AND the client runtime enabled loads it once. */}
1525
- {headerItems.length > 0 && <KywiJsLoader />}
1526
-
1527
- <header className="site-header">
1528
- <div className="site-header__inner">
1529
- <a className="site-brand" href="/">${escapeJsxText(a.projectName)}</a>
1530
- <div className="site-nav">
1531
- {headerItems.length > 0 && (
1532
- <SiteNav items={headerItems} variant="horizontal" ariaLabel="Primary" depth={2} />
1533
- )}
1534
- <a className="site-nav__admin" href="/admin">Admin →</a>
1535
- </div>
1536
- </div>
1537
- </header>
1538
-
1539
- <main className="site-main">{children}</main>
1540
-
1541
- <footer className="site-footer">
1542
- <div className="site-footer__inner">
1543
- {footerItems && footerItems.length > 0 && (
1544
- <SiteNav items={footerItems} variant="footer" ariaLabel="Footer" />
1545
- )}
1546
- <p className="site-footer__credit">Powered by <a href="https://kywi.dev">Kywi CMS</a></p>
1547
- </div>
1548
- </footer>
1549
- </div>
1550
- )
1551
- }
1552
- `
1060
+ export default layout.default
1061
+ // A LITERAL — same reason as the page route below it in the tree.
1062
+ export const dynamic = 'force-dynamic'
1063
+ `)
1553
1064
  }
1554
1065
 
1555
1066
  /** @param {Answers} a — the app's OWN public-site chrome CSS (header/footer/page). */
@@ -1654,759 +1165,38 @@ function siteStyles(a) {
1654
1165
  `
1655
1166
  }
1656
1167
 
1657
- /** Coupled catch-all: renders home ("/") and any published page at its path. */
1658
- function siteSlugPage() {
1659
- return `import React, { cache } from 'react'
1660
- import type { Metadata } from 'next'
1661
- import { notFound } from 'next/navigation'
1662
- import { cookies, headers } from 'next/headers'
1663
- import { KywiBody, KywiEditableAttribute, KywiEditableRegion } from '@kywi-software/core/scope-client'
1664
- import {
1665
- KywiLayout,
1666
- KywiRegion,
1667
- AudienceMetaTags,
1668
- hydrateLayoutFeeds,
1669
- hydrateLayoutNav,
1670
- pruneLayoutToServedArms,
1671
- type LayoutDocument,
1672
- } from '@kywi-software/core/layout'
1673
- import { KywiJsonLd } from '@kywi-software/core/scope'
1674
- import { ACCESS_COOKIE, canAccessContent, readSessionClaims } from '@kywi-software/core/host'
1675
- import config from '../../../lib/config'
1676
- import { getActiveSite } from '../../../lib/kywi'
1677
- import {
1678
- resolvePublicContent,
1679
- resolvePersonalization,
1680
- personalizeLayout,
1681
- resolveSelfIdWidget,
1682
- clientRuntimeEnabled,
1683
- transparencyNoticeEnabled,
1684
- requireConsentEnabled,
1685
- buildFeedResolver,
1686
- resolveLayoutComponents,
1687
- localeAlternates,
1688
- mediaUrl,
1689
- requestBaseUrl,
1690
- } from '../../../lib/site'
1691
- import { moduleComponents } from '../../../lib/modules'
1692
- import { PersonalizationRuntime } from '../../../components/personalization-runtime'
1693
- import { KywiFrontEdit } from '../kywi-front-edit'
1694
-
1695
- // Every page comes from the database, so this route is always dynamic.
1696
- export const dynamic = 'force-dynamic'
1697
-
1698
- type Params = { params: Promise<{ slug?: string[] }> }
1699
- type Search = { searchParams: Promise<Record<string, string | string[] | undefined>> }
1700
-
1701
- // One resolution shared by generateMetadata and the page render (React.cache
1702
- // dedupes within a request). Keyed on stable primitives so the two calls dedupe:
1703
- // the slug segments joined (segments never contain "/") and the Accept-Language
1704
- // header, which selects the locale variant.
1705
- const getNode = cache((key: string, acceptLanguage: string | null) =>
1706
- resolvePublicContent(key ? key.split('/') : [], { acceptLanguage }),
1707
- )
1708
-
1709
- // SEO: map the admin's SEO-tab fields — Meta Title / Description / Keywords, the
1710
- // OG Image (metaImageId, falling back to the page's Featured Image), Canonical,
1711
- // Robots index/follow, and (on a multi-locale site) hreflang alternates — onto
1712
- // Next's Metadata. The sitemap-only fields (changeFreq, sitemapPriority) are not
1713
- // <head> metadata.
1714
- export async function generateMetadata({ params }: Params): Promise<Metadata> {
1715
- const { slug } = await params
1716
- const acceptLanguage = (await headers()).get('accept-language')
1717
- const node = await getNode((slug ?? []).join('/'), acceptLanguage)
1718
- if (!node || node['status'] !== 'published') return {}
1719
-
1720
- const title = (node['metaTitle'] as string) || String(node['title'] ?? 'Untitled')
1721
- const description = (node['metaDescription'] as string) || undefined
1722
- const keywords = (node['metaKeywords'] as string) || undefined
1723
- const image = mediaUrl(node['metaImageId']) ?? mediaUrl(node['featuredImageId'])
1724
- const images = image ? [image] : undefined
1725
- const canonical = (node['canonicalUrl'] as string) || undefined
1726
- const languages = await localeAlternates(slug ?? [])
1727
- const alternates =
1728
- canonical || languages
1729
- ? { ...(canonical ? { canonical } : {}), ...(languages ? { languages } : {}) }
1730
- : undefined
1731
-
1732
- return {
1733
- title,
1734
- description,
1735
- keywords,
1736
- ...(alternates ? { alternates } : {}),
1737
- robots: { index: node['robotsIndex'] !== 'noindex', follow: node['robotsFollow'] !== 'nofollow' },
1738
- openGraph: { title, description, images, type: 'website' },
1739
- twitter: { card: images ? 'summary_large_image' : 'summary', title, description, images },
1740
- }
1741
- }
1742
-
1743
- // True once the Layout editor has placed at least one section into any region.
1744
- // A page with an empty layout document falls back to its Body rich text.
1745
- function hasRenderableLayout(layout: LayoutDocument | null | undefined): layout is LayoutDocument {
1746
- if (!layout || typeof layout !== 'object' || !layout.regions) return false
1747
- return Object.values(layout.regions).some(
1748
- (sections) => Array.isArray(sections) && sections.length > 0,
1749
- )
1750
- }
1751
-
1752
- // Is the current visitor a signed-in admin who may edit? The public route is
1753
- // outside the middleware's auth matcher, so verify the httpOnly session cookie
1754
- // here and derive the content permissions the overlay needs. Returns null for
1755
- // anyone who cannot edit — no edit DOM (not even the browse-mode toolbar) is
1756
- // emitted for them. Called on EVERY request (not just ?kywi-edit=1 ones) so the
1757
- // toolbar can surface itself for a signed-in admin who is just browsing — this
1758
- // is a cookie read + JWT verify, no DB round-trip, so the cost on an anonymous
1759
- // visitor's fast path is a fast null return (\`readSessionClaims\` bails
1760
- // immediately when there's no cookie).
1761
- async function resolveEditPermissions() {
1762
- const token = (await cookies()).get(ACCESS_COOKIE)?.value
1763
- const claims = await readSessionClaims(token, config.auth.secret)
1764
- if (!claims || !canAccessContent(claims.role, 'write')) return null
1765
- return { canEdit: true, canPublish: canAccessContent(claims.role, 'publish'), role: claims.role }
1766
- }
1767
-
1768
- // "/" resolves the seeded Home node; any other URL resolves the published node at
1769
- // that FULL Site Tree path (locale-aware), so /a/b/<slug> cannot serve a
1770
- // top-level <slug> (#30, #51). Draft / missing content 404s.
1771
- export default async function PublicPage({ params, searchParams }: Params & Search) {
1772
- const { slug } = await params
1773
- const acceptLanguage = (await headers()).get('accept-language')
1774
- const node = await getNode((slug ?? []).join('/'), acceptLanguage)
1775
- if (!node || node['status'] !== 'published') notFound()
1776
-
1777
- // The site this request is FOR, resolved from its Host header. \`siteId\` (not
1778
- // \`runtime.siteId\`, which is the DEFAULT site) keys every site-scoped call
1779
- // below; \`site\` selects the theme the flag helpers read.
1780
- const { runtime, site, siteId } = await getActiveSite()
1781
- const contentId = String(node['id'] ?? '')
1782
- const contentType = String(node['contentTypeName'] ?? 'page')
1783
- const title = String(node['title'] ?? 'Untitled')
1784
- const body = (node['body'] as string) || ''
1785
- const featured = mediaUrl(node['featuredImageId'])
1786
- const layout = node['layout'] as LayoutDocument | null | undefined
1787
-
1788
- // Resolved on every request so a signed-in admin gets the persistent browse
1789
- // toolbar even when just browsing — ?kywi-edit=1 only decides whether the
1790
- // page auto-enters the full overlay editor on mount (kywi-cms#93). An
1791
- // anonymous/read-only visitor resolves to perms === null: zero extra client
1792
- // JS, identical output to before this route ever heard of the overlay.
1793
- const editRequested = (await searchParams)['kywi-edit'] === '1'
1794
- const perms = await resolveEditPermissions()
1795
-
1796
- // Server-side personalization (#50), evaluated once: the winning audience (or a
1797
- // kywi_preview_init preview) drives page variants + variantContainers, and this
1798
- // visitor's A/B arms are assigned deterministically. Opted-out / anonymous
1799
- // visitors resolve to the default experience.
1800
- const perso = await resolvePersonalization(runtime, layout)
1801
- const selfIdWidget = await resolveSelfIdWidget(runtime, siteId)
1802
-
1803
- // <head> injections (React hoists these): per-page JSON-LD gated on the
1804
- // AX/JSON-LD setting, mapping the SAME node so page and schema agree (#56); the
1805
- // Comments module's content-id anchor (#29); the audience/visitor ids for
1806
- // client tooling (#50); and — when the theme opts in — the optional client
1807
- // runtime that mounts the self-ID widget and re-evaluates audiences.
1808
- //
1809
- // \`head\` is a FUNCTION because JSON-LD must describe the layout THIS visitor
1810
- // is served (#195): the served document only exists after the arms are pruned
1811
- // below, and building the description from the STORED layout would ship the
1812
- // DEFAULT arm's copy to a matched visitor — the #167 leak, through structured
1813
- // data. The no-layout branch passes nothing, and JSON-LD then describes the
1814
- // node's body/metaDescription/summary alone.
1815
- const baseUrl = await requestBaseUrl()
1816
- const headFor = (servedLayout?: unknown) => (
1817
- <>
1818
- <meta name="kywi:content-id" content={contentId} />
1819
- <KywiJsonLd
1820
- node={node}
1821
- config={runtime.config}
1822
- baseUrl={baseUrl}
1823
- siteId={siteId}
1824
- {...(servedLayout ? { layout: servedLayout } : {})}
1825
- />
1826
- <AudienceMetaTags audienceId={perso.audienceId} visitorId={perso.visitorId} />
1827
- {clientRuntimeEnabled(runtime, site) && perso.audiences.length > 0 ? (
1828
- <PersonalizationRuntime
1829
- audiences={perso.audiences}
1830
- serverSignals={perso.signals}
1831
- selfIdWidget={selfIdWidget}
1832
- transparencyNotice={transparencyNoticeEnabled(runtime, site)}
1833
- requireConsent={requireConsentEnabled(runtime, site)}
1834
- />
1835
- ) : null}
1836
- </>
1837
- )
1838
-
1839
- // When the Layout tab has designed the page, render its region-based document
1840
- // through core's own layout renderer (KywiLayout / KywiRegion). Before that:
1841
- // hydrate every Feed Display module (#28), resolve connected reusable
1842
- // components (#46), and supply the custom-module renderers (#48). Otherwise
1843
- // fall back to the built-in Body rich text (+ any Featured Image).
1844
- let content: React.ReactNode
1845
- if (hasRenderableLayout(layout)) {
1846
- // Apply the audience's page variant (and strip the other variants), then
1847
- // hydrate feeds and resolve components on the layout THIS visitor sees.
1848
- // variantContainer arms resolve at render time from \`personalization\`.
1849
- const personalized = personalizeLayout(layout, perso.audienceId)
1850
- const feedsHydrated = await hydrateLayoutFeeds(personalized, buildFeedResolver(runtime, siteId))
1851
- // Resolve every navMenu/navigation/siteMap module placed in THIS layout
1852
- // (menuSlug → a real menu; the tree otherwise) — the same seam as feeds,
1853
- // so a nav module dropped into any region/section just works (#112).
1854
- const currentPath = String(node['path'] ?? '/')
1855
- const navHydrated = await hydrateLayoutNav(
1856
- feedsHydrated,
1857
- runtime.scope.menus.createHydrationResolver(siteId, currentPath),
1858
- )
1859
- // Linked components resolve HERE, on the server, into the document itself:
1860
- // KywiLayout is a client component and the renderer's resolver props are
1861
- // functions, which cannot cross the RSC boundary (#147).
1862
- const hydrated = await resolveLayoutComponents(navHydrated, runtime, siteId)
1863
- // LAST pass before the client boundary (#167): keep ONLY the arm this
1864
- // visitor is served in every variantContainer / moduleVariantContainer.
1865
- // KywiLayout is a client component, so anything still on the document here
1866
- // is serialized into the page's RSC flight payload — arm SELECTION was
1867
- // always server-side, but the losing arms crossed with it and were readable
1868
- // in view-source. Pruning uses the same \`perso.personalization\` the
1869
- // renderer is handed, so the markup is unchanged.
1870
- //
1871
- // Order matters: after component resolution (a container that is only a
1872
- // \`componentId\` has no arms of its own to prune yet) and after the feed/nav
1873
- // hydration passes. The front-edit overlay below is given the STORED
1874
- // \`layout\`, never this — an editor needs every arm.
1875
- const served = pruneLayoutToServedArms(hydrated, perso.personalization)
1876
- content = (
1877
- <article className="page page--layout" data-kywi-content-id={contentId}>
1878
- {headFor(served)}
1879
- <KywiLayout
1880
- layout={served}
1881
- personalization={perso.personalization}
1882
- moduleComponents={moduleComponents}
1883
- >
1884
- {Object.keys(served.regions).map((name) => (
1885
- <KywiRegion key={name} name={name} />
1886
- ))}
1887
- </KywiLayout>
1888
- </article>
1889
- )
1890
- } else {
1891
- content = (
1892
- <article className="page" data-kywi-content-id={contentId}>
1893
- {headFor()}
1894
- {featured ? <img className="page__featured" src={featured} alt="" /> : null}
1895
- <h1 className="page__title">{title}</h1>
1896
- {perms ? (
1897
- <KywiEditableRegion region="main">
1898
- <KywiEditableAttribute field="body" type="richtext" contentId={contentId}>
1899
- {body ? <KywiBody content={body} /> : <p className="page__empty">This page has no content yet.</p>}
1900
- </KywiEditableAttribute>
1901
- </KywiEditableRegion>
1902
- ) : body ? (
1903
- <KywiBody content={body} />
1904
- ) : (
1905
- <p className="page__empty">This page has no content yet.</p>
1906
- )}
1907
- </article>
1908
- )
1909
- }
1910
-
1911
- // Mount the overlay (client) for any authenticated admin (write role+) —
1912
- // browse mode renders just the slim toolbar; ?kywi-edit=1 (editRequested)
1913
- // additionally auto-starts the full overlay editor. Nothing mounts for an
1914
- // anonymous or read-only visitor.
1915
- if (perms) {
1916
- return (
1917
- <KywiFrontEdit
1918
- canEdit={perms.canEdit}
1919
- canPublish={perms.canPublish}
1920
- editRequested={editRequested}
1921
- contentId={contentId}
1922
- contentType={contentType}
1923
- pageTitle={title}
1924
- pageStatus={String(node['status'] ?? '')}
1925
- initialLayout={layout ?? { regions: { main: [] } }}
1926
- adminHref={\`/admin/content/\${contentType}/\${contentId}\`}
1927
- moduleComponents={moduleComponents}
1928
- hostModules={config.modules ?? []}
1929
- >
1930
- {content}
1931
- </KywiFrontEdit>
1932
- )
1933
- }
1934
-
1935
- return content
1936
- }
1937
- `
1938
- }
1939
-
1940
- /**
1941
- * Front-of-site edit overlay (client). Mounted by the public page for ANY
1942
- * authenticated admin (write role+) — the page verifies the session cookie
1943
- * server-side first (resolveEditPermissions, called unconditionally) and only
1944
- * mounts this when that check passes, so canEdit is always true here.
1945
- *
1946
- * Two modes, same component:
1947
- * - **Browse** (default) — core's slim `KywiEditToolbar`, a persistent
1948
- * WordPress-admin-bar-style strip over the live page (page title, draft/
1949
- * published status, "Edit this page", "Go to admin") — this is the
1950
- * discoverability fix (kywi-cms#93): an admin browsing the public site
1951
- * normally, with no query param, now sees the entry point. The editable
1952
- * regions are also outlined via the `data-kywi-*` protocol.
1953
- * - **Edit** (the toolbar's "Edit this page", or landing with
1954
- * `?kywi-edit=1`) — the FULL `OverlayShell` layout editor, the same one the
1955
- * admin app mounts, rendered from the same core module registry + custom
1956
- * renderers. Save PUTs the layout document; Publish PUTs the layout then
1957
- * flips status — exactly the API the admin editor uses. Entering/leaving
1958
- * edit mode keeps `?kywi-edit=1` in sync via history.replaceState, so a
1959
- * reload (or a shared link) lands back in the same mode.
1960
- *
1961
- * The OverlayShell is lazy-loaded (`next/dynamic`, client-only): its weight
1962
- * (dnd-kit, canvas, side panels) is only fetched when an editor actually enters
1963
- * edit mode, so the public browse bundle stays light.
1964
- */
1965
- function frontEditOverlay() {
1966
- return `'use client'
1967
- import React from 'react'
1968
- import dynamic from 'next/dynamic'
1969
- import { KywiEditToolbar, useKywiEditMode } from '@kywi-software/core/scope-client'
1970
- import { adminFetch } from '@kywi-software/core/host-client'
1971
- import {
1972
- createModuleRegistry,
1973
- createThemeRegistry,
1974
- BUILT_IN_MODULE_COMPONENTS,
1975
- type LayoutDocument,
1976
- type ModuleComponentMap,
1977
- type ModuleConfig,
1978
- } from '@kywi-software/core/layout'
1979
- import type { SaveAction } from '@kywi-software/core/admin'
1980
-
1981
- /**
1982
- * Injects the admin design system's stylesheet as a plain \`<link>\` tag rather
1983
- * than an ES \`import\` (kywi-cms#130 follow-up). A dynamic \`import('…/admin/
1984
- * styles.css')\` still shipped the stylesheet on every public route that
1985
- * reaches this factory, regardless of the \`next/dynamic\` wrapping around
1986
- * it. A \`<link>\` created imperatively at runtime has no \`import\` statement
1987
- * for Next's CSS collector to see, so it never enters the build's CSS graph.
1988
- *
1989
- * VERIFIED (in the reference app this template mirrors) against a real
1990
- * \`next build && next start\`: an anonymous fetch of a real published content
1991
- * page shows zero OverlayShell/editor.css/admin.css references in any of its
1992
- * fetched resources. Fetching the SAME page against \`next dev\` still shows
1993
- * every one of those markers regardless of this fix — \`next dev\` bundles far
1994
- * more eagerly than a production build, for ALL routes, independent of any
1995
- * dynamic()/runtime-injection technique. A generated app's own e2e coverage
1996
- * of this (if any) needs to build for real, not assert against \`next dev\`.
1997
- *
1998
- * Two follow-ups from review:
1999
- * - CACHE-BUSTING: \`/kywi-admin.css\` and \`/kywi-admin.css.version\` are both
2000
- * synced from the installed \`@kywi-software/core\` package
2001
- * (scripts/sync-kywi-admin-css.mjs, run via package.json's
2002
- * \`sync:kywi-admin-css\` on every \`predev\`/\`prebuild\`) — mirroring how
2003
- * \`public/kywi.js\` is synced from \`@kywi-software/js\`. The version
2004
- * file's content becomes the \`?v=\` query string below, so a core upgrade
2005
- * that changes the stylesheet always serves a URL a CDN/browser has never
2006
- * cached, instead of a stale \`/kywi-admin.css\` response.
2007
- * - FOUC: returns a Promise that resolves once the tag has actually loaded
2008
- * (or failed to) — an existing, already-loaded tag resolves immediately;
2009
- * a fresh or still-loading one resolves on its \`load\`/\`error\` event. The
2010
- * dynamic() factory below awaits this ALONGSIDE the OverlayShell JS
2011
- * import, so the editor's first paint never races its own stylesheet.
2012
- */
2013
- function ensureAdminStylesheet(): Promise<void> {
2014
- if (typeof document === 'undefined') return Promise.resolve()
2015
- const existing = document.querySelector<HTMLLinkElement>('link[data-kywi-admin-css]')
2016
- if (existing) {
2017
- // \`.sheet\` is non-null only once the stylesheet has actually parsed —
2018
- // resolve immediately rather than re-attaching listeners to an event
2019
- // that already fired.
2020
- if (existing.sheet) return Promise.resolve()
2021
- return new Promise((resolve) => {
2022
- existing.addEventListener('load', () => resolve(), { once: true })
2023
- existing.addEventListener('error', () => resolve(), { once: true })
2024
- })
2025
- }
2026
- return fetch('/kywi-admin.css.version')
2027
- .then((res) => (res.ok ? res.text() : ''))
2028
- .catch(() => '')
2029
- .then(
2030
- (version) =>
2031
- new Promise<void>((resolve) => {
2032
- const link = document.createElement('link')
2033
- link.rel = 'stylesheet'
2034
- const v = version.trim()
2035
- link.href = v ? \`/kywi-admin.css?v=\${encodeURIComponent(v)}\` : '/kywi-admin.css'
2036
- link.dataset.kywiAdminCss = 'true'
2037
- // A load OR error either way lets the caller proceed — a stylesheet
2038
- // that 404s must never permanently block the editor from opening.
2039
- link.addEventListener('load', () => resolve(), { once: true })
2040
- link.addEventListener('error', () => resolve(), { once: true })
2041
- document.head.appendChild(link)
2042
- }),
2043
- )
2044
- }
2045
-
2046
- // Lazy-load the full layout editor bundle: the shell's JS (dnd-kit, canvas,
2047
- // panels) is pulled INSIDE this factory, so it enters the module graph only
2048
- // when an editor opens the overlay — never in the public browse bundle a
2049
- // visitor downloads. The stylesheet is handled separately — see
2050
- // \`ensureAdminStylesheet\` above — since a css import here, even a dynamic
2051
- // one, does not behave like the JS import right below it. Both are awaited
2052
- // together so the editor never paints ahead of its own stylesheet (FOUC).
2053
- const OverlayShell = dynamic(
2054
- async () => {
2055
- const [, mod] = await Promise.all([
2056
- ensureAdminStylesheet(),
2057
- import('@kywi-software/core/admin'),
2058
- ])
2059
- return mod.OverlayShell
2060
- },
2061
- { ssr: false },
2062
- )
2063
-
2064
- export interface KywiFrontEditProps {
2065
- canEdit: boolean
2066
- canPublish: boolean
2067
- /** True when the page was requested with \`?kywi-edit=1\` — auto-starts the full overlay editor on mount. */
2068
- editRequested: boolean
2069
- contentId: string
2070
- contentType: string
2071
- pageTitle: string
2072
- pageStatus: string
2073
- /** The page's saved layout document (empty regions when it has none yet). */
2074
- initialLayout: LayoutDocument
2075
- /** Deep link to this page in the full admin editor. */
2076
- adminHref: string
2077
- /** Custom (defineModule) renderers, shared with the public layout + admin (#48). */
2078
- moduleComponents?: ModuleComponentMap
2079
- /** Custom (defineModule) module CONFIGS from kywi.config.ts — gives the overlay
2080
- * editor each custom module's prop definitions, so the props rail and inline
2081
- * text editing work for them exactly as in the admin editor (kywi-cms#118). */
2082
- hostModules?: ModuleConfig[]
2083
- children: React.ReactNode
2084
- }
2085
-
2086
- /**
2087
- * Wraps the public page with the front-of-site edit affordance. The page
2088
- * renders this for ANY signed-in admin (write role+) — canEdit is always true
2089
- * here, since the page only mounts KywiFrontEdit once resolveEditPermissions
2090
- * has already confirmed it server-side. Browse mode shows the persistent
2091
- * KywiEditToolbar (the primary discoverability fix, kywi-cms#93); the full
2092
- * OverlayShell editor only mounts once edit mode actually starts — either the
2093
- * toolbar's "Edit this page" button, or landing with \`?kywi-edit=1\`
2094
- * (editRequested), which auto-starts it once on mount.
2095
- */
2096
- export function KywiFrontEdit({
2097
- canEdit,
2098
- canPublish,
2099
- editRequested,
2100
- contentId,
2101
- contentType,
2102
- pageTitle,
2103
- pageStatus,
2104
- initialLayout,
2105
- adminHref,
2106
- moduleComponents = {},
2107
- hostModules = [],
2108
- children,
2109
- }: KywiFrontEditProps) {
2110
- const edit = useKywiEditMode({ canEdit, canPublish })
2111
-
2112
- // ?kywi-edit=1 (editRequested) means "enter edit mode now" — flip it on once
2113
- // after mount. Without it the page mounts straight into browse mode (the
2114
- // persistent toolbar), which is the common case now that KywiFrontEdit
2115
- // renders for every signed-in admin, not only deep-linked ones.
2116
- const { startEdit, endEdit } = edit
2117
- React.useEffect(() => {
2118
- if (editRequested) startEdit()
2119
- }, [editRequested, startEdit])
2120
-
2121
- // Registries + renderers for the editor: the module registry carries each
2122
- // module's prop definitions (built-ins + this app's kywi.config.ts modules —
2123
- // without the host list, custom modules render but are uneditable, kywi-cms#118),
2124
- // merged with the custom renderers from lib/modules (#48).
2125
- const moduleRegistry = React.useMemo(
2126
- () => createModuleRegistry(hostModules ?? []),
2127
- [hostModules],
2128
- )
2129
- const themeRegistry = React.useMemo(() => createThemeRegistry(), [])
2130
- const editorComponents = React.useMemo(
2131
- () => ({ ...BUILT_IN_MODULE_COMPONENTS, ...moduleComponents }),
2132
- [moduleComponents],
2133
- )
2134
-
2135
- // Persist the edited layout to the same content API the admin editor uses.
2136
- const persistLayout = React.useCallback(
2137
- async (next: LayoutDocument) => {
2138
- const res = await fetch(\`/api/v1/content/\${contentType}/\${contentId}/layout\`, {
2139
- method: 'PUT',
2140
- credentials: 'same-origin',
2141
- headers: { 'Content-Type': 'application/json' },
2142
- body: JSON.stringify(next),
2143
- })
2144
- return res.ok
2145
- },
2146
- [contentType, contentId],
2147
- )
2148
-
2149
- const handleSave = React.useCallback(
2150
- async (next: LayoutDocument, _action: SaveAction) => {
2151
- await persistLayout(next)
2152
- endEdit()
2153
- },
2154
- [persistLayout, endEdit],
2155
- )
2156
-
2157
- const handlePublish = React.useCallback(
2158
- async (next: LayoutDocument, _action: SaveAction) => {
2159
- const ok = await persistLayout(next)
2160
- if (ok) {
2161
- // Best-effort status flip; the layout itself is already persisted above.
2162
- await fetch(\`/api/v1/content/\${contentType}/\${contentId}\`, {
2163
- method: 'PUT',
2164
- credentials: 'same-origin',
2165
- headers: { 'Content-Type': 'application/json' },
2166
- body: JSON.stringify({ status: 'published' }),
2167
- }).catch(() => undefined)
2168
- }
2169
- endEdit()
2170
- },
2171
- [persistLayout, contentType, contentId, endEdit],
2172
- )
2173
-
2174
- // Browse-mode one-click publish from the toolbar (no editor needed).
2175
- const handleToolbarPublish = React.useCallback(async () => {
2176
- const res = await adminFetch(\`/api/v1/content/by-id/\${contentId}/publish\`, { method: 'POST' })
2177
- if (res.ok) window.location.reload()
2178
- }, [contentId])
2179
-
2180
- // Edit mode: the full layout editor, in place, over the live page.
2181
- // NOTE: no \`kywi-admin-shell\` here (kywi-cms#94). That class is the admin
2182
- // design system's base+reset — font family, font size, colours, heading
2183
- // resets — and wrapping the page in it re-typesets the very content the
2184
- // owner is trying to judge at real width. The editor chrome carries its own
2185
- // styling; the page keeps the site's.
2186
- if (edit.isEditMode && edit.canEdit) {
2187
- return (
2188
- <div className="kywi-frontend-edit">
2189
- <OverlayShell
2190
- editMode={edit}
2191
- initialLayout={initialLayout}
2192
- contentId={contentId}
2193
- contentType={contentType}
2194
- pageTitle={pageTitle}
2195
- /* The page's own body wrapper, so the site's page-level CSS (width,
2196
- gutters, rhythm) still applies while editing in place. */
2197
- pageClassName="page page--layout"
2198
- themeName="default"
2199
- themeRegistry={themeRegistry}
2200
- moduleRegistry={moduleRegistry}
2201
- moduleComponents={editorComponents}
2202
- onSave={handleSave}
2203
- onPublish={handlePublish}
2204
- />
2205
- </div>
2206
- )
2207
- }
2208
-
2209
- // Browse mode: slim toolbar + editable-region outlines over the live page.
2210
- return (
2211
- <>
2212
- <KywiEditToolbar
2213
- isEditMode={edit.isEditMode}
2214
- canEdit={edit.canEdit}
2215
- canPublish={edit.canPublish}
2216
- onToggleEdit={edit.toggleEdit}
2217
- onPublish={handleToolbarPublish}
2218
- pageTitle={pageTitle}
2219
- pageStatus={pageStatus}
2220
- adminHref={adminHref}
2221
- />
2222
- {/* Toggling this attribute drives the editable-region outlines shipped in
2223
- @kywi-software/core/site/styles.css. */}
2224
- <div data-kywi-editing={edit.isEditMode ? '' : undefined}>{children}</div>
2225
- </>
2226
- )
2227
- }
2228
- `
2229
- }
2230
-
2231
- /**
2232
- * Shared `/kywi.js` script-injection primitive (kywi-cms#114). Two independent
2233
- * triggers can each want the browser bundle on the page: the site layout's nav
2234
- * (`<KywiJsLoader>`, purely for the hover-intent/keyboard/viewport-flip
2235
- * enhancement `initNavMenus()` applies unconditionally once the script boots)
2236
- * and `<PersonalizationRuntime>` (which additionally waits on
2237
- * `window.Kywi.bootAudienceEngine`). Before this fix only the personalization
2238
- * runtime ever injected the tag, so a site with `personalization.clientRuntime`
2239
- * off — or simply no active audiences — never got the nav enhancement either,
2240
- * even though the nav renders fully without it (kywi-cms#111's JS-off contract)
2241
- * and would only ever gain from it. `ensureKywiJsScript` is idempotent:
2242
- * whichever caller mounts first creates the ONE `<script data-kywi-js>` tag,
2243
- * every other caller finds it already there — so both triggers being true at
2244
- * once never loads the bundle twice.
2245
- */
2246
- function kywiJsLoaderLib() {
2247
- return `export function ensureKywiJsScript(): HTMLScriptElement | null {
2248
- if (typeof document === 'undefined') return null
2249
- const existing = document.querySelector<HTMLScriptElement>('script[data-kywi-js]')
2250
- if (existing) return existing
2251
- const script = document.createElement('script')
2252
- script.src = '/kywi.js'
2253
- script.async = true
2254
- script.dataset.kywiJs = 'true'
2255
- document.body.appendChild(script)
2256
- return script
2257
- }
2258
- `
2259
- }
2260
-
2261
- /**
2262
- * Loads `/kywi.js` for its nav enhancement alone, independent of
2263
- * personalization (kywi-cms#114). Rendered by the site layout whenever the
2264
- * resolved header menu is non-empty — the same condition `<SiteNav>` itself
2265
- * gates on before it renders a `[data-kywi-nav]` root — so this mounts exactly
2266
- * when there is a nav for `initNavMenus()` to enhance. Does nothing beyond
2267
- * ensuring the script tag exists: `Kywi.boot()` runs `initNavMenus()`
2268
- * unconditionally as part of its own bootstrap once the script loads, and
2269
- * never touches personalization on its own.
2270
- */
2271
- function kywiJsLoaderComponent() {
2272
- return `'use client'
2273
-
2274
- import { useEffect } from 'react'
2275
- import { ensureKywiJsScript } from '../lib/kywi-js-loader'
2276
-
2277
- export function KywiJsLoader() {
2278
- useEffect(() => {
2279
- ensureKywiJsScript()
2280
- }, [])
2281
-
2282
- return null
2283
- }
2284
- `
2285
- }
2286
-
2287
1168
  /**
2288
- * Client personalization runtime (kywi-cms#50). The SERVER already resolved and
2289
- * rendered the correct audience/experiment variant and set the ids in <head>;
2290
- * this optional client layer adds live re-evaluation, the self-ID widget, the
2291
- * transparency bar, and behavioral-signal collection by booting the
2292
- * @kywi-software/js browser bundle from /kywi.js. It is best-effort and mounted
2293
- * only when the theme opts in (theme.personalization.clientRuntime), so the site
2294
- * stays dependency-free by default: drop the built @kywi-software/js bundle at
2295
- * public/kywi.js to enable it (see README → "Personalization"). Without it the
2296
- * server-rendered variant is exactly what every visitor sees.
1169
+ * The public catch-all — a thin delegate over `createPublicPage`.
2297
1170
  *
2298
- * kywi-cms#114: `/kywi.js` may already be on the page independent of this
2299
- * component — the site layout's `<KywiJsLoader>` loads it purely for nav
2300
- * enhancement whenever the site has a nav. `ensureKywiJsScript` (shared with
2301
- * that component, lib/kywi-js-loader.ts) is idempotent, so this never injects
2302
- * a second copy.
2303
- */
2304
- function personalizationRuntime() {
2305
- return `'use client'
2306
-
2307
- import React, { useEffect } from 'react'
2308
- import type { Audience, VisitorSignals } from '@kywi-software/core/audiences/types'
2309
- import type { PublicSelfIdWidget } from '../lib/site'
2310
- import { ensureKywiJsScript } from '../lib/kywi-js-loader'
2311
-
2312
- /* eslint-disable @typescript-eslint/no-explicit-any */
2313
- declare global {
2314
- interface Window {
2315
- Kywi?: any
2316
- kywi?: any
2317
- }
2318
- }
2319
-
2320
- interface PersonalizationRuntimeProps {
2321
- /** Active audiences the client re-evaluates (rule definitions, no PII). */
2322
- audiences: Audience[]
2323
- /** Server-resolved signals (UTM / referrer / identity / opt-out) for parity. */
2324
- serverSignals: Partial<VisitorSignals>
2325
- /** Resolved self-ID widget config; when present its trigger/frequency mount it. */
2326
- selfIdWidget?: PublicSelfIdWidget | null
2327
- /**
2328
- * \`personalization.transparencyNotice.enabled\` — false suppresses the
2329
- * automatic global transparency panel; the visitor can still open it
2330
- * deliberately through a \`personalizationBadge\` module. Defaults to true.
2331
- */
2332
- transparencyNotice?: boolean
2333
- /**
2334
- * \`personalization.requireConsent\` — false turns the client's consent gate
2335
- * off for a site that collects consent with external tooling. Defaults to
2336
- * true, so the resolved audience is only remembered for a consenting visitor
2337
- * (kywi-cms#91).
2338
- */
2339
- requireConsent?: boolean
2340
- }
2341
-
2342
- /**
2343
- * Ensures /kywi.js is on the page, then boots the audience data-layer.
2344
- * bootAudienceEngine reads the opt-out cookie itself and resolves opted-out
2345
- * visitors to the default experience, so opt-out is respected end to end.
2346
- * Rendered only when theme.personalization.clientRuntime is on.
1171
+ * What used to be 279 emitted lines (content resolution, SEO metadata + locale
1172
+ * alternates, JSON-LD over the served document, audience meta tags, server-side
1173
+ * personalization and A/B assignment, the self-ID widget, arm pruning, the
1174
+ * front-edit overlay gate) is `@kywi-software/core/next`. The generated file
1175
+ * supplies the two project-specific inputs `layered-architecture DESIGN.md` §2
1176
+ * counted — the wiring runtime and this app's custom module renderers.
2347
1177
  *
2348
- * kywi-cms#114: the script tag may already be on the page independent of this
2349
- * component — the site layout's <KywiJsLoader> loads it purely for nav
2350
- * enhancement whenever the site has a nav. ensureKywiJsScript (shared with
2351
- * that component) is idempotent, so this never injects a second copy.
1178
+ * `dynamic` is written as a LITERAL on purpose: Next parses route segment
1179
+ * config statically and fails the build on `export const dynamic = page.dynamic`
1180
+ * ("Unsupported node type MemberExpression"). Core types the field as the
1181
+ * literal `'force-dynamic'` so the two still cannot drift.
2352
1182
  */
2353
- export function PersonalizationRuntime({ audiences, serverSignals, selfIdWidget, transparencyNotice, requireConsent }: PersonalizationRuntimeProps) {
2354
- useEffect(() => {
2355
- let cancelled = false
2356
- const ready = () => typeof window.Kywi?.bootAudienceEngine === 'function'
2357
-
2358
- function boot(): void {
2359
- if (cancelled || !ready()) return
2360
- const currentPath = window.location.pathname
2361
- window.Kywi
2362
- .bootAudienceEngine({
2363
- audiences,
2364
- currentPath,
2365
- serverSignals,
2366
- ...(selfIdWidget
2367
- ? { selfIdWidget: { ...selfIdWidget, audiences, currentPath } }
2368
- : {}),
2369
- ...(transparencyNotice === false ? { transparencyNotice: false } : {}),
2370
- ...(requireConsent === false ? { requireConsent: false } : {}),
2371
- })
2372
- .catch(() => {
2373
- /* client personalization is best-effort; the server already rendered defaults */
2374
- })
2375
- }
2376
-
2377
- function whenReady(): void {
2378
- if (ready()) return boot()
2379
- let tries = 0
2380
- const timer = setInterval(() => {
2381
- tries += 1
2382
- if (ready()) {
2383
- clearInterval(timer)
2384
- boot()
2385
- } else if (tries > 50) {
2386
- clearInterval(timer)
2387
- }
2388
- }, 60)
2389
- }
2390
-
2391
- // kywi-cms#114: the script tag itself may already be on the page — the
2392
- // site layout's <KywiJsLoader> injects it independently, purely for nav
2393
- // enhancement, whenever the site has a nav. \`ensureKywiJsScript\` is the
2394
- // shared dedup primitive: it returns the existing tag if one is already
2395
- // there, so this never creates a second one.
2396
- const script = ensureKywiJsScript()
2397
- if (ready()) boot()
2398
- else script?.addEventListener('load', whenReady)
2399
-
2400
- return () => {
2401
- cancelled = true
2402
- }
2403
- // Boot once on mount; audiences/serverSignals are per-request stable.
2404
- // eslint-disable-next-line react-hooks/exhaustive-deps
2405
- }, [])
2406
-
2407
- return null
2408
- }
2409
- `
1183
+ function siteSlugPage() {
1184
+ return stamped(`// The public catch-all: "/" and every published page at its Site Tree path.
1185
+ // Content resolution, SEO metadata, JSON-LD, personalization + A/B assignment
1186
+ // and the front-edit overlay all live in @kywi-software/core/next; this file
1187
+ // wires in THIS app's runtime and generated layer registry.
1188
+ import { createPublicPage } from '@kywi-software/core/next'
1189
+ import { kywi } from '../../../lib/kywi'
1190
+ import { layers } from '../../../kywi.layers'
1191
+
1192
+ const page = createPublicPage({ runtime: kywi, layers })
1193
+
1194
+ export default page.default
1195
+ export const generateMetadata = page.generateMetadata
1196
+ // A LITERAL, never \`page.dynamic\`: Next parses route segment config statically
1197
+ // and fails the build on a MemberExpression.
1198
+ export const dynamic = 'force-dynamic'
1199
+ `)
2410
1200
  }
2411
1201
 
2412
1202
  /** @param {Answers} a — headless/decoupled root page (no public rendering). */
@@ -2440,30 +1230,23 @@ export default function HomePage() {
2440
1230
  * /api/v1/[axPath] directly.
2441
1231
  */
2442
1232
  function axRootRoute(axPath) {
2443
- return `import { getKywiHandler } from '../../lib/kywi'
1233
+ return stamped(`import { createAxRoute } from '@kywi-software/core/next'
1234
+ import { kywi } from '../../lib/kywi'
1235
+
1236
+ const route = createAxRoute({ runtime: kywi, axPath: '${axPath}' })
2444
1237
 
2445
1238
  /** Root-level \`/${axPath}\` — delegates to the core AX generator (kywi-cms#55). */
2446
- export const dynamic = 'force-dynamic'
1239
+ export const GET = route.GET
2447
1240
 
2448
- export async function GET(req: Request): Promise<Response> {
2449
- const handler = await getKywiHandler()
2450
- return handler.handle(req, ['${axPath}'])
2451
- }
2452
- `
1241
+ export const dynamic = 'force-dynamic'
1242
+ `)
2453
1243
  }
2454
1244
 
2455
1245
  // ── API route (thin proxy over @kywi-software/core/host) ──────────────────────
2456
1246
 
2457
1247
  function apiRoute() {
2458
- return `import { NextRequest, NextResponse } from 'next/server'
2459
- import { getKywiHandler } from '../../../../lib/kywi'
2460
- import {
2461
- isTokenIssuingPath,
2462
- parseSessionTokens,
2463
- setAccessCookie,
2464
- setRefreshCookie,
2465
- clearSessionCookies,
2466
- } from '@kywi-software/core/host'
1248
+ return stamped(`import { createApiRoute } from '@kywi-software/core/next'
1249
+ import { kywi } from '../../../../lib/kywi'
2467
1250
 
2468
1251
  /**
2469
1252
  * The versioned Kywi API. Delegates every verb to core's DB-backed handler, with
@@ -2473,126 +1256,52 @@ import {
2473
1256
  * refresh side lives in middleware.ts. The cookie contract is shared via
2474
1257
  * @kywi-software/core/host — core itself is untouched.
2475
1258
  */
2476
- async function handleRequest(
2477
- req: NextRequest,
2478
- ctx: { params: Promise<{ kywi: string[] }> },
2479
- ): Promise<Response> {
2480
- const segments = (await ctx.params).kywi
2481
- const path = segments.join('/')
2482
- const handler = await getKywiHandler()
2483
- const res = await handler.handle(req, segments)
2484
-
2485
- if (path === 'auth/logout') {
2486
- const bodyText = await res.text()
2487
- const out = new NextResponse(bodyText, { status: res.status, headers: res.headers })
2488
- clearSessionCookies(out)
2489
- return out
2490
- }
2491
-
2492
- if (isTokenIssuingPath(path) && res.ok) {
2493
- const bodyText = await res.text()
2494
- const { accessToken, refreshToken } = parseSessionTokens(bodyText)
2495
- const out = new NextResponse(bodyText, { status: res.status, headers: res.headers })
2496
- if (accessToken) setAccessCookie(out, accessToken)
2497
- if (refreshToken) setRefreshCookie(out, refreshToken)
2498
- return out
2499
- }
2500
-
2501
- return res
2502
- }
1259
+ const route = createApiRoute({ runtime: kywi })
2503
1260
 
2504
- export const GET = handleRequest
2505
- export const POST = handleRequest
2506
- export const PUT = handleRequest
2507
- export const PATCH = handleRequest
2508
- export const DELETE = handleRequest
1261
+ export const GET = route.GET
1262
+ export const POST = route.POST
1263
+ export const PUT = route.PUT
1264
+ export const PATCH = route.PATCH
1265
+ export const DELETE = route.DELETE
2509
1266
 
2510
1267
  export const dynamic = 'force-dynamic'
2511
- `
1268
+ `)
2512
1269
  }
2513
1270
 
2514
1271
  // ── admin (the full core admin, mounted at /admin) ─────────────────────────────
2515
1272
 
2516
1273
  /**
2517
- * The single optional-catch-all host page that mounts the ENTIRE core admin
2518
- * (all surfaces) at /admin. Modeled on apps/reference — the whole scaffolded
2519
- * admin is `KywiAdminApp`, upgraded via `npm update @kywi-software/core`, not
2520
- * hand-written per-surface pages.
2521
- */
2522
- function adminCatchAllPage() {
2523
- return `/**
2524
- * Host page for the core-mounted admin (\`KywiAdminApp\`).
2525
- *
2526
- * This ONE optional-catch-all serves your entire admin at /admin — dashboard,
2527
- * site tree, media, users, forms, categories, tags, audiences, settings, and
2528
- * every other surface. It is a server component: it projects the full,
2529
- * server-side kywi.config.ts down to the secret-free AdminRuntimeConfig
2530
- * (toAdminRuntimeConfig), extracts ?next= for the login surface, and renders the
2531
- * one client KywiAdminApp, which maps the URL segments to a surface and mounts
2532
- * it inside the core admin chrome.
2533
- *
2534
- * The @kywi-software/core/admin/styles.css import is LOAD-BEARING: the admin
2535
- * design-system tokens/inputs live there, so without it every surface (and the
2536
- * login page) renders unstyled. Do not remove it.
2537
- *
2538
- * Server-side 404 (#58): the segments are resolved with the three-state
2539
- * resolveSurfaceState helper. Next's notFound() fires for a real HTTP 404 ONLY
2540
- * on a genuinely unknown path (status 'not-found'). A KNOWN surface held off by
2541
- * the config ceiling (status 'config-disabled') is NOT hard-404'd here — it
2542
- * falls through to KywiAdminApp, which renders the same in-shell "disabled"
2543
- * message a runtime-disabled surface gets, so both disable paths present
2544
- * consistently instead of hard-404 vs soft-hide. The \`login\` segment is exempt
2545
- * (not a registry surface — the middleware's auth exception renders it
2546
- * chrome-less through KywiAdminApp); the bare /admin index (segments === [])
2547
- * resolves to the dashboard, so it never 404s.
1274
+ * The admin host page — a thin delegate over `createAdminPage` (plan E3 T4).
2548
1275
  *
2549
- * You should not need to edit this file. To disable surfaces per deployment, set
2550
- * \`admin.features\` in kywi.config.ts; superAdmins can also toggle surfaces at
2551
- * runtime from the "Admin Features" surface.
1276
+ * What used to be 70 emitted lines (the AdminRuntimeConfig projection, the
1277
+ * `?next=` extraction and the three-state #58 404 gate) is
1278
+ * `@kywi-software/core/next`. TWO things stay in the generated file, and only
1279
+ * two: the admin stylesheet import (Next admits a global stylesheet only from a
1280
+ * file inside `app/`, and a published package cannot import it on the app's
1281
+ * behalf) and the `dynamic` LITERAL (Next parses route segment config
1282
+ * statically, so `export const dynamic = page.dynamic` fails the build).
2552
1283
  */
2553
- import { notFound } from 'next/navigation'
2554
- import { KywiAdminApp } from '@kywi-software/core/admin/app'
2555
- import { toAdminRuntimeConfig, resolveSurfaceState } from '@kywi-software/core/admin/server'
2556
- // CRITICAL: load the admin design system once for the whole mount.
1284
+ function adminCatchAllPage() {
1285
+ return stamped(`// Your entire admin at /admin — every surface. The surface resolution, the
1286
+ // secret-free config projection and the server-side 404 all live in
1287
+ // @kywi-software/core/next; this file wires in THIS app's runtime and generated
1288
+ // layer registry, and imports the admin stylesheet (Next takes a global
1289
+ // stylesheet only from a file in app/, and a package cannot import it for you) —
1290
+ // without it every surface, login included, renders unstyled. You should not
1291
+ // need to edit this file: to disable surfaces set \`admin.features\` in
1292
+ // kywi.config.ts, or toggle them at runtime from the Admin Features surface.
1293
+ import { createAdminPage } from '@kywi-software/core/next'
2557
1294
  import '@kywi-software/core/admin/styles.css'
2558
- import kywiConfig from '../../../lib/config'
2559
- // Custom (defineModule) renderers, shared with the public layout so the Layout
2560
- // editor canvas renders them the same as the live site (#48). Empty by default.
2561
- import { moduleComponents } from '../../../lib/modules'
2562
-
2563
- export default async function AdminCatchAll({
2564
- params,
2565
- searchParams,
2566
- }: {
2567
- params: Promise<{ admin?: string[] }>
2568
- searchParams: Promise<Record<string, string | string[] | undefined>>
2569
- }) {
2570
- const { admin = [] } = await params
2571
- const sp = await searchParams
2572
- const next = typeof sp.next === 'string' ? sp.next : null
2573
-
2574
- const runtime = toAdminRuntimeConfig(kywiConfig)
2575
-
2576
- // Real HTTP 404 only for a genuinely unknown admin path. A config-disabled
2577
- // surface is a known route held off by the ceiling — it renders the consistent
2578
- // in-shell "disabled" message via KywiAdminApp rather than a hard 404 (#58).
2579
- // The login segment is not a surface (chrome-less middleware exception); the
2580
- // bare index resolves to the dashboard, so both skip the 404 pre-check.
2581
- const isLogin = admin.length === 1 && admin[0] === 'login'
2582
- if (!isLogin && resolveSurfaceState(admin, runtime.admin).status === 'not-found') {
2583
- notFound()
2584
- }
1295
+ import { kywi } from '../../../lib/kywi'
1296
+ import { layers } from '../../../kywi.layers'
2585
1297
 
2586
- return (
2587
- <KywiAdminApp
2588
- runtime={runtime}
2589
- segments={admin}
2590
- loginNext={next}
2591
- moduleComponents={moduleComponents}
2592
- />
2593
- )
2594
- }
2595
- `
1298
+ const page = createAdminPage({ runtime: kywi, layers })
1299
+
1300
+ export default page.default
1301
+ // A LITERAL, never \`page.dynamic\`: Next parses route segment config statically
1302
+ // and fails the build on a MemberExpression.
1303
+ export const dynamic = 'force-dynamic'
1304
+ `)
2596
1305
  }
2597
1306
 
2598
1307
  // ── custom module renderers (lib/modules.tsx) ─────────────────────────────────
@@ -2732,7 +1441,8 @@ page: add sections and modules, then **Save** (PUTs the layout) or **Publish**
2732
1441
  (saves + publishes). **Done** returns you to the browse toolbar. Entering or
2733
1442
  leaving the editor keeps \`?kywi-edit=1\` in the URL in sync, so reloading (or
2734
1443
  sharing the link) lands back in the same mode. It all lives in
2735
- \`app/(site)/kywi-front-edit.tsx\`; the editor bundle (and the admin stylesheet) is
1444
+ \`@kywi-software/core/next\` (mounted by the generated public page), so it
1445
+ upgrades with the package; the editor bundle (and the admin stylesheet) is
2736
1446
  lazy-loaded, so pages your visitors see never carry its weight — an anonymous
2737
1447
  or read-only visitor gets no toolbar and no extra client JS. Custom
2738
1448
  \`defineModule\` types from \`kywi.config.ts\` also appear in the front-of-site
@@ -2776,13 +1486,13 @@ Audiences, experiments and the self-ID widget you configure in the admin resolve
2776
1486
  The **self-ID widget**, live re-evaluation, the transparency bar and behavioral
2777
1487
  signals are an optional *client* layer. Enable it by setting
2778
1488
  \`personalization: { clientRuntime: true }\` on your theme in \`kywi.config.ts\` and
2779
- dropping the built \`@kywi-software/js\` browser bundle at \`public/kywi.js\`; the
2780
- generated \`components/personalization-runtime.tsx\` loads it best-effort. Without
2781
- it, the server-rendered variant is what every visitor sees.
1489
+ dropping the built \`@kywi-software/js\` browser bundle at \`public/kywi.js\`;
1490
+ core's \`PersonalizationRuntime\` (mounted by the public page) loads it
1491
+ best-effort. Without it, the server-rendered variant is what every visitor sees.
2782
1492
 
2783
1493
  The same \`public/kywi.js\` bundle also drives the header nav's hover-intent,
2784
1494
  Escape/arrow-key and viewport-edge-flip enhancement — independent of
2785
- personalization. \`components/kywi-js-loader.tsx\` loads it whenever the site has
1495
+ personalization. Core's site layout loads it whenever the site has
2786
1496
  a nav, whether or not \`clientRuntime\` is on, so the nav gets that polish on
2787
1497
  every site once the bundle is in place; the nav renders and works fully without
2788
1498
  it either way. (\`public/kywi-admin.css\`, the front-of-site editor's stylesheet,
@@ -2880,16 +1590,25 @@ kywi.config.ts your config: sites, themes, content types, cus
2880
1590
  AGENTS.md guidance for AI agents working on this site (Kywi's building patterns)
2881
1591
  CLAUDE.md points AI agents to AGENTS.md
2882
1592
  ${SKILLS.map(({ slug }) => `.claude/skills/${slug}/SKILL.md ${slug.replace(/^kywi-/, '')} skill (loaded automatically)`).join('\n')}
2883
- middleware.ts auth gate + session refresh + cookie→bearer bridge
1593
+ middleware.ts re-exports core's middleware (auth gate, session refresh, cookie→bearer)
2884
1594
  next.config.mjs required Next config to consume @kywi-software/core
2885
- lib/kywi.ts server runtime (DB, API handler, content scope)
2886
- lib/config.ts single import path for kywi.config.ts
1595
+ lib/kywi.ts the ONE wiring point: kywi.config.ts → core's render path
2887
1596
  app/api/v1/[...kywi]/route.ts the versioned API (delegates to core)
2888
1597
  app/admin/[[...admin]]/page.tsx mounts the FULL core admin (all surfaces) at /admin
2889
1598
  lib/modules.tsx custom (defineModule) module renderers (admin + public)
2890
- 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/site-nav.tsx client wrapper around the shared nav renderer (header + footer)\ncomponents/kywi-js-loader.tsx loads /kywi.js for nav enhancement whenever the site has a nav\ncomponents/personalization-runtime.tsx optional client runtime (self-ID widget, live re-eval)\napp/(site)/kywi-front-edit.tsx front-of-site editor: browse toolbar + in-place Layout editor (?kywi-edit=1, lazy-loaded)' : '\napp/page.tsx returns 404 (no public rendering in this mode)'}
1599
+ app/{llms,robots,sitemap,…} root AX routes (llms.txt, robots.txt, sitemap.xml, …)${a.mode === 'coupled' ? '\napp/(site)/layout.tsx public shell (delegate): your brand, nav + stylesheets\napp/(site)/site.css your site chrome styles (edit freely)\napp/(site)/[[...slug]]/page.tsx renders published pages (delegate): wires in your module map\ncomponents/site-nav.tsx client wrapper around the shared nav renderer (header + footer)' : '\napp/page.tsx returns 404 (no public rendering in this mode)'}
2891
1600
  \`\`\`
2892
- `
1601
+ ${a.mode === 'coupled' ? `
1602
+ \`lib/kywi.ts\`, \`middleware.ts\` and the two \`app/(site)\` route files are
1603
+ **thin, version-stamped delegates** (each opens with a \`// kywi-render v…\`
1604
+ comment; nothing else in the project carries one). The public render
1605
+ path itself — content and locale resolution, SEO metadata, JSON-LD, feed and nav
1606
+ hydration, personalization, A/B assignment and the front-of-site editor — lives
1607
+ in \`@kywi-software/core/next\`, so \`pnpm up @kywi-software/core\` upgrades it
1608
+ instead of leaving you to hand-merge a changelog into your own copy. What stays
1609
+ yours: \`kywi.config.ts\`, \`app/(site)/site.css\`, \`lib/modules.tsx\` and
1610
+ \`components/site-nav.tsx\`.
1611
+ ` : ''}`
2893
1612
  }
2894
1613
 
2895
1614
  // ── helpers ───────────────────────────────────────────────────────────────────
@@ -2901,9 +1620,17 @@ function slugify(name) {
2901
1620
  .replace(/^_+|_+$/g, '') || 'kywi_app'
2902
1621
  }
2903
1622
 
2904
- /** Escape a value interpolated as JSX text so it can't break out of the element. */
2905
- function escapeJsxText(value) {
2906
- return String(value).replace(/[{}<>]/g, (ch) => `{'${ch}'}`)
1623
+ /**
1624
+ * Escape a value interpolated into a SINGLE-QUOTED TypeScript string literal, so
1625
+ * it cannot break out of the quotes or the line. Replaces the old
1626
+ * `escapeJsxText`: since plan E1 the project name reaches the generated site
1627
+ * layout as `brand: { label: '…' }` — a string literal argument — rather than as
1628
+ * JSX text inside the header markup.
1629
+ */
1630
+ function escapeJsString(value) {
1631
+ return String(value)
1632
+ .replace(/[\\']/g, (ch) => `\\${ch}`)
1633
+ .replace(/\r?\n/g, '\\n')
2907
1634
  }
2908
1635
 
2909
1636
  // ── Agent guidance (AGENTS.md / CLAUDE.md) ──────────────────────────────────────
@@ -2940,7 +1667,7 @@ export const GUIDANCE_VERSION = 1
2940
1667
  * @returns {string}
2941
1668
  */
2942
1669
  let _pkgVersion
2943
- function packageVersion() {
1670
+ export function packageVersion() {
2944
1671
  if (_pkgVersion === undefined) {
2945
1672
  const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json')
2946
1673
  _pkgVersion = JSON.parse(readFileSync(pkgPath, 'utf8')).version
@@ -3027,15 +1754,22 @@ export const AGENTS_LANDMARK_PATHS = {
3027
1754
  * @property {boolean} adminHost app/admin/[[...admin]]/page.tsx present
3028
1755
  * @property {boolean} moduleMap lib/modules.tsx present
3029
1756
  * @property {boolean} middleware middleware.ts present
3030
- * @property {boolean} libSite lib/site.ts present (the scaffold's public-render helpers)
1757
+ * @property {boolean} libSite lib/site.ts present — a LEGACY landmark since
1758
+ * plan E1: the scaffold no longer emits it (the public-render helpers ship in
1759
+ * `@kywi-software/core/next`), but a project scaffolded before E1 still has
1760
+ * its own copy and the header must describe that honestly.
3031
1761
  * @property {boolean} sitePage app/(site)/[[...slug]]/page.tsx present (renders public pages)
3032
1762
  * @property {boolean} headlessPage app/page.tsx present (the headless/decoupled 404 root)
3033
1763
  */
3034
1764
 
3035
1765
  /**
3036
1766
  * The landmark set a FRESH scaffold of the given mode has — derived statically
3037
- * from the mode, never from the filesystem, so buildFileSet emits AGENTS.md that
3038
- * is byte-identical to the previous mode-branching implementation.
1767
+ * from the mode, never from the filesystem.
1768
+ *
1769
+ * `libSite` is FALSE in every mode since plan E1: a freshly scaffolded project
1770
+ * has no `lib/site.ts` at all, because the public-render helpers ship in
1771
+ * `@kywi-software/core/next`. It stays in the type because
1772
+ * {@link detectAgentsLandmarks} still finds one in a pre-E1 project.
3039
1773
  * @param {'coupled'|'headless'|'decoupled'} mode
3040
1774
  * @returns {AgentsLandmarks}
3041
1775
  */
@@ -3046,7 +1780,7 @@ export function scaffoldLandmarks(mode) {
3046
1780
  adminHost: true,
3047
1781
  moduleMap: true,
3048
1782
  middleware: true,
3049
- libSite: coupled,
1783
+ libSite: false,
3050
1784
  sitePage: coupled,
3051
1785
  headlessPage: !coupled,
3052
1786
  }
@@ -3055,8 +1789,8 @@ export function scaffoldLandmarks(mode) {
3055
1789
  /**
3056
1790
  * Detect the AGENTS.md landmarks that actually exist in an EXISTING project, so
3057
1791
  * `create-kywi-app agents` writes a header that matches the app on disk (a
3058
- * hand-built app may render the public site from the page directly and predate
3059
- * the scaffold's lib/site.ts helper or lib/modules.tsx map).
1792
+ * hand-built app may have no lib/modules.tsx map; a project scaffolded before
1793
+ * plan E1 still carries its own lib/site.ts copy of the render helpers).
3060
1794
  * @param {string} projectDir absolute path to the project root
3061
1795
  * @returns {AgentsLandmarks}
3062
1796
  */
@@ -3077,9 +1811,10 @@ export function detectAgentsLandmarks(projectDir) {
3077
1811
  * AGENTS.md — a short, app-specific header that orients an agent in THIS app,
3078
1812
  * followed by Kywi's canonical patterns doc verbatim. Landmark-aware: each "Where
3079
1813
  * things live" bullet is emitted only for a landmark that is actually present, and
3080
- * absent public-render (lib/site.ts) or module-map (lib/modules.tsx) wiring becomes
3081
- * an honest "this app predates the scaffold's …" line instead of a bullet that
3082
- * asserts a file the project does not have. With no landmarks passed it defaults to
1814
+ * an absent module map (lib/modules.tsx) becomes an honest "this app predates the
1815
+ * scaffold's …" line instead of a bullet that asserts a file the project does not
1816
+ * have. A `lib/site.ts` found on disk (only a pre-E1 project has one) is described
1817
+ * as the legacy copy it is. With no landmarks passed it defaults to
3083
1818
  * the fresh-scaffold set for the mode, so the scaffold output is unchanged.
3084
1819
  * @param {Answers} a
3085
1820
  * @param {AgentsLandmarks} [landmarks]
@@ -3116,17 +1851,26 @@ export function agentsMd(a, landmarks = scaffoldLandmarks(a.mode)) {
3116
1851
  \`defineModule\` module map (shared by the admin editor and the public site); see
3117
1852
  the patterns doc below for the intended shape.`)
3118
1853
  }
3119
- // Public-render surface: the scaffold's lib/site.ts helper, else a page that
3120
- // renders publicly without it (honest note), else no public rendering at all.
1854
+ // Public-render surface. Three honest cases, in order:
1855
+ // 1. a PRE-E1 project that still owns a copy of the render helpers;
1856
+ // 2. the current shape — thin delegates over @kywi-software/core/next;
1857
+ // 3. no public rendering at all (headless/decoupled).
3121
1858
  if (L.libSite) {
3122
- bullets.push(`- \`lib/site.ts\` — public-render helpers (path/locale resolution, feeds,
3123
- components, personalization) used by \`app/(site)/[[...slug]]/page.tsx\`, which
3124
- renders every published page at its slug.`)
1859
+ bullets.push(`- \`lib/site.ts\` — this app's OWN copy of Kywi's public-render helpers
1860
+ (path/locale resolution, feeds, components, personalization), used by
1861
+ \`app/(site)/[[...slug]]/page.tsx\`, which renders every published page at its
1862
+ slug. It predates the layered shape: current Kywi ships those helpers in
1863
+ \`@kywi-software/core/next\`, and a copy here is a copy a core upgrade cannot
1864
+ reach. Prefer core's; do not add more render logic to this file.`)
3125
1865
  } else if (L.sitePage) {
3126
- bullets.push(`- \`app/(site)/[[...slug]]/page.tsx\` — renders every published page at its
3127
- slug. This app predates the scaffold's \`lib/site.ts\` public-render helpers
3128
- (path/locale resolution, feeds, components, personalization); see the patterns
3129
- doc below for the intended shape.`)
1866
+ bullets.push(`- \`app/(site)/[[...slug]]/page.tsx\` + \`app/(site)/layout.tsx\` — thin,
1867
+ version-stamped delegates. The public render path itself (content + locale
1868
+ resolution, SEO metadata, JSON-LD, feeds, nav, personalization + A/B, the
1869
+ front-edit overlay) lives in **\`@kywi-software/core/next\`** and upgrades with
1870
+ the package, so \`pnpm up @kywi-software/core\` reaches it. \`lib/kywi.ts\` is
1871
+ the one wiring point between the two. **Never paste render logic back into
1872
+ these files** — extend through \`lib/modules.tsx\`, \`kywi.config.ts\`,
1873
+ \`app/(site)/site.css\` and \`components/site-nav.tsx\` instead.`)
3130
1874
  } else {
3131
1875
  bullets.push(
3132
1876
  a.mode === 'decoupled'
@@ -3137,8 +1881,10 @@ export function agentsMd(a, landmarks = scaffoldLandmarks(a.mode)) {
3137
1881
  )
3138
1882
  }
3139
1883
  if (L.middleware) {
3140
- bullets.push(`- \`middleware.ts\` — auth gate + session refresh, thin wiring over
3141
- \`@kywi-software/core/host\`.`)
1884
+ bullets.push(`- \`middleware.ts\` — auth gate + session refresh. In a project scaffolded
1885
+ since the layered render path it is a one-line re-export of
1886
+ \`@kywi-software/core/next/middleware\`; older projects hold their own copy
1887
+ over \`@kywi-software/core/host\`.`)
3142
1888
  }
3143
1889
  bullets.push(`- \`.claude/skills/kywi-content-model/SKILL.md\` — content-model planning
3144
1890
  skill, loaded automatically before building anything.`)
@@ -3245,12 +1991,19 @@ export function buildFileSet(answers) {
3245
1991
  // mode so this stays byte-identical to the pre-landmark implementation; the
3246
1992
  // same guidanceFileSet powers `create-kywi-app agents` for existing projects.
3247
1993
  ...guidanceFileSet(answers, scaffoldLandmarks(answers.mode)),
3248
- // server runtime + config
1994
+ // The render path's ONE config seam — thin, stamped, upgradeable
1995
+ // (RENDER_LANDMARK_PATHS.runtime).
3249
1996
  'lib/kywi.ts': libKywi(),
3250
- 'lib/config.ts': libConfig(),
3251
1997
  // custom (defineModule) module renderers, shared by admin + public layout (#48)
3252
1998
  'lib/modules.tsx': libModules(),
3253
- // host wiring (thin, over @kywi-software/core/host)
1999
+ // Build-time enumerable site/theme registry for core's layer resolver
2000
+ // (RENDER_LANDMARK_PATHS.layers).
2001
+ 'kywi.layers.ts': kywiLayers(),
2002
+ // …and the layer files it names. A fresh scaffold is one site with one
2003
+ // theme; `upgrade` regenerates BOTH from the project's own kywi.config.ts.
2004
+ ...layerFilesFor(DEFAULT_REGISTRY_LAYOUT, answers.mode),
2005
+ // host wiring: a re-export of core's own middleware
2006
+ // (RENDER_LANDMARK_PATHS.middleware).
3254
2007
  'middleware.ts': middleware(),
3255
2008
  'app/api/v1/[...kywi]/route.ts': apiRoute(),
3256
2009
  // root
@@ -3272,26 +2025,21 @@ export function buildFileSet(answers) {
3272
2025
  if (answers.mode === 'coupled') {
3273
2026
  // Public site: an optional catch-all renders "/" (home) and every published
3274
2027
  // page at its slug. More-specific /admin and /api routes take precedence.
3275
- files['app/(site)/layout.tsx'] = siteLayout(answers)
2028
+ // Both files are thin, stamped delegates over @kywi-software/core/next
2029
+ // (RENDER_LANDMARK_PATHS) — the render path itself is core's, so
2030
+ // `pnpm up @kywi-software/core` reaches it. Nothing here may grow a copy of
2031
+ // a core helper; `__tests__/render-drift.test.mjs` (verification E8) fails
2032
+ // the build if it does.
2033
+ files['app/(site)/layout.tsx'] = siteLayout()
3276
2034
  files['app/(site)/site.css'] = siteStyles(answers)
3277
2035
  // 'use client' wrapper around core's shared nav renderer — required because
3278
2036
  // BUILT_IN_MODULE_COMPONENTS is exported from a 'use client' core module, so
3279
2037
  // the (Server Component) site layout above must render it through here
3280
2038
  // rather than looking it up directly (see siteNavComponent's doc comment).
2039
+ // Core ships an identical default; this stays project-owned, and wired
2040
+ // explicitly, because the header nav is the thing a site restyles first.
3281
2041
  files['components/site-nav.tsx'] = siteNavComponent()
3282
2042
  files['app/(site)/[[...slug]]/page.tsx'] = siteSlugPage()
3283
- // Public-render helpers: path/locale resolution, feed + component resolvers,
3284
- // personalization + experiments.
3285
- files['lib/site.ts'] = libSite()
3286
- // Shared /kywi.js script-injection primitive (kywi-cms#114) — the site
3287
- // layout's nav loader and the personalization runtime below both use it,
3288
- // so loading the bundle for either reason never double-loads it.
3289
- files['lib/kywi-js-loader.ts'] = kywiJsLoaderLib()
3290
- files['components/kywi-js-loader.tsx'] = kywiJsLoaderComponent()
3291
- // Optional client personalization runtime (self-ID widget, live re-eval). #50
3292
- files['components/personalization-runtime.tsx'] = personalizationRuntime()
3293
- // Front-of-site edit overlay (?kywi-edit=1), mounted by the page above.
3294
- files['app/(site)/kywi-front-edit.tsx'] = frontEditOverlay()
3295
2043
  // Syncs the admin stylesheet + a cache-busting version marker into
3296
2044
  // public/ on every predev/prebuild — see the doc comment on
3297
2045
  // syncKywiAdminCssScript and packageJson's syncScripts.