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