create-kywi-app 0.16.0 → 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.
- package/bin/create-kywi-app.mjs +1 -1
- package/lib/templates.mjs +176 -73
- package/package.json +1 -1
package/bin/create-kywi-app.mjs
CHANGED
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/
|
|
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
|
|
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 {
|
|
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
|
-
/**
|
|
591
|
-
|
|
592
|
-
|
|
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(
|
|
623
|
-
|
|
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
|
-
|
|
650
|
-
|
|
651
|
-
const {
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
892
|
-
|
|
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,
|
|
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
|
|
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
|
|
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(
|
|
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,
|
|
1008
|
-
listSelfIdFields(runtime.db,
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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={
|
|
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(
|
|
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