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