create-kywi-app 0.16.0 → 0.19.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
@@ -44,12 +44,15 @@ const CORE_RANGE = (v) => `^${v}`
44
44
 
45
45
  /** @param {Answers} a */
46
46
  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
47
+ // Coupled mode's front-of-site editor (core's `KywiFrontEdit`, from
48
+ // @kywi-software/core/next/client) needs the admin design system's stylesheet
49
+ // available at a plain, fetchable URL (/kywi-admin.css) — it is injected via a
50
+ // runtime <link> tag rather than a JS import (kywi-cms#130 follow-up: an
51
+ // import, even a dynamic one, still ships the stylesheet on every public route
52
+ // — see `ensureAdminStylesheet` in core's front-edit.tsx). The SYNC stays
53
+ // project-owned even though the overlay is core's: core fetches
54
+ // /kywi-admin.css.version at runtime, and only the app can put a file in its
55
+ // own public/. Unlike public/kywi.js (opt-in, manually placed when
53
56
  // personalization.clientRuntime is enabled — see README), this is a CORE
54
57
  // feature every coupled-mode app has, so the sync is automatic.
55
58
  const syncScripts =
@@ -191,7 +194,7 @@ assertProductionAuthSecret(process.env.AUTH_SECRET)
191
194
 
192
195
  /**
193
196
  * Kywi configuration — the single source of truth for this project.
194
- * Docs: https://kywi.dev/docs/config
197
+ * Docs: https://docs.kywi.dev/config
195
198
  */
196
199
  export default defineKywiConfig({
197
200
  // Deployment mode:
@@ -333,9 +336,11 @@ function tsconfig() {
333
336
  * Ambient declaration for stylesheet imports.
334
337
  *
335
338
  * 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
339
+ * history below). The front-edit overlay — then emitted as
340
+ * `app/(site)/kywi-front-edit.tsx`, since plan E1 core's own
341
+ * `@kywi-software/core/next/client` — originally pulled the admin design system
342
+ * in via `await import('@kywi-software/core/admin/styles.css')` inside
343
+ * `next/dynamic`'s factory. TypeScript treats a
339
344
  * *dynamic* import of a non-code file as an ordinary module import and
340
345
  * reports TS2307 for it, unlike a top-level `import 'x.css'` statement, which
341
346
  * it lets through as a resource import. Next type-checks during `next
@@ -344,11 +349,15 @@ function tsconfig() {
344
349
  * reference app imported that stylesheet in statement form and the
345
350
  * scaffold's own output had never been type-checked at all.
346
351
  *
352
+ * (The overlay moved into core in plan E1; the reasoning is kept here because
353
+ * this declaration is still what makes a stylesheet import type-check in a
354
+ * generated app.)
355
+ *
347
356
  * kywi-cms#130 follow-up: that dynamic import turned out to ship the
348
357
  * stylesheet to every public route regardless of the `next/dynamic`
349
358
  * wrapping (Next's App Router CSS collection bundles every css import it can
350
359
  * see in the reachable module graph, dynamic or not) — see
351
- * `ensureAdminStylesheet` in `frontEditOverlay()`, which replaced it with a
360
+ * `ensureAdminStylesheet` in core's `next/client/front-edit.tsx`, which replaced it with a
352
361
  * runtime `<link>` tag. This ambient declaration is no longer load-bearing
353
362
  * for that one case, but it costs nothing to keep as a general-purpose `*.css`
354
363
  * declaration for any future dynamic stylesheet import.
@@ -359,11 +368,12 @@ function tsconfig() {
359
368
  function cssTypes() {
360
369
  return `// Stylesheet imports. CSS Modules are typed by Next itself — the more
361
370
  // 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.)
371
+ // \`await import('…/styles.css')\` in the front-edit overlay, which TypeScript
372
+ // checks like any other module — see cssTypes' doc comment in templates.mjs.
373
+ // That import was replaced by a runtime \`<link>\` tag, kywi-cms#130 follow-up,
374
+ // and the overlay itself now lives in @kywi-software/core/next, so nothing in
375
+ // this generated app currently needs this declaration — kept for any future
376
+ // dynamic stylesheet import.)
367
377
  declare module '*.css'
368
378
  `
369
379
  }
@@ -455,835 +465,149 @@ public/kywi-admin.css.version
455
465
  `
456
466
  }
457
467
 
458
- // ── server runtime (lib/kywi.ts) ──────────────────────────────────────────────
459
-
460
- function libKywi() {
461
- return `import {
462
- createDb,
463
- createKywiApiHandler,
464
- createStorageProvider,
465
- resolveDatabaseUrl,
466
- resolveAuthSecret,
467
- } from '@kywi-software/core/server'
468
- import type { KywiApiHandler, KywiDb } from '@kywi-software/core/server'
469
- import { createKywiScope } from '@kywi-software/core/scope'
470
- import type { KywiScope } from '@kywi-software/core/scope'
471
- import config from '../kywi.config'
472
-
473
- /**
474
- * Server-side Kywi singletons — config, DB, API handler and the DB-backed scope,
475
- * all sharing one connection pool. Memoised so it initialises once per process.
476
- */
477
- export interface KywiRuntime {
478
- handler: KywiApiHandler
479
- scope: KywiScope
480
- db: KywiDb
481
- siteId: string
482
- config: typeof config
483
- }
484
-
485
- let _runtime: KywiRuntime | null = null
486
- let _init: Promise<KywiRuntime> | null = null
487
-
488
- async function init(): Promise<KywiRuntime> {
489
- const { url: dbUrl } = resolveDatabaseUrl(config.db.url)
490
- const db = createDb(dbUrl, config.db.provider)
491
- const authSecret = resolveAuthSecret(config.auth.secret)
492
- const storage = createStorageProvider(config.media ?? { provider: 'local', localPath: './uploads' })
493
- const handler = await createKywiApiHandler({ config, db, authSecret, storage })
494
- const scope = createKywiScope(config, db, undefined, { mediaBaseUrl: '/api/v1' })
495
-
496
- // Resolve the DB UUID for the first configured site (its \`id\` is the slug).
497
- const sites = await scope.site.list()
498
- const match = sites.find((s) => (s as { slug?: string }).slug === config.sites[0]?.id)
499
- const siteId = (match?.['id'] as string | undefined) ?? config.sites[0]?.id ?? 'default'
500
-
501
- return { handler, scope, db, siteId, config }
502
- }
503
-
504
- export async function getKywi(): Promise<KywiRuntime> {
505
- if (_runtime) return _runtime
506
- if (!_init) _init = init().then((r) => (_runtime = r))
507
- return _init
508
- }
509
-
510
- export async function getKywiHandler(): Promise<KywiApiHandler> {
511
- return (await getKywi()).handler
512
- }
513
- `
514
- }
515
-
516
- function libConfig() {
517
- return `/** Re-export the Kywi config for use throughout the app (single import path). */
518
- import config from '../kywi.config'
519
- export default config
520
- `
521
- }
522
-
523
- // ── public-render helpers (lib/site.ts) ───────────────────────────────────────
524
-
525
- /**
526
- * Server-side helpers for the coupled public site: path- and locale-aware content
527
- * resolution (#30, #51), the feed-hydration resolver (#28) and the component
528
- * resolver (#46) for the layout engine, plus small SEO/media utilities. Kept out
529
- * of the page so the route stays a thin render over these.
530
- */
531
- function libSite() {
532
- return `import { headers, cookies } from 'next/headers'
533
- import type {
534
- LayoutDocument,
535
- RegionNode,
536
- VariantContainer,
537
- FeedItemsResolver,
538
- PersonalizationState,
539
- } from '@kywi-software/core/layout'
540
- import {
541
- isLayoutSection,
542
- collectComponentRefs,
543
- componentDetachSource,
544
- resolveComponentPlacements,
545
- applyPageVariant,
546
- } from '@kywi-software/core/layout'
547
- import { resolveContentByPath, normalizePath } from '@kywi-software/core/nav'
548
- import { getFeedBySlug, getComponentById, resolveLocaleFromRequest } from '@kywi-software/core'
549
- import {
550
- evaluateActiveAudiences,
551
- getSelfIdWidgetConfig,
552
- listSelfIdFields,
553
- COOKIE_NAMES,
554
- } from '@kywi-software/core/audiences'
555
- import { cookieAllowedByHeader } from '@kywi-software/core/audiences/consent'
556
- import type {
557
- Audience,
558
- VisitorSignals,
559
- SelfIdField,
560
- SelfIdWidgetConfig,
561
- SelfIdTrigger,
562
- } from '@kywi-software/core/audiences/types'
563
- import { listRunningExperiments, resolveExperimentForContainer } from '@kywi-software/core/experiments'
564
- import { getKywi, type KywiRuntime } from './kywi'
565
- import config from './config'
566
-
567
- /** A resolved content node is a flat record of its columns (base + custom fields). */
568
- export type ContentNode = Record<string, unknown>
569
-
570
- // Absolute base for media/OG URLs. Set NEXT_PUBLIC_SITE_URL in production so
571
- // crawlers and structured data get absolute image URLs.
572
- const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'
573
-
574
- /** Public URL for a media file served by the API handler, or null. */
575
- export function mediaUrl(id: unknown): string | null {
576
- return typeof id === 'string' && id ? \`\${SITE_URL}/api/v1/media/\${id}/file\` : null
577
- }
578
-
579
- /** Absolute origin of the current request, for structured-data (JSON-LD) URLs. */
580
- export async function requestBaseUrl(): Promise<string> {
581
- const h = await headers()
582
- const host = h.get('host') ?? 'localhost'
583
- const isLocal = host.startsWith('localhost') || host.startsWith('127.') || host.startsWith('0.0.0.0')
584
- const proto = h.get('x-forwarded-proto') ?? (isLocal ? 'http' : 'https')
585
- return \`\${proto}://\${host}\`
586
- }
587
-
588
- // ─── Locale routing (#51) ────────────────────────────────────────────────────
589
-
590
- /** The active site's configured locales (the default is always included first). */
591
- export function siteLocales(): { defaultLocale: string; locales: string[] } {
592
- const site = config.sites[0]
593
- const defaultLocale = site?.defaultLocale ?? 'en'
594
- const configured = site?.locales && site.locales.length > 0 ? site.locales : [defaultLocale]
595
- const locales = configured.includes(defaultLocale) ? configured : [defaultLocale, ...configured]
596
- return { defaultLocale, locales }
597
- }
598
-
599
- /**
600
- * Split a URL slug path into its active locale and the remaining slug segments.
601
- * A leading segment matching a CONFIGURED locale is treated as a locale prefix
602
- * (\`/es/pricing\` → locale 'es', path ['pricing']); otherwise Accept-Language
603
- * selects a supported locale, else the site default applies (the whole path is
604
- * the slug). Locale prefixes are only honoured for locales in \`site.locales\`.
605
- */
606
- export function resolveRequestLocale(
607
- slugSegments: string[],
608
- acceptLanguage?: string | null,
609
- ): { locale: string; slugPath: string[] } {
610
- const { defaultLocale, locales } = siteLocales()
611
- const pathname = '/' + slugSegments.join('/')
612
- const locale = resolveLocaleFromRequest(pathname, acceptLanguage ?? null, defaultLocale, locales)
613
- const first = slugSegments[0]
614
- const slugPath = first && locales.includes(first) ? slugSegments.slice(1) : slugSegments
615
- return { locale, slugPath }
616
- }
468
+ // ── the generated render path: version stamp + landmarks ─────────────────────
617
469
 
618
470
  /**
619
- * hreflang alternates for a page across the site's configured locales, or
620
- * undefined for a single-locale site. Feeds Next's \`alternates.languages\`.
471
+ * Version of the GENERATED RENDER PATH's SHAPE — the four thin delegating files
472
+ * below (`lib/kywi.ts`, `app/(site)/layout.tsx`,
473
+ * `app/(site)/[[...slug]]/page.tsx`, `middleware.ts`), independent of the
474
+ * package version. Bump it when their shape changes in a way an existing
475
+ * project should pick up, so a project can compare its stamp against a newer
476
+ * create-kywi-app and know its render path is stale.
477
+ *
478
+ * Exactly the {@link GUIDANCE_VERSION} / {@link guidanceStamp} convention, one
479
+ * namespace over: `kywi-render` where the guidance files say
480
+ * `kywi-agent-guidance`. `layered-architecture DESIGN.md` §6 names that
481
+ * mechanism as the one `create-kywi-app upgrade` (plan E3) extends to these
482
+ * files — this is the half E1 owns, the emission of a detectable stamp.
621
483
  */
622
- export function localeAlternates(slugSegments: string[]): Record<string, string> | undefined {
623
- const { defaultLocale, locales } = siteLocales()
624
- if (locales.length <= 1) return undefined
625
- const { slugPath } = resolveRequestLocale(slugSegments, null)
626
- const rel = slugPath.join('/')
627
- const out: Record<string, string> = {}
628
- for (const loc of locales) {
629
- const base = loc === defaultLocale ? \`/\${rel}\` : \`/\${loc}/\${rel}\`
630
- out[loc] = base.length > 1 ? base.replace(/\\/$/, '') : base
631
- }
632
- return out
633
- }
634
-
635
- // ─── Content resolution (#30 full path + #51 locale) ─────────────────────────
484
+ export const RENDER_VERSION = 1
636
485
 
637
486
  /**
638
- * Resolve a public content node from a URL slug path, honouring the Site Tree
639
- * hierarchy: the FULL materialized path is matched, so \`/a/b/<slug>\` serves only
640
- * the node actually at \`/a/b/<slug>\` — never a top-level \`<slug>\` (#30). The
641
- * locale (URL prefix › Accept-Language › default) is resolved first and preferred,
642
- * with a default-locale fallback (#51). Returns the node regardless of status;
643
- * the caller enforces \`status === 'published'\`.
487
+ * The staleness stamp every generated render-path file opens with. A `//`
488
+ * comment rather than the guidance files' `<!-- … -->` (these are .ts/.tsx),
489
+ * so it is inert to the compiler but greppable by tooling and by an agent
490
+ * asking "is this project's render path current?".
491
+ * @returns {string}
644
492
  */
645
- export async function resolvePublicContent(
646
- slugSegments: string[],
647
- opts?: { acceptLanguage?: string | null },
648
- ): Promise<ContentNode | null> {
649
- const { scope, db, siteId } = await getKywi()
650
- const { defaultLocale } = siteLocales()
651
- const { locale, slugPath } = resolveRequestLocale(slugSegments, opts?.acceptLanguage)
652
-
653
- const bySlug = async (slug: string): Promise<ContentNode | null> => {
654
- let node = await scope.content.getBySlug(slug, siteId, { locale })
655
- if (!node && locale !== defaultLocale) {
656
- node = await scope.content.getBySlug(slug, siteId, { locale: defaultLocale })
657
- }
658
- return (node as ContentNode | null) ?? null
659
- }
660
-
661
- // Home ("/"): the seeded root node (slug "home", path "/").
662
- if (slugPath.length === 0) return bySlug('home')
663
-
664
- // Structural resolution by the full path enforces the parent chain (#30) and
665
- // picks the locale-correct row that shares that path (#51).
666
- const fullPath = normalizePath('/' + slugPath.join('/'))
667
- let structural = await resolveContentByPath(db, fullPath, siteId, { locale })
668
- if (!structural && locale !== defaultLocale) {
669
- structural = await resolveContentByPath(db, fullPath, siteId, { locale: defaultLocale })
670
- }
671
- if (structural) {
672
- return (await scope.content.getById(structural.id)) as ContentNode | null
673
- }
674
-
675
- // Flat-slug fallback ONLY for a single-segment URL whose target has no parent
676
- // chain (a top-level node whose \`path\` column was never materialized). A nested
677
- // node is never reachable at a wrong prefix — that is the #30 fix.
678
- if (slugPath.length === 1) {
679
- const node = await bySlug(slugPath[0]!)
680
- if (node && !node['parentId']) return node
681
- }
682
- return null
493
+ export function renderStamp() {
494
+ return `// kywi-render v${RENDER_VERSION} (create-kywi-app ${packageVersion()})`
683
495
  }
684
496
 
685
- // ─── Feed hydration (#28) ────────────────────────────────────────────────────
686
-
687
497
  /**
688
- * Build the {@link FeedItemsResolver} \`hydrateLayoutFeeds\` calls for each Feed
689
- * Display module: resolve the saved feed by slug, run its query (published-only,
690
- * per-module limit) through the feed engine, and pre-map each row's media id to a
691
- * URL so the (client) module can render images. Core owns the walk + item shape;
692
- * this owns the data access.
498
+ * Parses a {@link renderStamp} back out of a file's text:
499
+ * `[, renderVersion, cliVersion]`. Anchored to a whole line so a mention of the
500
+ * stamp inside prose cannot match.
693
501
  */
694
- export function buildFeedResolver(runtime: KywiRuntime): FeedItemsResolver {
695
- const { scope, db, siteId } = runtime
696
- return async (feedSlug, { limit }) => {
697
- const feed = await getFeedBySlug(db, feedSlug, siteId)
698
- if (!feed) return []
699
- const result = await scope.feeds.query({
700
- ...(feed.query as Record<string, unknown>),
701
- siteId,
702
- status: 'published',
703
- limit,
704
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
705
- } as any)
706
- return result.items.map((row) => ({
707
- ...row,
708
- image: mediaUrl(row['featuredImageId']) ?? undefined,
709
- }))
710
- }
711
- }
712
-
713
- // ─── Linked components: server-side pre-resolution (#46, #69, #147) ──────────
714
-
715
- /** Fetch a set of component rows once and index them by id. */
716
- async function loadComponents(ids: string[], runtime: KywiRuntime) {
717
- const { db, siteId } = runtime
718
- const entries = await Promise.all(
719
- ids.map(async (id) => [id, await getComponentById(db, id, siteId)] as const),
720
- )
721
- return new Map(entries)
722
- }
502
+ export const RENDER_STAMP_RE = /^\/\/ kywi-render v(\d+) \(create-kywi-app ([^)]+)\)$/m
723
503
 
724
504
  /**
725
- * Resolve every linked component (\`componentId\`) the layout places, ON THE
726
- * SERVER, before the document reaches the renderer.
727
- *
728
- * This is the ONLY way a Server Component can render linked components.
729
- * \`KywiLayout\` is a client component and its \`componentResolver\` /
730
- * \`sectionComponentResolver\` / \`variantContainerComponentResolver\` props are
731
- * FUNCTIONS: passing them from here throws "Functions cannot be passed directly
732
- * to Client Components" and the page 500s. Pre-resolution hands the renderer a
733
- * plain document instead — only data crosses the boundary — and produces exactly
734
- * the markup the resolvers were meant to produce, \`data-component-id\` included.
735
- *
736
- * \`collectComponentRefs\` is the engine's own link walker, the same one the
737
- * instance index and detach-all use, so this never falls behind on where a link
738
- * may live. It also knows that a connected section's inline columns are fallback
739
- * copy rather than placements, so links buried in one are not fetched — they
740
- * only render when that section's own component has gone missing.
741
- *
742
- * Definitions are read per request with no cache, so editing a component is live
743
- * on the next page view.
505
+ * Landmark → its relative path, for the RENDER PATH: one entry per thin,
506
+ * version-stamped file the scaffold emits at a path Next demands. Same role as
507
+ * {@link AGENTS_LANDMARK_PATHS} — one source of truth for the path strings —
508
+ * shared by the emitter ({@link buildFileSet}), the E8 drift guard
509
+ * (`__tests__/render-drift.test.mjs`) and, in plan E3, the `upgrade` command.
510
+ * @type {Record<'runtime'|'middleware'|'siteLayout'|'sitePage', string>}
744
511
  */
745
- export async function resolveLayoutComponents(
746
- layout: LayoutDocument,
747
- runtime: KywiRuntime,
748
- ): Promise<LayoutDocument> {
749
- const refs = collectComponentRefs(layout)
750
- if (refs.length === 0) return layout
751
- const map = await loadComponents([...new Set(refs.map((ref) => ref.componentId))], runtime)
752
- return resolveComponentPlacements(layout, (componentId) =>
753
- componentDetachSource(map.get(componentId) ?? null),
754
- )
755
- }
756
-
757
- // ─── Personalization + experiments (#50) ─────────────────────────────────────
758
-
759
- /** Server-resolved personalization for one public request. */
760
- export interface PublicPersonalization {
761
- /** Threaded into <KywiLayout personalization=…> for variantContainer nodes. */
762
- personalization: PersonalizationState
763
- /** Winning audience id (null = default / opted out / no match). */
764
- audienceId: string | null
765
- /** Stable visitor id (middleware cookie/header); keys A/B assignment. */
766
- visitorId: string
767
- /**
768
- * True when \`visitorId\` is a durable identity this visitor consented to
769
- * (kywi-cms#91). False means it was minted for this request alone — nothing
770
- * may be written against it, including an A/B exposure.
771
- */
772
- tracked: boolean
773
- /** Active audiences evaluated — handed to the client runtime for re-eval. */
774
- audiences: Audience[]
775
- /** Server-resolved signals — handed to the client runtime as serverSignals. */
776
- signals: Partial<VisitorSignals>
777
- }
778
-
779
- /** Is the winning audience id one of the currently active audiences? */
780
- function isActiveAudience(id: string | null, audiences: Audience[]): boolean {
781
- return id != null && audiences.some((a) => a.id === id)
512
+ export const RENDER_LANDMARK_PATHS = {
513
+ runtime: 'lib/kywi.ts',
514
+ middleware: 'middleware.ts',
515
+ siteLayout: 'app/(site)/layout.tsx',
516
+ sitePage: 'app/(site)/[[...slug]]/page.tsx',
782
517
  }
783
518
 
784
519
  /**
785
- * Evaluate the site's active audiences for this request, exactly once, and pick
786
- * the visitor's A/B arms — the two server seams that make page variants,
787
- * variantContainers and experiments resolve per-visitor with NO client runtime
788
- * (kywi-cms#50). The request the audience engine reads (URL + cookies + referrer)
789
- * is reconstructed from next/headers, so this is safe from any (site) Server
790
- * Component. A \`kywi_preview_init=<audienceId>\` cookie (set by the admin preview
791
- * link) forces that audience, so an editor previews it without matching its live
792
- * conditions.
520
+ * Which render-path files a FRESH scaffold of the given mode emits — derived
521
+ * statically from the mode, never from the filesystem ({@link
522
+ * scaffoldLandmarks}'s counterpart for the render path). The wiring runtime and
523
+ * the middleware exist in every mode; the two `app/(site)` routes are coupled
524
+ * mode only.
525
+ * @param {'coupled'|'headless'|'decoupled'} mode
526
+ * @returns {Record<'runtime'|'middleware'|'siteLayout'|'sitePage', boolean>}
793
527
  */
794
- export async function resolvePersonalization(
795
- runtime: KywiRuntime,
796
- layout: LayoutDocument | null | undefined,
797
- ): Promise<PublicPersonalization> {
798
- const [h, cookieStore] = await Promise.all([headers(), cookies()])
799
- // One resolution of \`theme.personalization.requireConsent\` for the whole
800
- // request, handed to every gate below — the visitor-id read AND the audience
801
- // engine's own collectors. They read the same cookies; disagreeing about
802
- // whether consent is required would personalize half a page.
803
- const gate = { requireConsent: requireConsentEnabled() }
804
- // \`kywi_visitor\` is a \`personalization\` cookie, so without consent it is
805
- // neither written nor read (kywi-cms#91) — the middleware hands this request a
806
- // THROWAWAY id instead, freshly minted and different on the next request.
807
- // \`tracked\` carries that fact to everything downstream that would otherwise
808
- // write the id to the database.
809
- const tracked = cookieAllowedByHeader(COOKIE_NAMES.VISITOR, h.get('cookie'), gate)
810
- // The stored cookie is read THROUGH that gate too, not just written through
811
- // it: a visitor who accepted last month and has since rejected still carries
812
- // one, and honouring it would re-identify them under the identity they
813
- // revoked. The header bridge is unaffected — the middleware already applied
814
- // the same rule when it minted the id this request carries.
815
- const visitorId =
816
- h.get('x-kywi-visitor') ??
817
- (tracked ? cookieStore.get(COOKIE_NAMES.VISITOR)?.value : undefined) ??
818
- 'vis-anon'
819
- const host = h.get('host') ?? 'localhost'
820
- const url = h.get('x-kywi-url') ?? \`http://\${host}/\`
821
-
822
- const reqHeaders = new Headers()
823
- h.forEach((value, key) => reqHeaders.set(key, value))
824
- const request = new Request(url, { headers: reqHeaders })
825
-
826
- const { db, siteId } = runtime
827
- // \`gate\` reaches the engine's server collectors, so a site whose consent is
828
- // owned by an external CMP (\`requireConsent: false\`) has its stored UTM /
829
- // visitor / known / pinned-audience cookies read here too, not just by the
830
- // middleware and the runtime routes.
831
- const { result, audiences } = await evaluateActiveAudiences(db, siteId, request, gate)
832
-
833
- const previewId = cookieStore.get(COOKIE_NAMES.PREVIEW_INIT)?.value || null
834
- const audienceId = isActiveAudience(previewId, audiences) ? previewId : result.winningAudienceId
835
-
836
- const experimentAssignments = await resolveExperimentAssignments(
837
- runtime,
838
- layout,
839
- audienceId,
840
- visitorId,
841
- tracked,
842
- )
843
-
844
- return {
845
- personalization: { resolvedAudienceId: audienceId, experimentAssignments },
846
- audienceId,
847
- visitorId,
848
- tracked,
849
- audiences,
850
- signals: result.signals,
851
- }
852
- }
853
-
854
- function* variantContainersInRegions(regions: Record<string, RegionNode[]>): Generator<VariantContainer> {
855
- for (const nodes of Object.values(regions)) {
856
- for (const node of nodes) {
857
- if (!isLayoutSection(node)) yield node
858
- }
859
- }
528
+ export function renderLandmarks(mode) {
529
+ const coupled = mode === 'coupled'
530
+ return { runtime: true, middleware: true, siteLayout: coupled, sitePage: coupled }
860
531
  }
861
532
 
862
533
  /**
863
- * Deterministically assign this visitor to a variant of every running A/B
864
- * experiment the (audience-resolved) layout actually shows, and record the
865
- * exposure. Stable on visitorId → the visitor keeps the same arm across requests;
866
- * the write is idempotent on (experiment, visitor). Returns experimentId →
867
- * chosen variant key for <KywiLayout personalization.experimentAssignments>.
868
- *
869
- * \`tracked\` is the consent gate (kywi-cms#91): false means \`visitorId\` is a
870
- * throwaway the middleware minted for this request alone, and the idempotent
871
- * write stops being idempotent — every anonymous page view would insert another
872
- * assignment row. The ARM IS STILL CHOSEN, so the page renders exactly as it
873
- * does for anyone else; only the counting stops.
534
+ * Read the render-path stamps out of an EXISTING project. Mirrors {@link
535
+ * detectAgentsLandmarks} (presence on disk) and adds the parsed stamp, because
536
+ * a render file is REPLACEABLE where a guidance file is only skip-or-force: a
537
+ * file carrying a stamp is create-kywi-app's own output and safe to overwrite,
538
+ * while an unstamped one was hand-written or predates the layered shape
539
+ * (`layered-architecture DESIGN.md` §8) and must be ejected, not clobbered.
540
+ * E1 only emits and detects; E3 acts on it.
541
+ * @param {string} projectDir absolute path to the project root
542
+ * @returns {Record<string, { path: string, present: boolean, version: number|null, cliVersion: string|null }>}
874
543
  */
875
- async function resolveExperimentAssignments(
876
- runtime: KywiRuntime,
877
- layout: LayoutDocument | null | undefined,
878
- audienceId: string | null,
879
- visitorId: string,
880
- tracked: boolean,
881
- ): Promise<Record<string, string | null>> {
882
- if (!layout) return {}
883
- // Pre-resolve FIRST, then read the containers. A container component saved
884
- // with \`mode: 'ab_test'\` keeps its experiment in the DEFINITION; a placement
885
- // that carries only a \`componentId\` would otherwise look like a plain
886
- // container to this pass, no assignment would be made, and every visitor would
887
- // see the Default arm of a live experiment. After pre-resolution there are no
888
- // unresolved placements left to special-case — the containers here are the
889
- // containers that render.
890
- const resolved = await resolveLayoutComponents(applyPageVariant(layout, audienceId), runtime)
891
- const experimentIds = new Set<string>()
892
- for (const node of variantContainersInRegions(resolved.regions)) {
893
- if (node.mode === 'ab_test' && !node.winnerId && node.experimentId) {
894
- experimentIds.add(node.experimentId)
544
+ export function detectRenderLandmarks(projectDir) {
545
+ /** @type {Record<string, { path: string, present: boolean, version: number|null, cliVersion: string|null }>} */
546
+ const out = {}
547
+ for (const [key, rel] of Object.entries(RENDER_LANDMARK_PATHS)) {
548
+ const abs = join(projectDir, rel)
549
+ const present = existsSync(abs)
550
+ const match = present ? RENDER_STAMP_RE.exec(readFileSync(abs, 'utf8')) : null
551
+ out[key] = {
552
+ path: rel,
553
+ present,
554
+ version: match ? Number(match[1]) : null,
555
+ cliVersion: match ? match[2] : null,
895
556
  }
896
557
  }
897
- if (experimentIds.size === 0) return {}
898
-
899
- const running = await listRunningExperiments(runtime.db, runtime.siteId)
900
- const byId = new Map(running.map((e) => [e.id, e]))
901
- const assignments: Record<string, string | null> = {}
902
- for (const id of experimentIds) {
903
- const exp = byId.get(id)
904
- if (!exp) continue
905
- const res = await resolveExperimentForContainer(runtime.db, exp, visitorId, { recordExposure: tracked })
906
- assignments[id] = res.variantKey
907
- }
908
- return assignments
909
- }
910
-
911
- /**
912
- * Return the audience-resolved layout: page variants (audience → whole-page
913
- * region override) applied, and \`pageVariants\`/\`abExperiments\` stripped so no
914
- * other audience's content is serialized to this visitor.
915
- *
916
- * This is only the PAGE-level half. Container-level arms (variantContainer /
917
- * moduleVariantContainer) are stripped by \`pruneLayoutToServedArms\` as the last
918
- * pass before \`<KywiLayout>\` — see the public page (#167). Run both, or the
919
- * losing arms are still readable in the page's RSC flight payload.
920
- */
921
- export function personalizeLayout(
922
- layout: LayoutDocument,
923
- audienceId: string | null,
924
- ): LayoutDocument {
925
- return applyPageVariant(layout, audienceId)
926
- }
927
-
928
- /** Is the @kywi-software/js client runtime enabled for this site's theme? */
929
- export function clientRuntimeEnabled(): boolean {
930
- const themeName = config.sites[0]?.theme
931
- const theme = config.themes.find((t) => t.name === themeName) ?? config.themes[0]
932
- return theme?.personalization?.clientRuntime === true
558
+ return out
933
559
  }
934
560
 
935
- /**
936
- * Is the global transparency surface (the automatic pill/panel showing which
937
- * audience the page was personalized for, with a switcher and opt-out) enabled
938
- * for this site's theme? \`personalization.transparencyNotice.enabled\` — unset
939
- * defaults to true; only an explicit \`false\` suppresses the surface from
940
- * appearing on its own. Even then a \`personalizationBadge\` module can still
941
- * open it deliberately.
942
- */
943
- export function transparencyNoticeEnabled(): boolean {
944
- const themeName = config.sites[0]?.theme
945
- const theme = config.themes.find((t) => t.name === themeName) ?? config.themes[0]
946
- return theme?.personalization?.transparencyNotice?.enabled !== false
947
- }
561
+ // ── the wiring runtime (lib/kywi.ts) ──────────────────────────────────────────
948
562
 
949
563
  /**
950
- * Must a visitor consent before this site writes a \`personalization\` cookie
951
- * (\`kywi_visitor\`, \`kywi_signals\`, \`kywi_utm\`, \`kywi_audience\`)?
952
- * \`personalization.requireConsent\` — unset defaults to TRUE; only an explicit
953
- * \`false\` turns Kywi's consent gate off, for a site that collects consent with
954
- * external tooling (kywi-cms#91).
564
+ * The app's ONE config seam, and the only render-path file that exists in every
565
+ * mode. Before plan E1 this was 111 lines of connection-pool, singleton and
566
+ * Host→site-resolution code copied into every project; it is now four lines
567
+ * over `createKywiNextRuntime`, which core owns and `pnpm up` upgrades.
955
568
  *
956
- * \`middleware.ts\` runs on the edge and cannot read this config, so it mirrors
957
- * the same flag with \`KYWI_REQUIRE_CONSENT=false\`. Set both, or neither.
569
+ * It cannot collapse further: core cannot import an app's `kywi.config.ts`
570
+ * (there is no import path from a published package back into the app that
571
+ * installed it, and a config may pull in project plugins), so the app hands the
572
+ * config in — see `packages/core/src/next/runtime.ts`'s header.
958
573
  */
959
- export function requireConsentEnabled(): boolean {
960
- const theme = config.themes.find((t) => t.name === config.sites[0]?.theme) ?? config.themes[0]
961
- return theme?.personalization?.requireConsent !== false
962
- }
963
-
964
- // ─── Self-ID widget (#50) ────────────────────────────────────────────────────
965
-
966
- /** The self-ID widget config in the serializable shape the client runtime reads. */
967
- export interface PublicSelfIdWidget {
968
- fields: Array<{
969
- id: string
970
- label: string
971
- type: 'select'
972
- options?: Array<{ value: string; label: string }>
973
- required?: boolean
974
- }>
975
- headline: string
976
- subheadline?: string
977
- submitLabel: string
978
- skipLabel?: string
979
- displayMode: 'modal' | 'inline' | 'slide-in' | 'hello_bar_top' | 'hello_bar_bottom' | 'drawer'
980
- frequency: 'once' | 'session' | 'always'
981
- trigger: SelfIdTrigger
982
- }
983
-
984
- // The admin's five display modes (SelfIdWidgetConfig['displayMode']) and the
985
- // client runtime's (PublicSelfIdWidget['displayMode'], @kywi-software/js) are the
986
- // same set — the runtime implements hello_bar_top/hello_bar_bottom/drawer as
987
- // their own layouts, not folded into slide-in (kywi-cms#97) — so this is a
988
- // passthrough. Kept as a named function (rather than assigning displayMode
989
- // directly) so the two types stay checked against each other at compile time.
990
- function mapDisplayMode(mode: SelfIdWidgetConfig['displayMode']): PublicSelfIdWidget['displayMode'] {
991
- return mode
992
- }
574
+ function libKywi() {
575
+ return `${renderStamp()}
576
+ // This app's ONE Kywi wiring point. The render path lives in
577
+ // @kywi-software/core/next and reads kywi.config.ts THROUGH this runtime — a
578
+ // package cannot import your config itself. Regenerated by
579
+ // \`create-kywi-app upgrade\`: make your changes in kywi.config.ts, not here.
580
+ import { createKywiNextRuntime } from '@kywi-software/core/next'
581
+ import config from '../kywi.config'
993
582
 
994
- function mapFrequency(freq: SelfIdWidgetConfig['frequency']): PublicSelfIdWidget['frequency'] {
995
- if (freq === 'once_visitor') return 'once'
996
- if (freq === 'once_session') return 'session'
997
- return 'always'
998
- }
583
+ export const kywi = createKywiNextRuntime(config)
999
584
 
1000
- /**
1001
- * Resolve the site's stored self-ID widget config into the runtime shape, its
1002
- * picklist fields hydrated. Returns null when there is no config or it references
1003
- * no resolvable fields, so the caller can skip mounting the widget entirely.
1004
- */
1005
- export async function resolveSelfIdWidget(runtime: KywiRuntime): Promise<PublicSelfIdWidget | null> {
1006
- const [widget, allFields] = await Promise.all([
1007
- getSelfIdWidgetConfig(runtime.db, runtime.siteId),
1008
- listSelfIdFields(runtime.db, runtime.siteId),
1009
- ])
1010
- if (!widget) return null
1011
-
1012
- const byId = new Map<string, SelfIdField>(allFields.map((f) => [f.id, f]))
1013
- const fields = widget.fieldIds
1014
- .map((id) => byId.get(id))
1015
- .filter((f): f is SelfIdField => f != null)
1016
- .map((f) => ({
1017
- id: f.id,
1018
- label: f.label,
1019
- type: 'select' as const,
1020
- // {value, label} pairs, not bare values — the client runtime renders the
1021
- // label and submits the value (kywi-cms#96); dropping the label here is
1022
- // what made every option render as its raw value.
1023
- options: f.picklist,
1024
- required: f.required,
1025
- }))
1026
- if (fields.length === 0) return null
1027
-
1028
- return {
1029
- fields,
1030
- headline: widget.headline,
1031
- ...(widget.subheadline ? { subheadline: widget.subheadline } : {}),
1032
- submitLabel: widget.submitLabel,
1033
- ...(widget.skipLabel ? { skipLabel: widget.skipLabel } : {}),
1034
- displayMode: mapDisplayMode(widget.displayMode),
1035
- frequency: mapFrequency(widget.frequency),
1036
- trigger: widget.trigger,
1037
- }
1038
- }
585
+ // Named accessors for the API + AX route handlers, which want the handler
586
+ // rather than the whole runtime.
587
+ export const { getKywi, getKywiHandler, getActiveSite } = kywi
1039
588
  `
1040
589
  }
1041
590
 
1042
- // ── middleware.ts (thin framework wiring over @kywi-software/core/host) ───────
1043
-
1044
- function middleware() {
1045
- return `import { NextResponse, type NextRequest } from 'next/server'
1046
- import {
1047
- ACCESS_COOKIE,
1048
- REFRESH_COOKIE,
1049
- assertProductionAuthSecret,
1050
- classifyAccessToken,
1051
- setAccessCookie,
1052
- clearSessionCookies,
1053
- } from '@kywi-software/core/host'
1054
- // Edge-safe leaf helper: builds the kywi_utm cookie value core's collectUtm reads
1055
- // back. Its own dependency-free entry (never the DB-backed audiences barrel), so
1056
- // it is importable from this edge middleware.
1057
- import { UTM_COOKIE, utmCookieValue } from '@kywi-software/core/audiences/utm-persistence'
1058
- // Same kind of edge-safe leaf: the kywi_entry writer, whose value core reads
1059
- // back as \`session.entryPage\` (kywi-cms#196).
1060
- import { ENTRY_COOKIE, entryPageCookieValue } from '@kywi-software/core/audiences/entry-page'
1061
- // Same kind of edge-safe leaf entry: the cookie-consent category map + gate. The
1062
- // visitor's decision (written by the \`cookieConsent\` layout module) decides
1063
- // whether the personalization cookies below may be persisted at all.
1064
- import { cookieAllowedByHeader } from '@kywi-software/core/audiences/consent'
1065
-
1066
- /**
1067
- * The edge mirror of \`theme.personalization.requireConsent\` (kywi-cms#91).
1068
- * Middleware runs on the edge and must not import \`kywi.config.ts\` (a config is
1069
- * free to pull in plugins and DB-backed code that cannot run there), so the one
1070
- * flag that lives in config is mirrored here as an env var.
1071
- * \`KYWI_REQUIRE_CONSENT=false\` turns the gate off for the two cookies this file
1072
- * writes. Set it together with the theme flag, never one alone —
1073
- * createKywiApiHandler THROWS at startup when the two disagree, so a mismatch
1074
- * fails the deploy instead of silently making the middleware and the API
1075
- * disagree about whether a visitor is tracked.
1076
- */
1077
- const REQUIRE_CONSENT = process.env.KYWI_REQUIRE_CONSENT !== 'false'
591
+ // ── middleware.ts (a re-export of core's own) ─────────────────────────────────
1078
592
 
1079
593
  /**
1080
- * Server-side auth enforcement + transparent session refresh for /admin and the
1081
- * versioned API. The security-critical logic (JWT verify, cookie contract) lives
1082
- * in @kywi-software/core/host; this file is only the Next.js wiring:
594
+ * The auth gate, transparent session refresh, cookie→bearer bridge, visitor /
595
+ * UTM / entry-page cookies and `.md` content negotiation were 242 lines of
596
+ * emitted code; they are now `@kywi-software/core/next/middleware`.
1083
597
  *
1084
- * 1. Refresh a stale access cookie from the 7-day refresh cookie, so an admin
1085
- * is never bounced to /admin/login mid-session.
1086
- * 2. Bridge the httpOnly access cookie onto \`Authorization: Bearer\` for
1087
- * /api/v1/* — core reads credentials only from that header, and the browser
1088
- * cannot attach it (the token is httpOnly).
1089
- * 3. Gate /admin/* (except /admin/login): no usable session → redirect to login.
1090
- *
1091
- * Must resolve the SAME secret handed to createKywiApiHandler (config.auth.secret
1092
- * === process.env.AUTH_SECRET ?? default), or it would reject tokens the API accepts.
1093
- */
1094
-
1095
- // Refuse to run in production with a missing or dev-fallback AUTH_SECRET — this
1096
- // middleware verifies session JWTs, so a publicly-known default would make them
1097
- // forgeable. Runs at module load (edge bundle init), so a bad prod deploy fails
1098
- // loudly instead of silently accepting forged tokens. No-op in development.
1099
- assertProductionAuthSecret(process.env.AUTH_SECRET)
1100
-
1101
- const AUTH_SECRET = process.env.AUTH_SECRET ?? 'dev-secret-change-in-production'
1102
-
1103
- // Token/session endpoints are pass-through: never guarded, refreshed, or bridged
1104
- // (they establish a session rather than require one; the refresh call below is
1105
- // itself a POST to /auth/refresh and must not recurse).
1106
- function isAuthEndpoint(pathname: string): boolean {
1107
- return pathname.startsWith('/api/v1/auth/') &&
1108
- !pathname.startsWith('/api/v1/auth/oauth-clients') &&
1109
- !pathname.startsWith('/api/v1/auth/api-keys')
1110
- }
1111
-
1112
- // Anonymous visitor identity for personalization/experiments. A/B assignment is
1113
- // deterministic on this id, so persisting it (2y cookie) is what gives a visitor
1114
- // a STABLE experiment arm across requests without any client runtime (#50). Name
1115
- // matches @kywi-software/core/audiences COOKIE_NAMES.VISITOR; kept as a literal
1116
- // here so the edge middleware never imports the (server-only) audiences module.
1117
- const VISITOR_COOKIE = 'kywi_visitor'
1118
- const VISITOR_MAX_AGE = 60 * 60 * 24 * 365 * 2 // 2 years
1119
-
1120
- /**
1121
- * Public (non-admin, non-API) request: never auth-gated. Ensure a stable
1122
- * \`kywi_visitor\` id — mint one on first visit — and forward it to the render as
1123
- * \`x-kywi-visitor\` so the page sees it on this very request (the freshly set
1124
- * cookie is not yet readable via cookies()). Also forward the full request URL
1125
- * as \`x-kywi-url\` (query string included) so the server render can read UTM
1126
- * params off the very first request — without it, campaign personalization
1127
- * would never fire on the landing page. Everything else passes through.
598
+ * It has its OWN core entry rather than living on the `/next` barrel because
599
+ * Next compiles this file for the EDGE runtime: the barrel reaches the
600
+ * database, sharp and the AWS SDK, and re-exporting the middleware from it
601
+ * would drag all of that into the edge bundle. Same precedent as core's
602
+ * `./host` split (see that file's header in core).
1128
603
  */
1129
- function handlePublicRequest(req: NextRequest): NextResponse {
1130
- // Both cookies below are \`personalization\` cookies in core's consent map, so
1131
- // neither is written OR READ until the visitor accepts. The header bridge is
1132
- // unaffected — this request still gets a visitor id and still personalizes off
1133
- // its own URL — so a site with no consent banner still renders, it just serves
1134
- // default content instead of tracking anyone. Place the \`cookieConsent\`
1135
- // module to let visitors turn personalization on.
1136
- const cookieHeader = req.headers.get('cookie')
1137
- const gate = { requireConsent: REQUIRE_CONSENT }
1138
- const mayPersonalize = cookieAllowedByHeader(VISITOR_COOKIE, cookieHeader, gate)
1139
- // Without consent the stored id is IGNORED, not just left un-refreshed: a
1140
- // visitor who accepted last month and rejected today still carries the cookie,
1141
- // and reading it would keep them tracked under the identity they just revoked.
1142
- const existing = mayPersonalize ? req.cookies.get(VISITOR_COOKIE)?.value : undefined
1143
- const visitorId = existing ?? crypto.randomUUID()
1144
- const headers = new Headers(req.headers)
1145
- headers.set('x-kywi-visitor', visitorId)
1146
- headers.set('x-kywi-url', req.nextUrl.href)
1147
- const res = NextResponse.next({ request: { headers } })
1148
- if (!existing && mayPersonalize) {
1149
- res.cookies.set(VISITOR_COOKIE, visitorId, { path: '/', maxAge: VISITOR_MAX_AGE, sameSite: 'lax' })
1150
- }
1151
- // Persist campaign UTM params so audience matching survives internal
1152
- // navigation: core's collectUtm reads this cookie when the URL has no utm_*
1153
- // query string. Only written when the request carries utm_* params, so an
1154
- // ordinary page view never clobbers a persisted campaign (utmCookieValue
1155
- // returns null → the existing cookie is left in place) — and only once the
1156
- // visitor has consented, which utmCookieValue reads off the header we pass.
1157
- const utmValue = utmCookieValue(req.nextUrl.href, { cookieHeader, ...gate })
1158
- if (utmValue) {
1159
- res.cookies.set(UTM_COOKIE, utmValue, { path: '/', maxAge: 60 * 60 * 24 * 30, sameSite: 'lax' })
1160
- }
1161
- // Record the session's ENTRY page (path + query) so rules on
1162
- // \`session.entryPage\` keep matching once the visitor navigates on
1163
- // (kywi-cms#196). Written once, consent-gated like the two above, and with no
1164
- // maxAge — a new visit is a new entry page. The landing request needs no
1165
- // cookie: core falls back to the request's own path, so \`?from=marketer\`
1166
- // personalizes the page it links to on that very request.
1167
- // Sec-Fetch-Dest / Accept are passed so ONLY a document request can name the
1168
- // entry page: a subresource that happens to be the session's first request
1169
- // (\`/kywi.js\`, a font, a route handler) would otherwise record itself as the
1170
- // page the visitor arrived on, and every entry-page rule would match an asset.
1171
- const entryValue = entryPageCookieValue(req.nextUrl.href, {
1172
- cookieHeader,
1173
- secFetchDest: req.headers.get('sec-fetch-dest'),
1174
- accept: req.headers.get('accept'),
1175
- ...gate,
1176
- })
1177
- if (entryValue) {
1178
- res.cookies.set(ENTRY_COOKIE, entryValue, { path: '/', sameSite: 'lax' })
1179
- }
1180
- return res
1181
- }
1182
-
1183
- async function fetchFreshAccessToken(origin: string, refreshToken: string): Promise<string | undefined> {
1184
- try {
1185
- const res = await fetch(origin + '/api/v1/auth/refresh', {
1186
- method: 'POST',
1187
- headers: { 'content-type': 'application/json' },
1188
- body: JSON.stringify({ refreshToken }),
1189
- })
1190
- if (!res.ok) return undefined
1191
- const body = (await res.json().catch(() => null)) as { data?: { accessToken?: unknown } } | null
1192
- const token = body?.data?.accessToken
1193
- return typeof token === 'string' && token.length > 0 ? token : undefined
1194
- } catch {
1195
- return undefined
1196
- }
1197
- }
1198
-
1199
- // Markdown content negotiation by URL suffix. Core serves markdown at
1200
- // \`/api/v1/ax/md/slug/<slug>\` and the AX layer enables it (ax.markdown), but the
1201
- // scaffold never wires the friendly \`/<path>.md\` URL the AX pitch (and the
1202
- // developer copy) names — so it 404s despite the feature being on. Rewrite any
1203
- // GET/HEAD for \`/<path>.md\` onto the core route so the named URL actually
1204
- // resolves. Nested paths keep their full slug (e.g. /docs/foo.md -> docs/foo).
1205
- // Never intercepts Next internals or the API — those never carry this suffix,
1206
- // but are excluded explicitly since the matcher's own exclusion is broad.
1207
- function handleMarkdownNegotiation(req: NextRequest): NextResponse | null {
1208
- const { pathname } = req.nextUrl
1209
- if (req.method !== 'GET' && req.method !== 'HEAD') return null
1210
- if (!pathname.endsWith('.md')) return null
1211
- if (pathname.startsWith('/api/') || pathname.startsWith('/_next/')) return null
1212
- const slug = pathname.replace(/^\\//, '').replace(/\\.md$/, '')
1213
- if (!slug) return null
1214
- const url = req.nextUrl.clone()
1215
- url.pathname = '/api/v1/ax/md/slug/' + slug
1216
- return NextResponse.rewrite(url)
1217
- }
1218
-
1219
- export async function middleware(req: NextRequest): Promise<NextResponse> {
1220
- const { pathname } = req.nextUrl
1221
- if (isAuthEndpoint(pathname)) return NextResponse.next()
1222
-
1223
- const md = handleMarkdownNegotiation(req)
1224
- if (md) return md
1225
-
1226
- // Public pages are never auth-gated — just give them a stable visitor id.
1227
- const isAdmin = pathname === '/admin' || pathname.startsWith('/admin/')
1228
- const isApi = pathname.startsWith('/api/v1/')
1229
- if (!isAdmin && !isApi) return handlePublicRequest(req)
1230
-
1231
- const accessToken = req.cookies.get(ACCESS_COOKIE)?.value
1232
- const refreshToken = req.cookies.get(REFRESH_COOKIE)?.value
1233
- const status = await classifyAccessToken(accessToken, AUTH_SECRET)
1234
-
1235
- let freshAccess: string | undefined
1236
- if (status !== 'valid' && refreshToken) {
1237
- freshAccess = await fetchFreshAccessToken(req.nextUrl.origin, refreshToken)
1238
- }
1239
- const usableToken = status === 'valid' ? accessToken : freshAccess
1240
-
1241
- // /api/v1/*: bridge cookie → Authorization header.
1242
- if (pathname.startsWith('/api/v1/')) {
1243
- if (usableToken && !req.headers.get('authorization')) {
1244
- const headers = new Headers(req.headers)
1245
- headers.set('authorization', 'Bearer ' + usableToken)
1246
- const res = NextResponse.next({ request: { headers } })
1247
- if (freshAccess) setAccessCookie(res, freshAccess)
1248
- return res
1249
- }
1250
- const res = NextResponse.next()
1251
- if (status !== 'valid' && refreshToken && !freshAccess) clearSessionCookies(res)
1252
- return res
1253
- }
1254
-
1255
- // /admin/*: server-side gate. Login must stay reachable while anonymous.
1256
- if (pathname === '/admin/login') return NextResponse.next()
1257
- if (status === 'valid') return NextResponse.next()
1258
- if (freshAccess) {
1259
- const res = NextResponse.next()
1260
- setAccessCookie(res, freshAccess)
1261
- return res
1262
- }
1263
-
1264
- const loginUrl = req.nextUrl.clone()
1265
- loginUrl.pathname = '/admin/login'
1266
- loginUrl.search = ''
1267
- loginUrl.searchParams.set('next', pathname + req.nextUrl.search)
1268
- const res = NextResponse.redirect(loginUrl)
1269
- clearSessionCookies(res)
1270
- return res
1271
- }
1272
-
1273
- export const config = {
1274
- matcher: [
1275
- '/admin/:path*',
1276
- '/api/v1/:path*',
1277
- // Public pages, for the visitor-id cookie above. Excludes Next internals and
1278
- // any path with a file extension (static assets, /favicon.ico, /kywi.js, and
1279
- // the AX files /robots.txt, /sitemap.xml, /llms*.txt).
1280
- '/((?!_next/|.*\\\\..*).*)',
1281
- // \`/<path>.md\` — the markdown-negotiation URL. The extension-excluding
1282
- // pattern above skips it, so it needs its own entry to reach
1283
- // handleMarkdownNegotiation.
1284
- '/((?!_next/|api/).*\\\\.md)',
1285
- ],
1286
- }
604
+ function middleware() {
605
+ return `${renderStamp()}
606
+ // Auth gate, session refresh, the cookie→bearer bridge, the visitor/UTM/entry
607
+ // cookies and .md content negotiation — all core's, all upgraded with the
608
+ // package. Imported from its OWN entry, never the \`/next\` barrel: that barrel
609
+ // reaches the database and sharp, which no edge bundle can carry.
610
+ export { middleware, config } from '@kywi-software/core/next/middleware'
1287
611
  `
1288
612
  }
1289
613
 
@@ -1353,102 +677,42 @@ export function SiteNav({ items, variant, ariaLabel, depth }: SiteNavProps) {
1353
677
  `
1354
678
  }
1355
679
 
1356
- /** @param {Answers} a — coupled public site shell. */
680
+ /**
681
+ * The public site shell — a thin delegate over `createSiteLayout`.
682
+ *
683
+ * Three things stay in the generated file, and only three:
684
+ *
685
+ * 1. **Both CSS imports.** Next admits a global stylesheet only from a file
686
+ * inside `app/`, and a published package cannot import the project's own
687
+ * `site.css` at all (`layered-architecture DESIGN.md` §4.1).
688
+ * 2. **The brand label**, because the scaffold bakes the project name in.
689
+ * Core defaults it to the ACTIVE site's configured `name`; passing it
690
+ * explicitly preserves what the template has always emitted.
691
+ * 3. **The nav component**, wired explicitly to `components/site-nav.tsx`.
692
+ * Core ships an identical default, so this line is redundant at runtime —
693
+ * it is here so the customization point is VISIBLE in the generated code:
694
+ * a project restyling its header edits that file and nothing else.
695
+ * @param {Answers} a
696
+ */
1357
697
  function siteLayout(a) {
1358
- return `import React from 'react'
1359
- import { headers } from 'next/headers'
1360
- import { themeTokenStyleBlock } from '@kywi-software/core/layout'
1361
- import { navTreeToMenuItems, normalizePath } from '@kywi-software/core/nav'
1362
- // Neutral, token-driven defaults for everything Kywi renders (prose, the layout
1363
- // grid + modules, forms, the edit overlay). Restyle via theme tokens, not by
1364
- // editing this — see README → "Theming".
698
+ return `${renderStamp()}
699
+ // The public site shell — header, footer, data-driven nav and the active site's
700
+ // theme tokens, all resolved per request. It lives in @kywi-software/core/next;
701
+ // this file wires in THIS app's runtime, brand and nav component, and imports
702
+ // the stylesheets (Next takes a global stylesheet only from a file in app/, and
703
+ // a package cannot import yours). Restyle via site.css, theme tokens
704
+ // (kywi.config.ts) and components/site-nav.tsx — not by editing this file.
705
+ import { createSiteLayout } from '@kywi-software/core/next'
1365
706
  import '@kywi-software/core/site/styles.css'
1366
- // This app's OWN chrome (header / footer / page wrapper). Yours to edit freely.
1367
707
  import './site.css'
1368
- import config from '../../kywi.config'
1369
- import { getKywi } from '../../lib/kywi'
708
+ import { kywi } from '../../lib/kywi'
1370
709
  import { SiteNav } from '../../components/site-nav'
1371
- import { KywiJsLoader } from '../../components/kywi-js-loader'
1372
710
 
1373
- /**
1374
- * Public site shell. Header + footer carry this project's brand; edit them (and
1375
- * site.css) freely — this is your app's own layer over the DB-backed content
1376
- * that ${'app/(site)/[[...slug]]'} renders.
1377
- *
1378
- * The header/footer nav is DATA-DRIVEN, never hand-typed: it renders the
1379
- * owner-managed \`'main'\` menu (Admin → Menus) when one exists, falling back to
1380
- * the published Site Tree (\`isNav\` pages) so a fresh site has working nav out
1381
- * of the box — and the footer renders a \`'footer'\` menu when one exists. NEVER
1382
- * hardcode this list: a menu item's href resolves from its linked page and
1383
- * can never drift the way a pasted URL can (see AGENTS.md → Navigation).
1384
- * Rendered through core's shared nav renderer via the generated
1385
- * \`components/site-nav.tsx\` (a \`'use client'\` wrapper — see that file for why)
1386
- * rather than bespoke markup, so it gets the same CSS and \`@kywi-software/js\`
1387
- * enhancement as a \`navigation\` module placed in the page body.
1388
- *
1389
- * The active site's theme tokens (kywi.config.ts → themes[].tokens) are flattened
1390
- * into a \`:root { --kywi-* }\` block by \`themeTokenStyleBlock\` and injected below,
1391
- * so kywi.config.ts is the single source of truth for the palette and spacing and
1392
- * both site.css and core's default styles resolve against those variables.
1393
- */
1394
- const siteTheme =
1395
- config.themes.find((t) => t.name === config.sites[0]?.theme) ?? config.themes[0]
1396
- const themeVars = themeTokenStyleBlock(siteTheme?.tokens)
711
+ const layout = createSiteLayout({ runtime: kywi, brand: { label: '${escapeJsString(a.projectName)}' }, nav: SiteNav })
1397
712
 
1398
- // Content edits (a renamed page, a reordered menu) must show up without a
1399
- // restart — same reasoning as the page route.
713
+ export default layout.default
714
+ // A LITERAL — same reason as the page route below it in the tree.
1400
715
  export const dynamic = 'force-dynamic'
1401
-
1402
- export default async function SiteLayout({ children }: { children: React.ReactNode }) {
1403
- const { scope, siteId } = await getKywi()
1404
-
1405
- // The middleware forwards the full request URL as \`x-kywi-url\` (see
1406
- // middleware.ts) so this shared layout can resolve which page is current —
1407
- // without it, isActive/isAncestor on nav items would have nothing to match.
1408
- const h = await headers()
1409
- const url = h.get('x-kywi-url')
1410
- const currentPath = normalizePath(url ? new URL(url).pathname : '/')
1411
-
1412
- const mainMenu = await scope.menus.getResolved(siteId, 'main', currentPath)
1413
- const headerItems = mainMenu ?? navTreeToMenuItems(await scope.nav.getTree(siteId, currentPath))
1414
- const footerItems = await scope.menus.getResolved(siteId, 'footer', currentPath)
1415
-
1416
- return (
1417
- <div className="site-shell">
1418
- {themeVars ? <style dangerouslySetInnerHTML={{ __html: themeVars }} /> : null}
1419
- {/* /kywi.js for the nav's hover-intent/keyboard/viewport-flip enhancement
1420
- (kywi-cms#114) — independent of personalization, gated on the exact
1421
- same condition <SiteNav> renders on below (a non-empty header menu),
1422
- so it loads whenever a \`[data-kywi-nav]\` root actually exists. Shares
1423
- its dedup with <PersonalizationRuntime> (lib/kywi-js-loader.ts), so a
1424
- page with both nav AND the client runtime enabled loads it once. */}
1425
- {headerItems.length > 0 && <KywiJsLoader />}
1426
-
1427
- <header className="site-header">
1428
- <div className="site-header__inner">
1429
- <a className="site-brand" href="/">${escapeJsxText(a.projectName)}</a>
1430
- <div className="site-nav">
1431
- {headerItems.length > 0 && (
1432
- <SiteNav items={headerItems} variant="horizontal" ariaLabel="Primary" depth={2} />
1433
- )}
1434
- <a className="site-nav__admin" href="/admin">Admin →</a>
1435
- </div>
1436
- </div>
1437
- </header>
1438
-
1439
- <main className="site-main">{children}</main>
1440
-
1441
- <footer className="site-footer">
1442
- <div className="site-footer__inner">
1443
- {footerItems && footerItems.length > 0 && (
1444
- <SiteNav items={footerItems} variant="footer" ariaLabel="Footer" />
1445
- )}
1446
- <p className="site-footer__credit">Powered by <a href="https://kywi.dev">Kywi CMS</a></p>
1447
- </div>
1448
- </footer>
1449
- </div>
1450
- )
1451
- }
1452
716
  `
1453
717
  }
1454
718
 
@@ -1554,755 +818,38 @@ function siteStyles(a) {
1554
818
  `
1555
819
  }
1556
820
 
1557
- /** Coupled catch-all: renders home ("/") and any published page at its path. */
1558
- function siteSlugPage() {
1559
- return `import React, { cache } from 'react'
1560
- import type { Metadata } from 'next'
1561
- import { notFound } from 'next/navigation'
1562
- import { cookies, headers } from 'next/headers'
1563
- import { KywiBody, KywiEditableAttribute, KywiEditableRegion } from '@kywi-software/core/scope-client'
1564
- import {
1565
- KywiLayout,
1566
- KywiRegion,
1567
- AudienceMetaTags,
1568
- hydrateLayoutFeeds,
1569
- hydrateLayoutNav,
1570
- pruneLayoutToServedArms,
1571
- type LayoutDocument,
1572
- } from '@kywi-software/core/layout'
1573
- import { KywiJsonLd } from '@kywi-software/core/scope'
1574
- import { ACCESS_COOKIE, canAccessContent, readSessionClaims } from '@kywi-software/core/host'
1575
- import config from '../../../lib/config'
1576
- import { getKywi } from '../../../lib/kywi'
1577
- import {
1578
- resolvePublicContent,
1579
- resolvePersonalization,
1580
- personalizeLayout,
1581
- resolveSelfIdWidget,
1582
- clientRuntimeEnabled,
1583
- transparencyNoticeEnabled,
1584
- requireConsentEnabled,
1585
- buildFeedResolver,
1586
- resolveLayoutComponents,
1587
- localeAlternates,
1588
- mediaUrl,
1589
- requestBaseUrl,
1590
- } from '../../../lib/site'
1591
- import { moduleComponents } from '../../../lib/modules'
1592
- import { PersonalizationRuntime } from '../../../components/personalization-runtime'
1593
- import { KywiFrontEdit } from '../kywi-front-edit'
1594
-
1595
- // Every page comes from the database, so this route is always dynamic.
1596
- export const dynamic = 'force-dynamic'
1597
-
1598
- type Params = { params: Promise<{ slug?: string[] }> }
1599
- type Search = { searchParams: Promise<Record<string, string | string[] | undefined>> }
1600
-
1601
- // One resolution shared by generateMetadata and the page render (React.cache
1602
- // dedupes within a request). Keyed on stable primitives so the two calls dedupe:
1603
- // the slug segments joined (segments never contain "/") and the Accept-Language
1604
- // header, which selects the locale variant.
1605
- const getNode = cache((key: string, acceptLanguage: string | null) =>
1606
- resolvePublicContent(key ? key.split('/') : [], { acceptLanguage }),
1607
- )
1608
-
1609
- // SEO: map the admin's SEO-tab fields — Meta Title / Description / Keywords, the
1610
- // OG Image (metaImageId, falling back to the page's Featured Image), Canonical,
1611
- // Robots index/follow, and (on a multi-locale site) hreflang alternates — onto
1612
- // Next's Metadata. The sitemap-only fields (changeFreq, sitemapPriority) are not
1613
- // <head> metadata.
1614
- export async function generateMetadata({ params }: Params): Promise<Metadata> {
1615
- const { slug } = await params
1616
- const acceptLanguage = (await headers()).get('accept-language')
1617
- const node = await getNode((slug ?? []).join('/'), acceptLanguage)
1618
- if (!node || node['status'] !== 'published') return {}
1619
-
1620
- const title = (node['metaTitle'] as string) || String(node['title'] ?? 'Untitled')
1621
- const description = (node['metaDescription'] as string) || undefined
1622
- const keywords = (node['metaKeywords'] as string) || undefined
1623
- const image = mediaUrl(node['metaImageId']) ?? mediaUrl(node['featuredImageId'])
1624
- const images = image ? [image] : undefined
1625
- const canonical = (node['canonicalUrl'] as string) || undefined
1626
- const languages = localeAlternates(slug ?? [])
1627
- const alternates =
1628
- canonical || languages
1629
- ? { ...(canonical ? { canonical } : {}), ...(languages ? { languages } : {}) }
1630
- : undefined
1631
-
1632
- return {
1633
- title,
1634
- description,
1635
- keywords,
1636
- ...(alternates ? { alternates } : {}),
1637
- robots: { index: node['robotsIndex'] !== 'noindex', follow: node['robotsFollow'] !== 'nofollow' },
1638
- openGraph: { title, description, images, type: 'website' },
1639
- twitter: { card: images ? 'summary_large_image' : 'summary', title, description, images },
1640
- }
1641
- }
1642
-
1643
- // True once the Layout editor has placed at least one section into any region.
1644
- // A page with an empty layout document falls back to its Body rich text.
1645
- function hasRenderableLayout(layout: LayoutDocument | null | undefined): layout is LayoutDocument {
1646
- if (!layout || typeof layout !== 'object' || !layout.regions) return false
1647
- return Object.values(layout.regions).some(
1648
- (sections) => Array.isArray(sections) && sections.length > 0,
1649
- )
1650
- }
1651
-
1652
- // Is the current visitor a signed-in admin who may edit? The public route is
1653
- // outside the middleware's auth matcher, so verify the httpOnly session cookie
1654
- // here and derive the content permissions the overlay needs. Returns null for
1655
- // anyone who cannot edit — no edit DOM (not even the browse-mode toolbar) is
1656
- // emitted for them. Called on EVERY request (not just ?kywi-edit=1 ones) so the
1657
- // toolbar can surface itself for a signed-in admin who is just browsing — this
1658
- // is a cookie read + JWT verify, no DB round-trip, so the cost on an anonymous
1659
- // visitor's fast path is a fast null return (\`readSessionClaims\` bails
1660
- // immediately when there's no cookie).
1661
- async function resolveEditPermissions() {
1662
- const token = (await cookies()).get(ACCESS_COOKIE)?.value
1663
- const claims = await readSessionClaims(token, config.auth.secret)
1664
- if (!claims || !canAccessContent(claims.role, 'write')) return null
1665
- return { canEdit: true, canPublish: canAccessContent(claims.role, 'publish'), role: claims.role }
1666
- }
1667
-
1668
- // "/" resolves the seeded Home node; any other URL resolves the published node at
1669
- // that FULL Site Tree path (locale-aware), so /a/b/<slug> cannot serve a
1670
- // top-level <slug> (#30, #51). Draft / missing content 404s.
1671
- export default async function PublicPage({ params, searchParams }: Params & Search) {
1672
- const { slug } = await params
1673
- const acceptLanguage = (await headers()).get('accept-language')
1674
- const node = await getNode((slug ?? []).join('/'), acceptLanguage)
1675
- if (!node || node['status'] !== 'published') notFound()
1676
-
1677
- const runtime = await getKywi()
1678
- const contentId = String(node['id'] ?? '')
1679
- const contentType = String(node['contentTypeName'] ?? 'page')
1680
- const title = String(node['title'] ?? 'Untitled')
1681
- const body = (node['body'] as string) || ''
1682
- const featured = mediaUrl(node['featuredImageId'])
1683
- const layout = node['layout'] as LayoutDocument | null | undefined
1684
-
1685
- // Resolved on every request so a signed-in admin gets the persistent browse
1686
- // toolbar even when just browsing — ?kywi-edit=1 only decides whether the
1687
- // page auto-enters the full overlay editor on mount (kywi-cms#93). An
1688
- // anonymous/read-only visitor resolves to perms === null: zero extra client
1689
- // JS, identical output to before this route ever heard of the overlay.
1690
- const editRequested = (await searchParams)['kywi-edit'] === '1'
1691
- const perms = await resolveEditPermissions()
1692
-
1693
- // Server-side personalization (#50), evaluated once: the winning audience (or a
1694
- // kywi_preview_init preview) drives page variants + variantContainers, and this
1695
- // visitor's A/B arms are assigned deterministically. Opted-out / anonymous
1696
- // visitors resolve to the default experience.
1697
- const perso = await resolvePersonalization(runtime, layout)
1698
- const selfIdWidget = await resolveSelfIdWidget(runtime)
1699
-
1700
- // <head> injections (React hoists these): per-page JSON-LD gated on the
1701
- // AX/JSON-LD setting, mapping the SAME node so page and schema agree (#56); the
1702
- // Comments module's content-id anchor (#29); the audience/visitor ids for
1703
- // client tooling (#50); and — when the theme opts in — the optional client
1704
- // runtime that mounts the self-ID widget and re-evaluates audiences.
1705
- //
1706
- // \`head\` is a FUNCTION because JSON-LD must describe the layout THIS visitor
1707
- // is served (#195): the served document only exists after the arms are pruned
1708
- // below, and building the description from the STORED layout would ship the
1709
- // DEFAULT arm's copy to a matched visitor — the #167 leak, through structured
1710
- // data. The no-layout branch passes nothing, and JSON-LD then describes the
1711
- // node's body/metaDescription/summary alone.
1712
- const baseUrl = await requestBaseUrl()
1713
- const headFor = (servedLayout?: unknown) => (
1714
- <>
1715
- <meta name="kywi:content-id" content={contentId} />
1716
- <KywiJsonLd
1717
- node={node}
1718
- config={runtime.config}
1719
- baseUrl={baseUrl}
1720
- siteId={runtime.siteId}
1721
- {...(servedLayout ? { layout: servedLayout } : {})}
1722
- />
1723
- <AudienceMetaTags audienceId={perso.audienceId} visitorId={perso.visitorId} />
1724
- {clientRuntimeEnabled() && perso.audiences.length > 0 ? (
1725
- <PersonalizationRuntime
1726
- audiences={perso.audiences}
1727
- serverSignals={perso.signals}
1728
- selfIdWidget={selfIdWidget}
1729
- transparencyNotice={transparencyNoticeEnabled()}
1730
- requireConsent={requireConsentEnabled()}
1731
- />
1732
- ) : null}
1733
- </>
1734
- )
1735
-
1736
- // When the Layout tab has designed the page, render its region-based document
1737
- // through core's own layout renderer (KywiLayout / KywiRegion). Before that:
1738
- // hydrate every Feed Display module (#28), resolve connected reusable
1739
- // components (#46), and supply the custom-module renderers (#48). Otherwise
1740
- // fall back to the built-in Body rich text (+ any Featured Image).
1741
- let content: React.ReactNode
1742
- if (hasRenderableLayout(layout)) {
1743
- // Apply the audience's page variant (and strip the other variants), then
1744
- // hydrate feeds and resolve components on the layout THIS visitor sees.
1745
- // variantContainer arms resolve at render time from \`personalization\`.
1746
- const personalized = personalizeLayout(layout, perso.audienceId)
1747
- const feedsHydrated = await hydrateLayoutFeeds(personalized, buildFeedResolver(runtime))
1748
- // Resolve every navMenu/navigation/siteMap module placed in THIS layout
1749
- // (menuSlug → a real menu; the tree otherwise) — the same seam as feeds,
1750
- // so a nav module dropped into any region/section just works (#112).
1751
- const currentPath = String(node['path'] ?? '/')
1752
- const navHydrated = await hydrateLayoutNav(
1753
- feedsHydrated,
1754
- runtime.scope.menus.createHydrationResolver(runtime.siteId, currentPath),
1755
- )
1756
- // Linked components resolve HERE, on the server, into the document itself:
1757
- // KywiLayout is a client component and the renderer's resolver props are
1758
- // functions, which cannot cross the RSC boundary (#147).
1759
- const hydrated = await resolveLayoutComponents(navHydrated, runtime)
1760
- // LAST pass before the client boundary (#167): keep ONLY the arm this
1761
- // visitor is served in every variantContainer / moduleVariantContainer.
1762
- // KywiLayout is a client component, so anything still on the document here
1763
- // is serialized into the page's RSC flight payload — arm SELECTION was
1764
- // always server-side, but the losing arms crossed with it and were readable
1765
- // in view-source. Pruning uses the same \`perso.personalization\` the
1766
- // renderer is handed, so the markup is unchanged.
1767
- //
1768
- // Order matters: after component resolution (a container that is only a
1769
- // \`componentId\` has no arms of its own to prune yet) and after the feed/nav
1770
- // hydration passes. The front-edit overlay below is given the STORED
1771
- // \`layout\`, never this — an editor needs every arm.
1772
- const served = pruneLayoutToServedArms(hydrated, perso.personalization)
1773
- content = (
1774
- <article className="page page--layout" data-kywi-content-id={contentId}>
1775
- {headFor(served)}
1776
- <KywiLayout
1777
- layout={served}
1778
- personalization={perso.personalization}
1779
- moduleComponents={moduleComponents}
1780
- >
1781
- {Object.keys(served.regions).map((name) => (
1782
- <KywiRegion key={name} name={name} />
1783
- ))}
1784
- </KywiLayout>
1785
- </article>
1786
- )
1787
- } else {
1788
- content = (
1789
- <article className="page" data-kywi-content-id={contentId}>
1790
- {headFor()}
1791
- {featured ? <img className="page__featured" src={featured} alt="" /> : null}
1792
- <h1 className="page__title">{title}</h1>
1793
- {perms ? (
1794
- <KywiEditableRegion region="main">
1795
- <KywiEditableAttribute field="body" type="richtext" contentId={contentId}>
1796
- {body ? <KywiBody content={body} /> : <p className="page__empty">This page has no content yet.</p>}
1797
- </KywiEditableAttribute>
1798
- </KywiEditableRegion>
1799
- ) : body ? (
1800
- <KywiBody content={body} />
1801
- ) : (
1802
- <p className="page__empty">This page has no content yet.</p>
1803
- )}
1804
- </article>
1805
- )
1806
- }
1807
-
1808
- // Mount the overlay (client) for any authenticated admin (write role+) —
1809
- // browse mode renders just the slim toolbar; ?kywi-edit=1 (editRequested)
1810
- // additionally auto-starts the full overlay editor. Nothing mounts for an
1811
- // anonymous or read-only visitor.
1812
- if (perms) {
1813
- return (
1814
- <KywiFrontEdit
1815
- canEdit={perms.canEdit}
1816
- canPublish={perms.canPublish}
1817
- editRequested={editRequested}
1818
- contentId={contentId}
1819
- contentType={contentType}
1820
- pageTitle={title}
1821
- pageStatus={String(node['status'] ?? '')}
1822
- initialLayout={layout ?? { regions: { main: [] } }}
1823
- adminHref={\`/admin/content/\${contentType}/\${contentId}\`}
1824
- moduleComponents={moduleComponents}
1825
- hostModules={config.modules ?? []}
1826
- >
1827
- {content}
1828
- </KywiFrontEdit>
1829
- )
1830
- }
1831
-
1832
- return content
1833
- }
1834
- `
1835
- }
1836
-
1837
- /**
1838
- * Front-of-site edit overlay (client). Mounted by the public page for ANY
1839
- * authenticated admin (write role+) — the page verifies the session cookie
1840
- * server-side first (resolveEditPermissions, called unconditionally) and only
1841
- * mounts this when that check passes, so canEdit is always true here.
1842
- *
1843
- * Two modes, same component:
1844
- * - **Browse** (default) — core's slim `KywiEditToolbar`, a persistent
1845
- * WordPress-admin-bar-style strip over the live page (page title, draft/
1846
- * published status, "Edit this page", "Go to admin") — this is the
1847
- * discoverability fix (kywi-cms#93): an admin browsing the public site
1848
- * normally, with no query param, now sees the entry point. The editable
1849
- * regions are also outlined via the `data-kywi-*` protocol.
1850
- * - **Edit** (the toolbar's "Edit this page", or landing with
1851
- * `?kywi-edit=1`) — the FULL `OverlayShell` layout editor, the same one the
1852
- * admin app mounts, rendered from the same core module registry + custom
1853
- * renderers. Save PUTs the layout document; Publish PUTs the layout then
1854
- * flips status — exactly the API the admin editor uses. Entering/leaving
1855
- * edit mode keeps `?kywi-edit=1` in sync via history.replaceState, so a
1856
- * reload (or a shared link) lands back in the same mode.
1857
- *
1858
- * The OverlayShell is lazy-loaded (`next/dynamic`, client-only): its weight
1859
- * (dnd-kit, canvas, side panels) is only fetched when an editor actually enters
1860
- * edit mode, so the public browse bundle stays light.
1861
- */
1862
- function frontEditOverlay() {
1863
- return `'use client'
1864
- import React from 'react'
1865
- import dynamic from 'next/dynamic'
1866
- import { KywiEditToolbar, useKywiEditMode } from '@kywi-software/core/scope-client'
1867
- import { adminFetch } from '@kywi-software/core/host-client'
1868
- import {
1869
- createModuleRegistry,
1870
- createThemeRegistry,
1871
- BUILT_IN_MODULE_COMPONENTS,
1872
- type LayoutDocument,
1873
- type ModuleComponentMap,
1874
- type ModuleConfig,
1875
- } from '@kywi-software/core/layout'
1876
- import type { SaveAction } from '@kywi-software/core/admin'
1877
-
1878
821
  /**
1879
- * Injects the admin design system's stylesheet as a plain \`<link>\` tag rather
1880
- * than an ES \`import\` (kywi-cms#130 follow-up). A dynamic \`import('…/admin/
1881
- * styles.css')\` still shipped the stylesheet on every public route that
1882
- * reaches this factory, regardless of the \`next/dynamic\` wrapping around
1883
- * it. A \`<link>\` created imperatively at runtime has no \`import\` statement
1884
- * for Next's CSS collector to see, so it never enters the build's CSS graph.
822
+ * The public catch-all — a thin delegate over `createPublicPage`.
1885
823
  *
1886
- * VERIFIED (in the reference app this template mirrors) against a real
1887
- * \`next build && next start\`: an anonymous fetch of a real published content
1888
- * page shows zero OverlayShell/editor.css/admin.css references in any of its
1889
- * fetched resources. Fetching the SAME page against \`next dev\` still shows
1890
- * every one of those markers regardless of this fix — \`next dev\` bundles far
1891
- * more eagerly than a production build, for ALL routes, independent of any
1892
- * dynamic()/runtime-injection technique. A generated app's own e2e coverage
1893
- * of this (if any) needs to build for real, not assert against \`next dev\`.
824
+ * What used to be 279 emitted lines (content resolution, SEO metadata + locale
825
+ * alternates, JSON-LD over the served document, audience meta tags, server-side
826
+ * personalization and A/B assignment, the self-ID widget, arm pruning, the
827
+ * front-edit overlay gate) is `@kywi-software/core/next`. The generated file
828
+ * supplies the two project-specific inputs `layered-architecture DESIGN.md` §2
829
+ * counted — the wiring runtime and this app's custom module renderers.
1894
830
  *
1895
- * Two follow-ups from review:
1896
- * - CACHE-BUSTING: \`/kywi-admin.css\` and \`/kywi-admin.css.version\` are both
1897
- * synced from the installed \`@kywi-software/core\` package
1898
- * (scripts/sync-kywi-admin-css.mjs, run via package.json's
1899
- * \`sync:kywi-admin-css\` on every \`predev\`/\`prebuild\`) — mirroring how
1900
- * \`public/kywi.js\` is synced from \`@kywi-software/js\`. The version
1901
- * file's content becomes the \`?v=\` query string below, so a core upgrade
1902
- * that changes the stylesheet always serves a URL a CDN/browser has never
1903
- * cached, instead of a stale \`/kywi-admin.css\` response.
1904
- * - FOUC: returns a Promise that resolves once the tag has actually loaded
1905
- * (or failed to) — an existing, already-loaded tag resolves immediately;
1906
- * a fresh or still-loading one resolves on its \`load\`/\`error\` event. The
1907
- * dynamic() factory below awaits this ALONGSIDE the OverlayShell JS
1908
- * import, so the editor's first paint never races its own stylesheet.
831
+ * `dynamic` is written as a LITERAL on purpose: Next parses route segment
832
+ * config statically and fails the build on `export const dynamic = page.dynamic`
833
+ * ("Unsupported node type MemberExpression"). Core types the field as the
834
+ * literal `'force-dynamic'` so the two still cannot drift.
1909
835
  */
1910
- function ensureAdminStylesheet(): Promise<void> {
1911
- if (typeof document === 'undefined') return Promise.resolve()
1912
- const existing = document.querySelector<HTMLLinkElement>('link[data-kywi-admin-css]')
1913
- if (existing) {
1914
- // \`.sheet\` is non-null only once the stylesheet has actually parsed —
1915
- // resolve immediately rather than re-attaching listeners to an event
1916
- // that already fired.
1917
- if (existing.sheet) return Promise.resolve()
1918
- return new Promise((resolve) => {
1919
- existing.addEventListener('load', () => resolve(), { once: true })
1920
- existing.addEventListener('error', () => resolve(), { once: true })
1921
- })
1922
- }
1923
- return fetch('/kywi-admin.css.version')
1924
- .then((res) => (res.ok ? res.text() : ''))
1925
- .catch(() => '')
1926
- .then(
1927
- (version) =>
1928
- new Promise<void>((resolve) => {
1929
- const link = document.createElement('link')
1930
- link.rel = 'stylesheet'
1931
- const v = version.trim()
1932
- link.href = v ? \`/kywi-admin.css?v=\${encodeURIComponent(v)}\` : '/kywi-admin.css'
1933
- link.dataset.kywiAdminCss = 'true'
1934
- // A load OR error either way lets the caller proceed — a stylesheet
1935
- // that 404s must never permanently block the editor from opening.
1936
- link.addEventListener('load', () => resolve(), { once: true })
1937
- link.addEventListener('error', () => resolve(), { once: true })
1938
- document.head.appendChild(link)
1939
- }),
1940
- )
1941
- }
1942
-
1943
- // Lazy-load the full layout editor bundle: the shell's JS (dnd-kit, canvas,
1944
- // panels) is pulled INSIDE this factory, so it enters the module graph only
1945
- // when an editor opens the overlay — never in the public browse bundle a
1946
- // visitor downloads. The stylesheet is handled separately — see
1947
- // \`ensureAdminStylesheet\` above — since a css import here, even a dynamic
1948
- // one, does not behave like the JS import right below it. Both are awaited
1949
- // together so the editor never paints ahead of its own stylesheet (FOUC).
1950
- const OverlayShell = dynamic(
1951
- async () => {
1952
- const [, mod] = await Promise.all([
1953
- ensureAdminStylesheet(),
1954
- import('@kywi-software/core/admin'),
1955
- ])
1956
- return mod.OverlayShell
1957
- },
1958
- { ssr: false },
1959
- )
1960
-
1961
- export interface KywiFrontEditProps {
1962
- canEdit: boolean
1963
- canPublish: boolean
1964
- /** True when the page was requested with \`?kywi-edit=1\` — auto-starts the full overlay editor on mount. */
1965
- editRequested: boolean
1966
- contentId: string
1967
- contentType: string
1968
- pageTitle: string
1969
- pageStatus: string
1970
- /** The page's saved layout document (empty regions when it has none yet). */
1971
- initialLayout: LayoutDocument
1972
- /** Deep link to this page in the full admin editor. */
1973
- adminHref: string
1974
- /** Custom (defineModule) renderers, shared with the public layout + admin (#48). */
1975
- moduleComponents?: ModuleComponentMap
1976
- /** Custom (defineModule) module CONFIGS from kywi.config.ts — gives the overlay
1977
- * editor each custom module's prop definitions, so the props rail and inline
1978
- * text editing work for them exactly as in the admin editor (kywi-cms#118). */
1979
- hostModules?: ModuleConfig[]
1980
- children: React.ReactNode
1981
- }
1982
-
1983
- /**
1984
- * Wraps the public page with the front-of-site edit affordance. The page
1985
- * renders this for ANY signed-in admin (write role+) — canEdit is always true
1986
- * here, since the page only mounts KywiFrontEdit once resolveEditPermissions
1987
- * has already confirmed it server-side. Browse mode shows the persistent
1988
- * KywiEditToolbar (the primary discoverability fix, kywi-cms#93); the full
1989
- * OverlayShell editor only mounts once edit mode actually starts — either the
1990
- * toolbar's "Edit this page" button, or landing with \`?kywi-edit=1\`
1991
- * (editRequested), which auto-starts it once on mount.
1992
- */
1993
- export function KywiFrontEdit({
1994
- canEdit,
1995
- canPublish,
1996
- editRequested,
1997
- contentId,
1998
- contentType,
1999
- pageTitle,
2000
- pageStatus,
2001
- initialLayout,
2002
- adminHref,
2003
- moduleComponents = {},
2004
- hostModules = [],
2005
- children,
2006
- }: KywiFrontEditProps) {
2007
- const edit = useKywiEditMode({ canEdit, canPublish })
2008
-
2009
- // ?kywi-edit=1 (editRequested) means "enter edit mode now" — flip it on once
2010
- // after mount. Without it the page mounts straight into browse mode (the
2011
- // persistent toolbar), which is the common case now that KywiFrontEdit
2012
- // renders for every signed-in admin, not only deep-linked ones.
2013
- const { startEdit, endEdit } = edit
2014
- React.useEffect(() => {
2015
- if (editRequested) startEdit()
2016
- }, [editRequested, startEdit])
2017
-
2018
- // Registries + renderers for the editor: the module registry carries each
2019
- // module's prop definitions (built-ins + this app's kywi.config.ts modules —
2020
- // without the host list, custom modules render but are uneditable, kywi-cms#118),
2021
- // merged with the custom renderers from lib/modules (#48).
2022
- const moduleRegistry = React.useMemo(
2023
- () => createModuleRegistry(hostModules ?? []),
2024
- [hostModules],
2025
- )
2026
- const themeRegistry = React.useMemo(() => createThemeRegistry(), [])
2027
- const editorComponents = React.useMemo(
2028
- () => ({ ...BUILT_IN_MODULE_COMPONENTS, ...moduleComponents }),
2029
- [moduleComponents],
2030
- )
2031
-
2032
- // Persist the edited layout to the same content API the admin editor uses.
2033
- const persistLayout = React.useCallback(
2034
- async (next: LayoutDocument) => {
2035
- const res = await fetch(\`/api/v1/content/\${contentType}/\${contentId}/layout\`, {
2036
- method: 'PUT',
2037
- credentials: 'same-origin',
2038
- headers: { 'Content-Type': 'application/json' },
2039
- body: JSON.stringify(next),
2040
- })
2041
- return res.ok
2042
- },
2043
- [contentType, contentId],
2044
- )
2045
-
2046
- const handleSave = React.useCallback(
2047
- async (next: LayoutDocument, _action: SaveAction) => {
2048
- await persistLayout(next)
2049
- endEdit()
2050
- },
2051
- [persistLayout, endEdit],
2052
- )
2053
-
2054
- const handlePublish = React.useCallback(
2055
- async (next: LayoutDocument, _action: SaveAction) => {
2056
- const ok = await persistLayout(next)
2057
- if (ok) {
2058
- // Best-effort status flip; the layout itself is already persisted above.
2059
- await fetch(\`/api/v1/content/\${contentType}/\${contentId}\`, {
2060
- method: 'PUT',
2061
- credentials: 'same-origin',
2062
- headers: { 'Content-Type': 'application/json' },
2063
- body: JSON.stringify({ status: 'published' }),
2064
- }).catch(() => undefined)
2065
- }
2066
- endEdit()
2067
- },
2068
- [persistLayout, contentType, contentId, endEdit],
2069
- )
2070
-
2071
- // Browse-mode one-click publish from the toolbar (no editor needed).
2072
- const handleToolbarPublish = React.useCallback(async () => {
2073
- const res = await adminFetch(\`/api/v1/content/by-id/\${contentId}/publish\`, { method: 'POST' })
2074
- if (res.ok) window.location.reload()
2075
- }, [contentId])
2076
-
2077
- // Edit mode: the full layout editor, in place, over the live page.
2078
- // NOTE: no \`kywi-admin-shell\` here (kywi-cms#94). That class is the admin
2079
- // design system's base+reset — font family, font size, colours, heading
2080
- // resets — and wrapping the page in it re-typesets the very content the
2081
- // owner is trying to judge at real width. The editor chrome carries its own
2082
- // styling; the page keeps the site's.
2083
- if (edit.isEditMode && edit.canEdit) {
2084
- return (
2085
- <div className="kywi-frontend-edit">
2086
- <OverlayShell
2087
- editMode={edit}
2088
- initialLayout={initialLayout}
2089
- contentId={contentId}
2090
- contentType={contentType}
2091
- pageTitle={pageTitle}
2092
- /* The page's own body wrapper, so the site's page-level CSS (width,
2093
- gutters, rhythm) still applies while editing in place. */
2094
- pageClassName="page page--layout"
2095
- themeName="default"
2096
- themeRegistry={themeRegistry}
2097
- moduleRegistry={moduleRegistry}
2098
- moduleComponents={editorComponents}
2099
- onSave={handleSave}
2100
- onPublish={handlePublish}
2101
- />
2102
- </div>
2103
- )
2104
- }
2105
-
2106
- // Browse mode: slim toolbar + editable-region outlines over the live page.
2107
- return (
2108
- <>
2109
- <KywiEditToolbar
2110
- isEditMode={edit.isEditMode}
2111
- canEdit={edit.canEdit}
2112
- canPublish={edit.canPublish}
2113
- onToggleEdit={edit.toggleEdit}
2114
- onPublish={handleToolbarPublish}
2115
- pageTitle={pageTitle}
2116
- pageStatus={pageStatus}
2117
- adminHref={adminHref}
2118
- />
2119
- {/* Toggling this attribute drives the editable-region outlines shipped in
2120
- @kywi-software/core/site/styles.css. */}
2121
- <div data-kywi-editing={edit.isEditMode ? '' : undefined}>{children}</div>
2122
- </>
2123
- )
2124
- }
2125
- `
2126
- }
2127
-
2128
- /**
2129
- * Shared `/kywi.js` script-injection primitive (kywi-cms#114). Two independent
2130
- * triggers can each want the browser bundle on the page: the site layout's nav
2131
- * (`<KywiJsLoader>`, purely for the hover-intent/keyboard/viewport-flip
2132
- * enhancement `initNavMenus()` applies unconditionally once the script boots)
2133
- * and `<PersonalizationRuntime>` (which additionally waits on
2134
- * `window.Kywi.bootAudienceEngine`). Before this fix only the personalization
2135
- * runtime ever injected the tag, so a site with `personalization.clientRuntime`
2136
- * off — or simply no active audiences — never got the nav enhancement either,
2137
- * even though the nav renders fully without it (kywi-cms#111's JS-off contract)
2138
- * and would only ever gain from it. `ensureKywiJsScript` is idempotent:
2139
- * whichever caller mounts first creates the ONE `<script data-kywi-js>` tag,
2140
- * every other caller finds it already there — so both triggers being true at
2141
- * once never loads the bundle twice.
2142
- */
2143
- function kywiJsLoaderLib() {
2144
- return `export function ensureKywiJsScript(): HTMLScriptElement | null {
2145
- if (typeof document === 'undefined') return null
2146
- const existing = document.querySelector<HTMLScriptElement>('script[data-kywi-js]')
2147
- if (existing) return existing
2148
- const script = document.createElement('script')
2149
- script.src = '/kywi.js'
2150
- script.async = true
2151
- script.dataset.kywiJs = 'true'
2152
- document.body.appendChild(script)
2153
- return script
2154
- }
2155
- `
2156
- }
2157
-
2158
- /**
2159
- * Loads `/kywi.js` for its nav enhancement alone, independent of
2160
- * personalization (kywi-cms#114). Rendered by the site layout whenever the
2161
- * resolved header menu is non-empty — the same condition `<SiteNav>` itself
2162
- * gates on before it renders a `[data-kywi-nav]` root — so this mounts exactly
2163
- * when there is a nav for `initNavMenus()` to enhance. Does nothing beyond
2164
- * ensuring the script tag exists: `Kywi.boot()` runs `initNavMenus()`
2165
- * unconditionally as part of its own bootstrap once the script loads, and
2166
- * never touches personalization on its own.
2167
- */
2168
- function kywiJsLoaderComponent() {
2169
- return `'use client'
2170
-
2171
- import { useEffect } from 'react'
2172
- import { ensureKywiJsScript } from '../lib/kywi-js-loader'
2173
-
2174
- export function KywiJsLoader() {
2175
- useEffect(() => {
2176
- ensureKywiJsScript()
2177
- }, [])
2178
-
2179
- return null
2180
- }
2181
- `
2182
- }
2183
-
2184
- /**
2185
- * Client personalization runtime (kywi-cms#50). The SERVER already resolved and
2186
- * rendered the correct audience/experiment variant and set the ids in <head>;
2187
- * this optional client layer adds live re-evaluation, the self-ID widget, the
2188
- * transparency bar, and behavioral-signal collection by booting the
2189
- * @kywi-software/js browser bundle from /kywi.js. It is best-effort and mounted
2190
- * only when the theme opts in (theme.personalization.clientRuntime), so the site
2191
- * stays dependency-free by default: drop the built @kywi-software/js bundle at
2192
- * public/kywi.js to enable it (see README → "Personalization"). Without it the
2193
- * server-rendered variant is exactly what every visitor sees.
2194
- *
2195
- * kywi-cms#114: `/kywi.js` may already be on the page independent of this
2196
- * component — the site layout's `<KywiJsLoader>` loads it purely for nav
2197
- * enhancement whenever the site has a nav. `ensureKywiJsScript` (shared with
2198
- * that component, lib/kywi-js-loader.ts) is idempotent, so this never injects
2199
- * a second copy.
2200
- */
2201
- function personalizationRuntime() {
2202
- return `'use client'
2203
-
2204
- import React, { useEffect } from 'react'
2205
- import type { Audience, VisitorSignals } from '@kywi-software/core/audiences/types'
2206
- import type { PublicSelfIdWidget } from '../lib/site'
2207
- import { ensureKywiJsScript } from '../lib/kywi-js-loader'
2208
-
2209
- /* eslint-disable @typescript-eslint/no-explicit-any */
2210
- declare global {
2211
- interface Window {
2212
- Kywi?: any
2213
- kywi?: any
2214
- }
2215
- }
2216
-
2217
- interface PersonalizationRuntimeProps {
2218
- /** Active audiences the client re-evaluates (rule definitions, no PII). */
2219
- audiences: Audience[]
2220
- /** Server-resolved signals (UTM / referrer / identity / opt-out) for parity. */
2221
- serverSignals: Partial<VisitorSignals>
2222
- /** Resolved self-ID widget config; when present its trigger/frequency mount it. */
2223
- selfIdWidget?: PublicSelfIdWidget | null
2224
- /**
2225
- * \`personalization.transparencyNotice.enabled\` — false suppresses the
2226
- * automatic global transparency panel; the visitor can still open it
2227
- * deliberately through a \`personalizationBadge\` module. Defaults to true.
2228
- */
2229
- transparencyNotice?: boolean
2230
- /**
2231
- * \`personalization.requireConsent\` — false turns the client's consent gate
2232
- * off for a site that collects consent with external tooling. Defaults to
2233
- * true, so the resolved audience is only remembered for a consenting visitor
2234
- * (kywi-cms#91).
2235
- */
2236
- requireConsent?: boolean
2237
- }
2238
-
2239
- /**
2240
- * Ensures /kywi.js is on the page, then boots the audience data-layer.
2241
- * bootAudienceEngine reads the opt-out cookie itself and resolves opted-out
2242
- * visitors to the default experience, so opt-out is respected end to end.
2243
- * Rendered only when theme.personalization.clientRuntime is on.
2244
- *
2245
- * kywi-cms#114: the script tag may already be on the page independent of this
2246
- * component — the site layout's <KywiJsLoader> loads it purely for nav
2247
- * enhancement whenever the site has a nav. ensureKywiJsScript (shared with
2248
- * that component) is idempotent, so this never injects a second copy.
2249
- */
2250
- export function PersonalizationRuntime({ audiences, serverSignals, selfIdWidget, transparencyNotice, requireConsent }: PersonalizationRuntimeProps) {
2251
- useEffect(() => {
2252
- let cancelled = false
2253
- const ready = () => typeof window.Kywi?.bootAudienceEngine === 'function'
2254
-
2255
- function boot(): void {
2256
- if (cancelled || !ready()) return
2257
- const currentPath = window.location.pathname
2258
- window.Kywi
2259
- .bootAudienceEngine({
2260
- audiences,
2261
- currentPath,
2262
- serverSignals,
2263
- ...(selfIdWidget
2264
- ? { selfIdWidget: { ...selfIdWidget, audiences, currentPath } }
2265
- : {}),
2266
- ...(transparencyNotice === false ? { transparencyNotice: false } : {}),
2267
- ...(requireConsent === false ? { requireConsent: false } : {}),
2268
- })
2269
- .catch(() => {
2270
- /* client personalization is best-effort; the server already rendered defaults */
2271
- })
2272
- }
2273
-
2274
- function whenReady(): void {
2275
- if (ready()) return boot()
2276
- let tries = 0
2277
- const timer = setInterval(() => {
2278
- tries += 1
2279
- if (ready()) {
2280
- clearInterval(timer)
2281
- boot()
2282
- } else if (tries > 50) {
2283
- clearInterval(timer)
2284
- }
2285
- }, 60)
2286
- }
836
+ function siteSlugPage() {
837
+ return `${renderStamp()}
838
+ // The public catch-all: "/" and every published page at its Site Tree path.
839
+ // Content resolution, SEO metadata, JSON-LD, personalization + A/B assignment
840
+ // and the front-edit overlay all live in @kywi-software/core/next; this file
841
+ // wires in THIS app's runtime and its custom module renderers (lib/modules.tsx).
842
+ import { createPublicPage } from '@kywi-software/core/next'
843
+ import { kywi } from '../../../lib/kywi'
844
+ import { moduleComponents } from '../../../lib/modules'
2287
845
 
2288
- // kywi-cms#114: the script tag itself may already be on the page — the
2289
- // site layout's <KywiJsLoader> injects it independently, purely for nav
2290
- // enhancement, whenever the site has a nav. \`ensureKywiJsScript\` is the
2291
- // shared dedup primitive: it returns the existing tag if one is already
2292
- // there, so this never creates a second one.
2293
- const script = ensureKywiJsScript()
2294
- if (ready()) boot()
2295
- else script?.addEventListener('load', whenReady)
2296
-
2297
- return () => {
2298
- cancelled = true
2299
- }
2300
- // Boot once on mount; audiences/serverSignals are per-request stable.
2301
- // eslint-disable-next-line react-hooks/exhaustive-deps
2302
- }, [])
846
+ const page = createPublicPage({ runtime: kywi, modules: moduleComponents })
2303
847
 
2304
- return null
2305
- }
848
+ export default page.default
849
+ export const generateMetadata = page.generateMetadata
850
+ // A LITERAL, never \`page.dynamic\`: Next parses route segment config statically
851
+ // and fails the build on a MemberExpression.
852
+ export const dynamic = 'force-dynamic'
2306
853
  `
2307
854
  }
2308
855
 
@@ -2452,7 +999,7 @@ import { KywiAdminApp } from '@kywi-software/core/admin/app'
2452
999
  import { toAdminRuntimeConfig, resolveSurfaceState } from '@kywi-software/core/admin/server'
2453
1000
  // CRITICAL: load the admin design system once for the whole mount.
2454
1001
  import '@kywi-software/core/admin/styles.css'
2455
- import kywiConfig from '../../../lib/config'
1002
+ import kywiConfig from '../../../kywi.config'
2456
1003
  // Custom (defineModule) renderers, shared with the public layout so the Layout
2457
1004
  // editor canvas renders them the same as the live site (#48). Empty by default.
2458
1005
  import { moduleComponents } from '../../../lib/modules'
@@ -2629,7 +1176,8 @@ page: add sections and modules, then **Save** (PUTs the layout) or **Publish**
2629
1176
  (saves + publishes). **Done** returns you to the browse toolbar. Entering or
2630
1177
  leaving the editor keeps \`?kywi-edit=1\` in the URL in sync, so reloading (or
2631
1178
  sharing the link) lands back in the same mode. It all lives in
2632
- \`app/(site)/kywi-front-edit.tsx\`; the editor bundle (and the admin stylesheet) is
1179
+ \`@kywi-software/core/next\` (mounted by the generated public page), so it
1180
+ upgrades with the package; the editor bundle (and the admin stylesheet) is
2633
1181
  lazy-loaded, so pages your visitors see never carry its weight — an anonymous
2634
1182
  or read-only visitor gets no toolbar and no extra client JS. Custom
2635
1183
  \`defineModule\` types from \`kywi.config.ts\` also appear in the front-of-site
@@ -2673,13 +1221,13 @@ Audiences, experiments and the self-ID widget you configure in the admin resolve
2673
1221
  The **self-ID widget**, live re-evaluation, the transparency bar and behavioral
2674
1222
  signals are an optional *client* layer. Enable it by setting
2675
1223
  \`personalization: { clientRuntime: true }\` on your theme in \`kywi.config.ts\` and
2676
- dropping the built \`@kywi-software/js\` browser bundle at \`public/kywi.js\`; the
2677
- generated \`components/personalization-runtime.tsx\` loads it best-effort. Without
2678
- it, the server-rendered variant is what every visitor sees.
1224
+ dropping the built \`@kywi-software/js\` browser bundle at \`public/kywi.js\`;
1225
+ core's \`PersonalizationRuntime\` (mounted by the public page) loads it
1226
+ best-effort. Without it, the server-rendered variant is what every visitor sees.
2679
1227
 
2680
1228
  The same \`public/kywi.js\` bundle also drives the header nav's hover-intent,
2681
1229
  Escape/arrow-key and viewport-edge-flip enhancement — independent of
2682
- personalization. \`components/kywi-js-loader.tsx\` loads it whenever the site has
1230
+ personalization. Core's site layout loads it whenever the site has
2683
1231
  a nav, whether or not \`clientRuntime\` is on, so the nav gets that polish on
2684
1232
  every site once the bundle is in place; the nav renders and works fully without
2685
1233
  it either way. (\`public/kywi-admin.css\`, the front-of-site editor's stylesheet,
@@ -2777,16 +1325,25 @@ kywi.config.ts your config: sites, themes, content types, cus
2777
1325
  AGENTS.md guidance for AI agents working on this site (Kywi's building patterns)
2778
1326
  CLAUDE.md points AI agents to AGENTS.md
2779
1327
  ${SKILLS.map(({ slug }) => `.claude/skills/${slug}/SKILL.md ${slug.replace(/^kywi-/, '')} skill (loaded automatically)`).join('\n')}
2780
- middleware.ts auth gate + session refresh + cookie→bearer bridge
1328
+ middleware.ts re-exports core's middleware (auth gate, session refresh, cookie→bearer)
2781
1329
  next.config.mjs required Next config to consume @kywi-software/core
2782
- lib/kywi.ts server runtime (DB, API handler, content scope)
2783
- lib/config.ts single import path for kywi.config.ts
1330
+ lib/kywi.ts the ONE wiring point: kywi.config.ts → core's render path
2784
1331
  app/api/v1/[...kywi]/route.ts the versioned API (delegates to core)
2785
1332
  app/admin/[[...admin]]/page.tsx mounts the FULL core admin (all surfaces) at /admin
2786
1333
  lib/modules.tsx custom (defineModule) module renderers (admin + public)
2787
- 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)'}
1334
+ 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)'}
2788
1335
  \`\`\`
2789
- `
1336
+ ${a.mode === 'coupled' ? `
1337
+ \`lib/kywi.ts\`, \`middleware.ts\` and the two \`app/(site)\` route files are
1338
+ **thin, version-stamped delegates** (each opens with a \`// kywi-render v…\`
1339
+ comment; nothing else in the project carries one). The public render
1340
+ path itself — content and locale resolution, SEO metadata, JSON-LD, feed and nav
1341
+ hydration, personalization, A/B assignment and the front-of-site editor — lives
1342
+ in \`@kywi-software/core/next\`, so \`pnpm up @kywi-software/core\` upgrades it
1343
+ instead of leaving you to hand-merge a changelog into your own copy. What stays
1344
+ yours: \`kywi.config.ts\`, \`app/(site)/site.css\`, \`lib/modules.tsx\` and
1345
+ \`components/site-nav.tsx\`.
1346
+ ` : ''}`
2790
1347
  }
2791
1348
 
2792
1349
  // ── helpers ───────────────────────────────────────────────────────────────────
@@ -2798,9 +1355,17 @@ function slugify(name) {
2798
1355
  .replace(/^_+|_+$/g, '') || 'kywi_app'
2799
1356
  }
2800
1357
 
2801
- /** Escape a value interpolated as JSX text so it can't break out of the element. */
2802
- function escapeJsxText(value) {
2803
- return String(value).replace(/[{}<>]/g, (ch) => `{'${ch}'}`)
1358
+ /**
1359
+ * Escape a value interpolated into a SINGLE-QUOTED TypeScript string literal, so
1360
+ * it cannot break out of the quotes or the line. Replaces the old
1361
+ * `escapeJsxText`: since plan E1 the project name reaches the generated site
1362
+ * layout as `brand: { label: '…' }` — a string literal argument — rather than as
1363
+ * JSX text inside the header markup.
1364
+ */
1365
+ function escapeJsString(value) {
1366
+ return String(value)
1367
+ .replace(/[\\']/g, (ch) => `\\${ch}`)
1368
+ .replace(/\r?\n/g, '\\n')
2804
1369
  }
2805
1370
 
2806
1371
  // ── Agent guidance (AGENTS.md / CLAUDE.md) ──────────────────────────────────────
@@ -2924,15 +1489,22 @@ export const AGENTS_LANDMARK_PATHS = {
2924
1489
  * @property {boolean} adminHost app/admin/[[...admin]]/page.tsx present
2925
1490
  * @property {boolean} moduleMap lib/modules.tsx present
2926
1491
  * @property {boolean} middleware middleware.ts present
2927
- * @property {boolean} libSite lib/site.ts present (the scaffold's public-render helpers)
1492
+ * @property {boolean} libSite lib/site.ts present — a LEGACY landmark since
1493
+ * plan E1: the scaffold no longer emits it (the public-render helpers ship in
1494
+ * `@kywi-software/core/next`), but a project scaffolded before E1 still has
1495
+ * its own copy and the header must describe that honestly.
2928
1496
  * @property {boolean} sitePage app/(site)/[[...slug]]/page.tsx present (renders public pages)
2929
1497
  * @property {boolean} headlessPage app/page.tsx present (the headless/decoupled 404 root)
2930
1498
  */
2931
1499
 
2932
1500
  /**
2933
1501
  * The landmark set a FRESH scaffold of the given mode has — derived statically
2934
- * from the mode, never from the filesystem, so buildFileSet emits AGENTS.md that
2935
- * is byte-identical to the previous mode-branching implementation.
1502
+ * from the mode, never from the filesystem.
1503
+ *
1504
+ * `libSite` is FALSE in every mode since plan E1: a freshly scaffolded project
1505
+ * has no `lib/site.ts` at all, because the public-render helpers ship in
1506
+ * `@kywi-software/core/next`. It stays in the type because
1507
+ * {@link detectAgentsLandmarks} still finds one in a pre-E1 project.
2936
1508
  * @param {'coupled'|'headless'|'decoupled'} mode
2937
1509
  * @returns {AgentsLandmarks}
2938
1510
  */
@@ -2943,7 +1515,7 @@ export function scaffoldLandmarks(mode) {
2943
1515
  adminHost: true,
2944
1516
  moduleMap: true,
2945
1517
  middleware: true,
2946
- libSite: coupled,
1518
+ libSite: false,
2947
1519
  sitePage: coupled,
2948
1520
  headlessPage: !coupled,
2949
1521
  }
@@ -2952,8 +1524,8 @@ export function scaffoldLandmarks(mode) {
2952
1524
  /**
2953
1525
  * Detect the AGENTS.md landmarks that actually exist in an EXISTING project, so
2954
1526
  * `create-kywi-app agents` writes a header that matches the app on disk (a
2955
- * hand-built app may render the public site from the page directly and predate
2956
- * the scaffold's lib/site.ts helper or lib/modules.tsx map).
1527
+ * hand-built app may have no lib/modules.tsx map; a project scaffolded before
1528
+ * plan E1 still carries its own lib/site.ts copy of the render helpers).
2957
1529
  * @param {string} projectDir absolute path to the project root
2958
1530
  * @returns {AgentsLandmarks}
2959
1531
  */
@@ -2974,9 +1546,10 @@ export function detectAgentsLandmarks(projectDir) {
2974
1546
  * AGENTS.md — a short, app-specific header that orients an agent in THIS app,
2975
1547
  * followed by Kywi's canonical patterns doc verbatim. Landmark-aware: each "Where
2976
1548
  * things live" bullet is emitted only for a landmark that is actually present, and
2977
- * absent public-render (lib/site.ts) or module-map (lib/modules.tsx) wiring becomes
2978
- * an honest "this app predates the scaffold's …" line instead of a bullet that
2979
- * asserts a file the project does not have. With no landmarks passed it defaults to
1549
+ * an absent module map (lib/modules.tsx) becomes an honest "this app predates the
1550
+ * scaffold's …" line instead of a bullet that asserts a file the project does not
1551
+ * have. A `lib/site.ts` found on disk (only a pre-E1 project has one) is described
1552
+ * as the legacy copy it is. With no landmarks passed it defaults to
2980
1553
  * the fresh-scaffold set for the mode, so the scaffold output is unchanged.
2981
1554
  * @param {Answers} a
2982
1555
  * @param {AgentsLandmarks} [landmarks]
@@ -3013,17 +1586,26 @@ export function agentsMd(a, landmarks = scaffoldLandmarks(a.mode)) {
3013
1586
  \`defineModule\` module map (shared by the admin editor and the public site); see
3014
1587
  the patterns doc below for the intended shape.`)
3015
1588
  }
3016
- // Public-render surface: the scaffold's lib/site.ts helper, else a page that
3017
- // renders publicly without it (honest note), else no public rendering at all.
1589
+ // Public-render surface. Three honest cases, in order:
1590
+ // 1. a PRE-E1 project that still owns a copy of the render helpers;
1591
+ // 2. the current shape — thin delegates over @kywi-software/core/next;
1592
+ // 3. no public rendering at all (headless/decoupled).
3018
1593
  if (L.libSite) {
3019
- bullets.push(`- \`lib/site.ts\` — public-render helpers (path/locale resolution, feeds,
3020
- components, personalization) used by \`app/(site)/[[...slug]]/page.tsx\`, which
3021
- renders every published page at its slug.`)
1594
+ bullets.push(`- \`lib/site.ts\` — this app's OWN copy of Kywi's public-render helpers
1595
+ (path/locale resolution, feeds, components, personalization), used by
1596
+ \`app/(site)/[[...slug]]/page.tsx\`, which renders every published page at its
1597
+ slug. It predates the layered shape: current Kywi ships those helpers in
1598
+ \`@kywi-software/core/next\`, and a copy here is a copy a core upgrade cannot
1599
+ reach. Prefer core's; do not add more render logic to this file.`)
3022
1600
  } else if (L.sitePage) {
3023
- bullets.push(`- \`app/(site)/[[...slug]]/page.tsx\` — renders every published page at its
3024
- slug. This app predates the scaffold's \`lib/site.ts\` public-render helpers
3025
- (path/locale resolution, feeds, components, personalization); see the patterns
3026
- doc below for the intended shape.`)
1601
+ bullets.push(`- \`app/(site)/[[...slug]]/page.tsx\` + \`app/(site)/layout.tsx\` — thin,
1602
+ version-stamped delegates. The public render path itself (content + locale
1603
+ resolution, SEO metadata, JSON-LD, feeds, nav, personalization + A/B, the
1604
+ front-edit overlay) lives in **\`@kywi-software/core/next\`** and upgrades with
1605
+ the package, so \`pnpm up @kywi-software/core\` reaches it. \`lib/kywi.ts\` is
1606
+ the one wiring point between the two. **Never paste render logic back into
1607
+ these files** — extend through \`lib/modules.tsx\`, \`kywi.config.ts\`,
1608
+ \`app/(site)/site.css\` and \`components/site-nav.tsx\` instead.`)
3027
1609
  } else {
3028
1610
  bullets.push(
3029
1611
  a.mode === 'decoupled'
@@ -3034,8 +1616,10 @@ export function agentsMd(a, landmarks = scaffoldLandmarks(a.mode)) {
3034
1616
  )
3035
1617
  }
3036
1618
  if (L.middleware) {
3037
- bullets.push(`- \`middleware.ts\` — auth gate + session refresh, thin wiring over
3038
- \`@kywi-software/core/host\`.`)
1619
+ bullets.push(`- \`middleware.ts\` — auth gate + session refresh. In a project scaffolded
1620
+ since the layered render path it is a one-line re-export of
1621
+ \`@kywi-software/core/next/middleware\`; older projects hold their own copy
1622
+ over \`@kywi-software/core/host\`.`)
3039
1623
  }
3040
1624
  bullets.push(`- \`.claude/skills/kywi-content-model/SKILL.md\` — content-model planning
3041
1625
  skill, loaded automatically before building anything.`)
@@ -3142,12 +1726,13 @@ export function buildFileSet(answers) {
3142
1726
  // mode so this stays byte-identical to the pre-landmark implementation; the
3143
1727
  // same guidanceFileSet powers `create-kywi-app agents` for existing projects.
3144
1728
  ...guidanceFileSet(answers, scaffoldLandmarks(answers.mode)),
3145
- // server runtime + config
1729
+ // The render path's ONE config seam — thin, stamped, upgradeable
1730
+ // (RENDER_LANDMARK_PATHS.runtime).
3146
1731
  'lib/kywi.ts': libKywi(),
3147
- 'lib/config.ts': libConfig(),
3148
1732
  // custom (defineModule) module renderers, shared by admin + public layout (#48)
3149
1733
  'lib/modules.tsx': libModules(),
3150
- // host wiring (thin, over @kywi-software/core/host)
1734
+ // host wiring: a re-export of core's own middleware
1735
+ // (RENDER_LANDMARK_PATHS.middleware).
3151
1736
  'middleware.ts': middleware(),
3152
1737
  'app/api/v1/[...kywi]/route.ts': apiRoute(),
3153
1738
  // root
@@ -3169,26 +1754,21 @@ export function buildFileSet(answers) {
3169
1754
  if (answers.mode === 'coupled') {
3170
1755
  // Public site: an optional catch-all renders "/" (home) and every published
3171
1756
  // page at its slug. More-specific /admin and /api routes take precedence.
1757
+ // Both files are thin, stamped delegates over @kywi-software/core/next
1758
+ // (RENDER_LANDMARK_PATHS) — the render path itself is core's, so
1759
+ // `pnpm up @kywi-software/core` reaches it. Nothing here may grow a copy of
1760
+ // a core helper; `__tests__/render-drift.test.mjs` (verification E8) fails
1761
+ // the build if it does.
3172
1762
  files['app/(site)/layout.tsx'] = siteLayout(answers)
3173
1763
  files['app/(site)/site.css'] = siteStyles(answers)
3174
1764
  // 'use client' wrapper around core's shared nav renderer — required because
3175
1765
  // BUILT_IN_MODULE_COMPONENTS is exported from a 'use client' core module, so
3176
1766
  // the (Server Component) site layout above must render it through here
3177
1767
  // rather than looking it up directly (see siteNavComponent's doc comment).
1768
+ // Core ships an identical default; this stays project-owned, and wired
1769
+ // explicitly, because the header nav is the thing a site restyles first.
3178
1770
  files['components/site-nav.tsx'] = siteNavComponent()
3179
1771
  files['app/(site)/[[...slug]]/page.tsx'] = siteSlugPage()
3180
- // Public-render helpers: path/locale resolution, feed + component resolvers,
3181
- // personalization + experiments.
3182
- files['lib/site.ts'] = libSite()
3183
- // Shared /kywi.js script-injection primitive (kywi-cms#114) — the site
3184
- // layout's nav loader and the personalization runtime below both use it,
3185
- // so loading the bundle for either reason never double-loads it.
3186
- files['lib/kywi-js-loader.ts'] = kywiJsLoaderLib()
3187
- files['components/kywi-js-loader.tsx'] = kywiJsLoaderComponent()
3188
- // Optional client personalization runtime (self-ID widget, live re-eval). #50
3189
- files['components/personalization-runtime.tsx'] = personalizationRuntime()
3190
- // Front-of-site edit overlay (?kywi-edit=1), mounted by the page above.
3191
- files['app/(site)/kywi-front-edit.tsx'] = frontEditOverlay()
3192
1772
  // Syncs the admin stylesheet + a cache-busting version marker into
3193
1773
  // public/ on every predev/prebuild — see the doc comment on
3194
1774
  // syncKywiAdminCssScript and packageJson's syncScripts.