create-kywi-app 0.15.2 → 0.18.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.
@@ -303,7 +303,7 @@ Next steps:
303
303
  pnpm seed # prints the superadmin credentials
304
304
  pnpm dev # http://localhost:3000
305
305
 
306
- Docs: https://kywi.dev/docs
306
+ Docs: https://docs.kywi.dev
307
307
  `)
308
308
  return 0
309
309
  }
package/lib/templates.mjs CHANGED
@@ -191,7 +191,7 @@ assertProductionAuthSecret(process.env.AUTH_SECRET)
191
191
 
192
192
  /**
193
193
  * Kywi configuration — the single source of truth for this project.
194
- * Docs: https://kywi.dev/docs/config
194
+ * Docs: https://docs.kywi.dev/config
195
195
  */
196
196
  export default defineKywiConfig({
197
197
  // Deployment mode:
@@ -458,7 +458,9 @@ public/kywi-admin.css.version
458
458
  // ── server runtime (lib/kywi.ts) ──────────────────────────────────────────────
459
459
 
460
460
  function libKywi() {
461
- return `import {
461
+ return `import { cache } from 'react'
462
+ import { headers } from 'next/headers'
463
+ import {
462
464
  createDb,
463
465
  createKywiApiHandler,
464
466
  createStorageProvider,
@@ -468,6 +470,7 @@ function libKywi() {
468
470
  import type { KywiApiHandler, KywiDb } from '@kywi-software/core/server'
469
471
  import { createKywiScope } from '@kywi-software/core/scope'
470
472
  import type { KywiScope } from '@kywi-software/core/scope'
473
+ import { resolveActiveSite } from '@kywi-software/core/host'
471
474
  import config from '../kywi.config'
472
475
 
473
476
  /**
@@ -478,6 +481,14 @@ export interface KywiRuntime {
478
481
  handler: KywiApiHandler
479
482
  scope: KywiScope
480
483
  db: KywiDb
484
+ /**
485
+ * DB UUID of the DEFAULT site (\`config.sites[0]\`), resolved once at init.
486
+ *
487
+ * This is a process-level fallback, NOT the site a request is for. Public
488
+ * render code must use \`getActiveSite()\` below, which resolves from the
489
+ * request's Host header — keying a public query on this instead is what makes
490
+ * a multi-site app serve the first site's content on every domain.
491
+ */
481
492
  siteId: string
482
493
  config: typeof config
483
494
  }
@@ -493,10 +504,11 @@ async function init(): Promise<KywiRuntime> {
493
504
  const handler = await createKywiApiHandler({ config, db, authSecret, storage })
494
505
  const scope = createKywiScope(config, db, undefined, { mediaBaseUrl: '/api/v1' })
495
506
 
496
- // Resolve the DB UUID for the first configured site (its \`id\` is the slug).
507
+ // Resolve the DB UUID for the DEFAULT configured site (its \`id\` is the slug).
508
+ // Per-request site resolution lives in \`getActiveSite()\`, not here.
497
509
  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'
510
+ const match = sites.find((s) => (s as { slug?: string }).slug === config.sites[0]?.id) // DEFAULT-SITE FALLBACK: boot-time default, not a per-request resolution
511
+ const siteId = (match?.['id'] as string | undefined) ?? config.sites[0]?.id ?? 'default' // DEFAULT-SITE FALLBACK: boot-time default, not a per-request resolution
500
512
 
501
513
  return { handler, scope, db, siteId, config }
502
514
  }
@@ -510,6 +522,52 @@ export async function getKywi(): Promise<KywiRuntime> {
510
522
  export async function getKywiHandler(): Promise<KywiApiHandler> {
511
523
  return (await getKywi()).handler
512
524
  }
525
+
526
+ // ─── Host → site resolution ─────────────────────────────────────────────────
527
+
528
+ /** One configured site (config uses the slug as \`id\`). */
529
+ export type SiteConfig = (typeof config.sites)[number]
530
+
531
+ /** The site the current public request is for: config entry + its DB UUID. */
532
+ export interface ActiveSite {
533
+ runtime: KywiRuntime
534
+ /** Resolved config site (matched by request Host, else the default site). */
535
+ site: SiteConfig
536
+ /** DB UUID of that site (what the scope/queries key on). */
537
+ siteId: string
538
+ }
539
+
540
+ // Config-slug → DB-UUID map, cached for the process. Sites are reconciled at
541
+ // boot and static thereafter; a miss falls back to the slug.
542
+ let _slugToUuid: Record<string, string> | null = null
543
+ async function slugToUuid(runtime: KywiRuntime): Promise<Record<string, string>> {
544
+ if (_slugToUuid) return _slugToUuid
545
+ const rows = (await runtime.scope.site.list()) as Array<{ id?: string; slug?: string }>
546
+ const map: Record<string, string> = {}
547
+ for (const r of rows) if (r.slug && r.id) map[r.slug] = r.id
548
+ _slugToUuid = map
549
+ return map
550
+ }
551
+
552
+ /**
553
+ * Which configured site is this request for? Resolved from the request's HOST
554
+ * header, so \`docs.example.com\` serves the docs site and \`example.com\` serves
555
+ * the marketing site from one app and one database.
556
+ *
557
+ * Wrapped in React \`cache()\` so every helper on a single render shares one
558
+ * lookup. The process-level singletons in \`getKywi()\` are unaffected — only the
559
+ * per-request Host differs.
560
+ *
561
+ * DO NOT read \`x-kywi-url\` here: in dev it carries the server's own bound origin
562
+ * (localhost), which would defeat host-based routing entirely.
563
+ */
564
+ export const getActiveSite = cache(async (): Promise<ActiveSite> => {
565
+ const runtime = await getKywi()
566
+ const host = (await headers()).get('host')
567
+ const map = await slugToUuid(runtime)
568
+ const { site, siteId } = resolveActiveSite(runtime.config.sites, map, host)
569
+ return { runtime, site, siteId }
570
+ })
513
571
  `
514
572
  }
515
573
 
@@ -532,17 +590,15 @@ function libSite() {
532
590
  return `import { headers, cookies } from 'next/headers'
533
591
  import type {
534
592
  LayoutDocument,
535
- RegionNode,
536
- VariantContainer,
537
593
  FeedItemsResolver,
538
594
  PersonalizationState,
539
595
  } from '@kywi-software/core/layout'
540
596
  import {
541
- isLayoutSection,
542
597
  collectComponentRefs,
543
598
  componentDetachSource,
544
599
  resolveComponentPlacements,
545
600
  applyPageVariant,
601
+ collectLayoutExperimentIds,
546
602
  } from '@kywi-software/core/layout'
547
603
  import { resolveContentByPath, normalizePath } from '@kywi-software/core/nav'
548
604
  import { getFeedBySlug, getComponentById, resolveLocaleFromRequest } from '@kywi-software/core'
@@ -561,8 +617,7 @@ import type {
561
617
  SelfIdTrigger,
562
618
  } from '@kywi-software/core/audiences/types'
563
619
  import { listRunningExperiments, resolveExperimentForContainer } from '@kywi-software/core/experiments'
564
- import { getKywi, type KywiRuntime } from './kywi'
565
- import config from './config'
620
+ import { getActiveSite, type KywiRuntime, type SiteConfig } from './kywi'
566
621
 
567
622
  /** A resolved content node is a flat record of its columns (base + custom fields). */
568
623
  export type ContentNode = Record<string, unknown>
@@ -587,9 +642,18 @@ export async function requestBaseUrl(): Promise<string> {
587
642
 
588
643
  // ─── Locale routing (#51) ────────────────────────────────────────────────────
589
644
 
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]
645
+ /**
646
+ * The active site's configured locales (the default is always included first).
647
+ *
648
+ * \`activeSite\` is the site the REQUEST resolved to (\`getActiveSite().site\`).
649
+ * Omitting it falls back to the default site, which is right only for a
650
+ * single-site app — a second site would otherwise be served site one's locales.
651
+ */
652
+ export function siteLocales(
653
+ runtime: KywiRuntime,
654
+ activeSite?: SiteConfig,
655
+ ): { defaultLocale: string; locales: string[] } {
656
+ const site = activeSite ?? runtime.config.sites[0] // DEFAULT-SITE FALLBACK: Host matched no site
593
657
  const defaultLocale = site?.defaultLocale ?? 'en'
594
658
  const configured = site?.locales && site.locales.length > 0 ? site.locales : [defaultLocale]
595
659
  const locales = configured.includes(defaultLocale) ? configured : [defaultLocale, ...configured]
@@ -604,10 +668,12 @@ export function siteLocales(): { defaultLocale: string; locales: string[] } {
604
668
  * the slug). Locale prefixes are only honoured for locales in \`site.locales\`.
605
669
  */
606
670
  export function resolveRequestLocale(
671
+ runtime: KywiRuntime,
607
672
  slugSegments: string[],
608
673
  acceptLanguage?: string | null,
674
+ activeSite?: SiteConfig,
609
675
  ): { locale: string; slugPath: string[] } {
610
- const { defaultLocale, locales } = siteLocales()
676
+ const { defaultLocale, locales } = siteLocales(runtime, activeSite)
611
677
  const pathname = '/' + slugSegments.join('/')
612
678
  const locale = resolveLocaleFromRequest(pathname, acceptLanguage ?? null, defaultLocale, locales)
613
679
  const first = slugSegments[0]
@@ -619,10 +685,17 @@ export function resolveRequestLocale(
619
685
  * hreflang alternates for a page across the site's configured locales, or
620
686
  * undefined for a single-locale site. Feeds Next's \`alternates.languages\`.
621
687
  */
622
- export function localeAlternates(slugSegments: string[]): Record<string, string> | undefined {
623
- const { defaultLocale, locales } = siteLocales()
688
+ export async function localeAlternates(
689
+ slugSegments: string[],
690
+ ): Promise<Record<string, string> | undefined> {
691
+ // Async because the alternates belong to the site THIS request is for: a
692
+ // two-locale docs site and a single-locale marketing site share this app, and
693
+ // reading the default site's locales here would emit the wrong hreflang set
694
+ // (or none) on the other host.
695
+ const { runtime, site } = await getActiveSite()
696
+ const { defaultLocale, locales } = siteLocales(runtime, site)
624
697
  if (locales.length <= 1) return undefined
625
- const { slugPath } = resolveRequestLocale(slugSegments, null)
698
+ const { slugPath } = resolveRequestLocale(runtime, slugSegments, null, site)
626
699
  const rel = slugPath.join('/')
627
700
  const out: Record<string, string> = {}
628
701
  for (const loc of locales) {
@@ -646,9 +719,12 @@ export async function resolvePublicContent(
646
719
  slugSegments: string[],
647
720
  opts?: { acceptLanguage?: string | null },
648
721
  ): Promise<ContentNode | null> {
649
- const { scope, db, siteId } = await getKywi()
650
- const { defaultLocale } = siteLocales()
651
- const { locale, slugPath } = resolveRequestLocale(slugSegments, opts?.acceptLanguage)
722
+ // Every query below is keyed on the site the REQUEST is for, not on the
723
+ // default site: this is the seam that decides whose \`/about\` a visitor gets.
724
+ const { runtime, site, siteId } = await getActiveSite()
725
+ const { scope, db } = runtime
726
+ const { defaultLocale } = siteLocales(runtime, site)
727
+ const { locale, slugPath } = resolveRequestLocale(runtime, slugSegments, opts?.acceptLanguage, site)
652
728
 
653
729
  const bySlug = async (slug: string): Promise<ContentNode | null> => {
654
730
  let node = await scope.content.getBySlug(slug, siteId, { locale })
@@ -691,8 +767,8 @@ export async function resolvePublicContent(
691
767
  * URL so the (client) module can render images. Core owns the walk + item shape;
692
768
  * this owns the data access.
693
769
  */
694
- export function buildFeedResolver(runtime: KywiRuntime): FeedItemsResolver {
695
- const { scope, db, siteId } = runtime
770
+ export function buildFeedResolver(runtime: KywiRuntime, siteId: string): FeedItemsResolver {
771
+ const { scope, db } = runtime
696
772
  return async (feedSlug, { limit }) => {
697
773
  const feed = await getFeedBySlug(db, feedSlug, siteId)
698
774
  if (!feed) return []
@@ -713,8 +789,8 @@ export function buildFeedResolver(runtime: KywiRuntime): FeedItemsResolver {
713
789
  // ─── Linked components: server-side pre-resolution (#46, #69, #147) ──────────
714
790
 
715
791
  /** 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
792
+ async function loadComponents(ids: string[], runtime: KywiRuntime, siteId: string) {
793
+ const { db } = runtime
718
794
  const entries = await Promise.all(
719
795
  ids.map(async (id) => [id, await getComponentById(db, id, siteId)] as const),
720
796
  )
@@ -745,10 +821,11 @@ async function loadComponents(ids: string[], runtime: KywiRuntime) {
745
821
  export async function resolveLayoutComponents(
746
822
  layout: LayoutDocument,
747
823
  runtime: KywiRuntime,
824
+ siteId: string,
748
825
  ): Promise<LayoutDocument> {
749
826
  const refs = collectComponentRefs(layout)
750
827
  if (refs.length === 0) return layout
751
- const map = await loadComponents([...new Set(refs.map((ref) => ref.componentId))], runtime)
828
+ const map = await loadComponents([...new Set(refs.map((ref) => ref.componentId))], runtime, siteId)
752
829
  return resolveComponentPlacements(layout, (componentId) =>
753
830
  componentDetachSource(map.get(componentId) ?? null),
754
831
  )
@@ -796,11 +873,14 @@ export async function resolvePersonalization(
796
873
  layout: LayoutDocument | null | undefined,
797
874
  ): Promise<PublicPersonalization> {
798
875
  const [h, cookieStore] = await Promise.all([headers(), cookies()])
876
+ // Evaluate against the HOST-resolved site, so a visitor on site B is matched
877
+ // only against site B's audiences and gated by site B's theme flags.
878
+ const { site, siteId } = await getActiveSite()
799
879
  // One resolution of \`theme.personalization.requireConsent\` for the whole
800
880
  // request, handed to every gate below — the visitor-id read AND the audience
801
881
  // engine's own collectors. They read the same cookies; disagreeing about
802
882
  // whether consent is required would personalize half a page.
803
- const gate = { requireConsent: requireConsentEnabled() }
883
+ const gate = { requireConsent: requireConsentEnabled(runtime, site) }
804
884
  // \`kywi_visitor\` is a \`personalization\` cookie, so without consent it is
805
885
  // neither written nor read (kywi-cms#91) — the middleware hands this request a
806
886
  // THROWAWAY id instead, freshly minted and different on the next request.
@@ -823,7 +903,7 @@ export async function resolvePersonalization(
823
903
  h.forEach((value, key) => reqHeaders.set(key, value))
824
904
  const request = new Request(url, { headers: reqHeaders })
825
905
 
826
- const { db, siteId } = runtime
906
+ const { db } = runtime
827
907
  // \`gate\` reaches the engine's server collectors, so a site whose consent is
828
908
  // owned by an external CMP (\`requireConsent: false\`) has its stored UTM /
829
909
  // visitor / known / pinned-audience cookies read here too, not just by the
@@ -835,6 +915,7 @@ export async function resolvePersonalization(
835
915
 
836
916
  const experimentAssignments = await resolveExperimentAssignments(
837
917
  runtime,
918
+ siteId,
838
919
  layout,
839
920
  audienceId,
840
921
  visitorId,
@@ -851,14 +932,6 @@ export async function resolvePersonalization(
851
932
  }
852
933
  }
853
934
 
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
- }
860
- }
861
-
862
935
  /**
863
936
  * Deterministically assign this visitor to a variant of every running A/B
864
937
  * experiment the (audience-resolved) layout actually shows, and record the
@@ -871,9 +944,18 @@ function* variantContainersInRegions(regions: Record<string, RegionNode[]>): Gen
871
944
  * write stops being idempotent — every anonymous page view would insert another
872
945
  * assignment row. The ARM IS STILL CHOSEN, so the page renders exactly as it
873
946
  * does for anyone else; only the counting stops.
947
+ *
948
+ * Experiment ids come from \`collectLayoutExperimentIds\` — the engine's own
949
+ * walker, the same one \`<KywiLayout>\` and the reference app use. It descends
950
+ * into every section's columns as well as the top-level regions, so a
951
+ * module-level \`moduleVariantContainer\` (an ab_test placed inside a section,
952
+ * not a bare section-level band) is bucketed exactly like one — a hand-rolled
953
+ * walker here previously stopped at the top level and silently never assigned
954
+ * those (kywi-cms#221).
874
955
  */
875
956
  async function resolveExperimentAssignments(
876
957
  runtime: KywiRuntime,
958
+ siteId: string,
877
959
  layout: LayoutDocument | null | undefined,
878
960
  audienceId: string | null,
879
961
  visitorId: string,
@@ -887,16 +969,11 @@ async function resolveExperimentAssignments(
887
969
  // see the Default arm of a live experiment. After pre-resolution there are no
888
970
  // unresolved placements left to special-case — the containers here are the
889
971
  // 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)
895
- }
896
- }
897
- if (experimentIds.size === 0) return {}
972
+ const resolved = await resolveLayoutComponents(applyPageVariant(layout, audienceId), runtime, siteId)
973
+ const experimentIds = collectLayoutExperimentIds(resolved)
974
+ if (experimentIds.length === 0) return {}
898
975
 
899
- const running = await listRunningExperiments(runtime.db, runtime.siteId)
976
+ const running = await listRunningExperiments(runtime.db, siteId)
900
977
  const byId = new Map(running.map((e) => [e.id, e]))
901
978
  const assignments: Record<string, string | null> = {}
902
979
  for (const id of experimentIds) {
@@ -925,10 +1002,10 @@ export function personalizeLayout(
925
1002
  return applyPageVariant(layout, audienceId)
926
1003
  }
927
1004
 
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]
1005
+ /** Is the @kywi-software/js client runtime enabled for the ACTIVE site's theme? */
1006
+ export function clientRuntimeEnabled(runtime: KywiRuntime, activeSite?: SiteConfig): boolean {
1007
+ const themeName = (activeSite ?? runtime.config.sites[0])?.theme // DEFAULT-SITE FALLBACK: Host matched no site
1008
+ const theme = runtime.config.themes.find((t) => t.name === themeName) ?? runtime.config.themes[0]
932
1009
  return theme?.personalization?.clientRuntime === true
933
1010
  }
934
1011
 
@@ -940,9 +1017,9 @@ export function clientRuntimeEnabled(): boolean {
940
1017
  * appearing on its own. Even then a \`personalizationBadge\` module can still
941
1018
  * open it deliberately.
942
1019
  */
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]
1020
+ export function transparencyNoticeEnabled(runtime: KywiRuntime, activeSite?: SiteConfig): boolean {
1021
+ const themeName = (activeSite ?? runtime.config.sites[0])?.theme // DEFAULT-SITE FALLBACK: Host matched no site
1022
+ const theme = runtime.config.themes.find((t) => t.name === themeName) ?? runtime.config.themes[0]
946
1023
  return theme?.personalization?.transparencyNotice?.enabled !== false
947
1024
  }
948
1025
 
@@ -955,9 +1032,18 @@ export function transparencyNoticeEnabled(): boolean {
955
1032
  *
956
1033
  * \`middleware.ts\` runs on the edge and cannot read this config, so it mirrors
957
1034
  * the same flag with \`KYWI_REQUIRE_CONSENT=false\`. Set both, or neither.
1035
+ *
1036
+ * The flag is resolved from the ACTIVE site's theme, but as of core 0.17.0 every
1037
+ * configured site in one deployment must agree on it: the edge mirror is a
1038
+ * single process-wide env var and cannot carry two answers, so
1039
+ * \`createKywiApiHandler\` FAILS STARTUP on a divergence rather than letting a
1040
+ * second site silently inherit the first site's privacy default. A per-host
1041
+ * mirror map would lift that constraint and is deferred
1042
+ * (\`docs-site DESIGN.md §3.4\`).
958
1043
  */
959
- export function requireConsentEnabled(): boolean {
960
- const theme = config.themes.find((t) => t.name === config.sites[0]?.theme) ?? config.themes[0]
1044
+ export function requireConsentEnabled(runtime: KywiRuntime, activeSite?: SiteConfig): boolean {
1045
+ const themeName = (activeSite ?? runtime.config.sites[0])?.theme // DEFAULT-SITE FALLBACK: Host matched no site
1046
+ const theme = runtime.config.themes.find((t) => t.name === themeName) ?? runtime.config.themes[0]
961
1047
  return theme?.personalization?.requireConsent !== false
962
1048
  }
963
1049
 
@@ -1002,10 +1088,13 @@ function mapFrequency(freq: SelfIdWidgetConfig['frequency']): PublicSelfIdWidget
1002
1088
  * picklist fields hydrated. Returns null when there is no config or it references
1003
1089
  * no resolvable fields, so the caller can skip mounting the widget entirely.
1004
1090
  */
1005
- export async function resolveSelfIdWidget(runtime: KywiRuntime): Promise<PublicSelfIdWidget | null> {
1091
+ export async function resolveSelfIdWidget(
1092
+ runtime: KywiRuntime,
1093
+ siteId: string,
1094
+ ): Promise<PublicSelfIdWidget | null> {
1006
1095
  const [widget, allFields] = await Promise.all([
1007
- getSelfIdWidgetConfig(runtime.db, runtime.siteId),
1008
- listSelfIdFields(runtime.db, runtime.siteId),
1096
+ getSelfIdWidgetConfig(runtime.db, siteId),
1097
+ listSelfIdFields(runtime.db, siteId),
1009
1098
  ])
1010
1099
  if (!widget) return null
1011
1100
 
@@ -1365,8 +1454,7 @@ import { navTreeToMenuItems, normalizePath } from '@kywi-software/core/nav'
1365
1454
  import '@kywi-software/core/site/styles.css'
1366
1455
  // This app's OWN chrome (header / footer / page wrapper). Yours to edit freely.
1367
1456
  import './site.css'
1368
- import config from '../../kywi.config'
1369
- import { getKywi } from '../../lib/kywi'
1457
+ import { getActiveSite } from '../../lib/kywi'
1370
1458
  import { SiteNav } from '../../components/site-nav'
1371
1459
  import { KywiJsLoader } from '../../components/kywi-js-loader'
1372
1460
 
@@ -1390,17 +1478,29 @@ import { KywiJsLoader } from '../../components/kywi-js-loader'
1390
1478
  * into a \`:root { --kywi-* }\` block by \`themeTokenStyleBlock\` and injected below,
1391
1479
  * so kywi.config.ts is the single source of truth for the palette and spacing and
1392
1480
  * both site.css and core's default styles resolve against those variables.
1481
+ *
1482
+ * Everything it renders — the menus, the nav-tree fallback and the theme block —
1483
+ * is keyed on the site the REQUEST resolved to, so one app serves each of its
1484
+ * configured domains its own nav and its own palette.
1393
1485
  */
1394
- const siteTheme =
1395
- config.themes.find((t) => t.name === config.sites[0]?.theme) ?? config.themes[0]
1396
- const themeVars = themeTokenStyleBlock(siteTheme?.tokens)
1397
1486
 
1398
1487
  // Content edits (a renamed page, a reordered menu) must show up without a
1399
1488
  // restart — same reasoning as the page route.
1400
1489
  export const dynamic = 'force-dynamic'
1401
1490
 
1402
1491
  export default async function SiteLayout({ children }: { children: React.ReactNode }) {
1403
- const { scope, siteId } = await getKywi()
1492
+ // The site this request is FOR (resolved from its Host header), never the
1493
+ // default site: the menus, the nav tree and the theme below are all keyed on
1494
+ // it, so a second domain gets its own nav and its own palette.
1495
+ const { runtime, site, siteId } = await getActiveSite()
1496
+ const { scope } = runtime
1497
+
1498
+ // Resolved PER REQUEST, not at module scope: a module-scope constant is
1499
+ // evaluated once at import, so every host would emit the first site's tokens
1500
+ // and a second site could not be themed at all.
1501
+ const siteTheme =
1502
+ runtime.config.themes.find((t) => t.name === site.theme) ?? runtime.config.themes[0]
1503
+ const themeVars = themeTokenStyleBlock(siteTheme?.tokens)
1404
1504
 
1405
1505
  // The middleware forwards the full request URL as \`x-kywi-url\` (see
1406
1506
  // middleware.ts) so this shared layout can resolve which page is current —
@@ -1573,7 +1673,7 @@ import {
1573
1673
  import { KywiJsonLd } from '@kywi-software/core/scope'
1574
1674
  import { ACCESS_COOKIE, canAccessContent, readSessionClaims } from '@kywi-software/core/host'
1575
1675
  import config from '../../../lib/config'
1576
- import { getKywi } from '../../../lib/kywi'
1676
+ import { getActiveSite } from '../../../lib/kywi'
1577
1677
  import {
1578
1678
  resolvePublicContent,
1579
1679
  resolvePersonalization,
@@ -1623,7 +1723,7 @@ export async function generateMetadata({ params }: Params): Promise<Metadata> {
1623
1723
  const image = mediaUrl(node['metaImageId']) ?? mediaUrl(node['featuredImageId'])
1624
1724
  const images = image ? [image] : undefined
1625
1725
  const canonical = (node['canonicalUrl'] as string) || undefined
1626
- const languages = localeAlternates(slug ?? [])
1726
+ const languages = await localeAlternates(slug ?? [])
1627
1727
  const alternates =
1628
1728
  canonical || languages
1629
1729
  ? { ...(canonical ? { canonical } : {}), ...(languages ? { languages } : {}) }
@@ -1674,7 +1774,10 @@ export default async function PublicPage({ params, searchParams }: Params & Sear
1674
1774
  const node = await getNode((slug ?? []).join('/'), acceptLanguage)
1675
1775
  if (!node || node['status'] !== 'published') notFound()
1676
1776
 
1677
- const runtime = await getKywi()
1777
+ // The site this request is FOR, resolved from its Host header. \`siteId\` (not
1778
+ // \`runtime.siteId\`, which is the DEFAULT site) keys every site-scoped call
1779
+ // below; \`site\` selects the theme the flag helpers read.
1780
+ const { runtime, site, siteId } = await getActiveSite()
1678
1781
  const contentId = String(node['id'] ?? '')
1679
1782
  const contentType = String(node['contentTypeName'] ?? 'page')
1680
1783
  const title = String(node['title'] ?? 'Untitled')
@@ -1695,7 +1798,7 @@ export default async function PublicPage({ params, searchParams }: Params & Sear
1695
1798
  // visitor's A/B arms are assigned deterministically. Opted-out / anonymous
1696
1799
  // visitors resolve to the default experience.
1697
1800
  const perso = await resolvePersonalization(runtime, layout)
1698
- const selfIdWidget = await resolveSelfIdWidget(runtime)
1801
+ const selfIdWidget = await resolveSelfIdWidget(runtime, siteId)
1699
1802
 
1700
1803
  // <head> injections (React hoists these): per-page JSON-LD gated on the
1701
1804
  // AX/JSON-LD setting, mapping the SAME node so page and schema agree (#56); the
@@ -1717,17 +1820,17 @@ export default async function PublicPage({ params, searchParams }: Params & Sear
1717
1820
  node={node}
1718
1821
  config={runtime.config}
1719
1822
  baseUrl={baseUrl}
1720
- siteId={runtime.siteId}
1823
+ siteId={siteId}
1721
1824
  {...(servedLayout ? { layout: servedLayout } : {})}
1722
1825
  />
1723
1826
  <AudienceMetaTags audienceId={perso.audienceId} visitorId={perso.visitorId} />
1724
- {clientRuntimeEnabled() && perso.audiences.length > 0 ? (
1827
+ {clientRuntimeEnabled(runtime, site) && perso.audiences.length > 0 ? (
1725
1828
  <PersonalizationRuntime
1726
1829
  audiences={perso.audiences}
1727
1830
  serverSignals={perso.signals}
1728
1831
  selfIdWidget={selfIdWidget}
1729
- transparencyNotice={transparencyNoticeEnabled()}
1730
- requireConsent={requireConsentEnabled()}
1832
+ transparencyNotice={transparencyNoticeEnabled(runtime, site)}
1833
+ requireConsent={requireConsentEnabled(runtime, site)}
1731
1834
  />
1732
1835
  ) : null}
1733
1836
  </>
@@ -1744,19 +1847,19 @@ export default async function PublicPage({ params, searchParams }: Params & Sear
1744
1847
  // hydrate feeds and resolve components on the layout THIS visitor sees.
1745
1848
  // variantContainer arms resolve at render time from \`personalization\`.
1746
1849
  const personalized = personalizeLayout(layout, perso.audienceId)
1747
- const feedsHydrated = await hydrateLayoutFeeds(personalized, buildFeedResolver(runtime))
1850
+ const feedsHydrated = await hydrateLayoutFeeds(personalized, buildFeedResolver(runtime, siteId))
1748
1851
  // Resolve every navMenu/navigation/siteMap module placed in THIS layout
1749
1852
  // (menuSlug → a real menu; the tree otherwise) — the same seam as feeds,
1750
1853
  // so a nav module dropped into any region/section just works (#112).
1751
1854
  const currentPath = String(node['path'] ?? '/')
1752
1855
  const navHydrated = await hydrateLayoutNav(
1753
1856
  feedsHydrated,
1754
- runtime.scope.menus.createHydrationResolver(runtime.siteId, currentPath),
1857
+ runtime.scope.menus.createHydrationResolver(siteId, currentPath),
1755
1858
  )
1756
1859
  // Linked components resolve HERE, on the server, into the document itself:
1757
1860
  // KywiLayout is a client component and the renderer's resolver props are
1758
1861
  // functions, which cannot cross the RSC boundary (#147).
1759
- const hydrated = await resolveLayoutComponents(navHydrated, runtime)
1862
+ const hydrated = await resolveLayoutComponents(navHydrated, runtime, siteId)
1760
1863
  // LAST pass before the client boundary (#167): keep ONLY the arm this
1761
1864
  // visitor is served in every variantContainer / moduleVariantContainer.
1762
1865
  // KywiLayout is a client component, so anything still on the document here
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-kywi-app",
3
- "version": "0.15.2",
3
+ "version": "0.18.0",
4
4
  "description": "Scaffold a new Kywi CMS project — npx create-kywi-app my-site",
5
5
  "type": "module",
6
6
  "homepage": "https://github.com/Kywi-Software/kywi-cms#readme",