create-kywi-app 0.5.0 → 0.6.1

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.
Files changed (2) hide show
  1. package/lib/templates.mjs +193 -14
  2. package/package.json +1 -1
package/lib/templates.mjs CHANGED
@@ -383,12 +383,13 @@ import type {
383
383
  ModuleNode,
384
384
  FeedItemsResolver,
385
385
  ComponentResolver,
386
+ SectionComponentResolver,
386
387
  PersonalizationState,
387
388
  } from '@kywi-software/core/layout'
388
389
  import { isLayoutSection, applyPageVariant } from '@kywi-software/core/layout'
389
390
  import { resolveContentByPath, normalizePath } from '@kywi-software/core/nav'
390
391
  import { getFeedBySlug, getComponentById, resolveLocaleFromRequest } from '@kywi-software/core'
391
- import { resolveComponentDefinition } from '@kywi-software/core/admin/server'
392
+ import { resolveComponentDefinition, resolveSectionComponentDefinition } from '@kywi-software/core/admin/server'
392
393
  import {
393
394
  evaluateActiveAudiences,
394
395
  getSelfIdWidgetConfig,
@@ -590,6 +591,41 @@ export async function buildComponentResolver(
590
591
  return (componentId) => resolveComponentDefinition(map.get(componentId) ?? null)
591
592
  }
592
593
 
594
+ /** Yield every section-level component reference (\`section.componentId\`) in the
595
+ * layout, descending into variantContainer default + variant sections (#69). */
596
+ function* sectionComponentIds(regions: Record<string, RegionNode[]>): Generator<string> {
597
+ for (const nodes of Object.values(regions)) {
598
+ for (const node of nodes) {
599
+ if (isLayoutSection(node)) {
600
+ if (node.componentId) yield node.componentId
601
+ } else {
602
+ for (const s of node.defaultSections) if (s.componentId) yield s.componentId
603
+ for (const v of node.variants) for (const s of v.sections) if (s.componentId) yield s.componentId
604
+ }
605
+ }
606
+ }
607
+ }
608
+
609
+ /**
610
+ * Prefetch every connected *section* component (\`section.componentId\`) referenced
611
+ * by the layout and return a synchronous {@link SectionComponentResolver} over the
612
+ * snapshot — parallel to {@link buildComponentResolver} but for reusable sections
613
+ * (#69). Returns undefined when the layout references none (skip the prop).
614
+ */
615
+ export async function buildSectionComponentResolver(
616
+ layout: LayoutDocument,
617
+ runtime: KywiRuntime,
618
+ ): Promise<SectionComponentResolver | undefined> {
619
+ const ids = new Set<string>(sectionComponentIds(layout.regions))
620
+ if (ids.size === 0) return undefined
621
+ const { db, siteId } = runtime
622
+ const entries = await Promise.all(
623
+ [...ids].map(async (id) => [id, await getComponentById(db, id, siteId)] as const),
624
+ )
625
+ const map = new Map(entries)
626
+ return (componentId) => resolveSectionComponentDefinition(map.get(componentId) ?? null)
627
+ }
628
+
593
629
  // ─── Personalization + experiments (#50) ─────────────────────────────────────
594
630
 
595
631
  /** Server-resolved personalization for one public request. */
@@ -792,6 +828,10 @@ import {
792
828
  setAccessCookie,
793
829
  clearSessionCookies,
794
830
  } from '@kywi-software/core/host'
831
+ // Edge-safe leaf helper: builds the kywi_utm cookie value core's collectUtm reads
832
+ // back. Its own dependency-free entry (never the DB-backed audiences barrel), so
833
+ // it is importable from this edge middleware.
834
+ import { UTM_COOKIE, utmCookieValue } from '@kywi-software/core/audiences/utm-persistence'
795
835
 
796
836
  /**
797
837
  * Server-side auth enforcement + transparent session refresh for /admin and the
@@ -838,17 +878,30 @@ const VISITOR_MAX_AGE = 60 * 60 * 24 * 365 * 2 // 2 years
838
878
  * Public (non-admin, non-API) request: never auth-gated. Ensure a stable
839
879
  * \`kywi_visitor\` id — mint one on first visit — and forward it to the render as
840
880
  * \`x-kywi-visitor\` so the page sees it on this very request (the freshly set
841
- * cookie is not yet readable via cookies()). Everything else passes through.
881
+ * cookie is not yet readable via cookies()). Also forward the full request URL
882
+ * as \`x-kywi-url\` (query string included) so the server render can read UTM
883
+ * params off the very first request — without it, campaign personalization
884
+ * would never fire on the landing page. Everything else passes through.
842
885
  */
843
886
  function handlePublicRequest(req: NextRequest): NextResponse {
844
887
  const existing = req.cookies.get(VISITOR_COOKIE)?.value
845
888
  const visitorId = existing ?? crypto.randomUUID()
846
889
  const headers = new Headers(req.headers)
847
890
  headers.set('x-kywi-visitor', visitorId)
891
+ headers.set('x-kywi-url', req.nextUrl.href)
848
892
  const res = NextResponse.next({ request: { headers } })
849
893
  if (!existing) {
850
894
  res.cookies.set(VISITOR_COOKIE, visitorId, { path: '/', maxAge: VISITOR_MAX_AGE, sameSite: 'lax' })
851
895
  }
896
+ // Persist campaign UTM params so audience matching survives internal
897
+ // navigation: core's collectUtm reads this cookie when the URL has no utm_*
898
+ // query string. Only written when the request carries utm_* params, so an
899
+ // ordinary page view never clobbers a persisted campaign (utmCookieValue
900
+ // returns null → the existing cookie is left in place).
901
+ const utmValue = utmCookieValue(req.nextUrl.href)
902
+ if (utmValue) {
903
+ res.cookies.set(UTM_COOKIE, utmValue, { path: '/', maxAge: 60 * 60 * 24 * 30, sameSite: 'lax' })
904
+ }
852
905
  return res
853
906
  }
854
907
 
@@ -1117,6 +1170,7 @@ import {
1117
1170
  clientRuntimeEnabled,
1118
1171
  buildFeedResolver,
1119
1172
  buildComponentResolver,
1173
+ buildSectionComponentResolver,
1120
1174
  localeAlternates,
1121
1175
  mediaUrl,
1122
1176
  requestBaseUrl,
@@ -1255,6 +1309,7 @@ export default async function PublicPage({ params, searchParams }: Params & Sear
1255
1309
  const personalized = personalizeLayout(layout, perso.audienceId)
1256
1310
  const hydrated = await hydrateLayoutFeeds(personalized, buildFeedResolver(runtime))
1257
1311
  const componentResolver = await buildComponentResolver(hydrated, runtime)
1312
+ const sectionComponentResolver = await buildSectionComponentResolver(hydrated, runtime)
1258
1313
  content = (
1259
1314
  <article className="page page--layout" data-kywi-content-id={contentId}>
1260
1315
  {head}
@@ -1263,6 +1318,7 @@ export default async function PublicPage({ params, searchParams }: Params & Sear
1263
1318
  personalization={perso.personalization}
1264
1319
  moduleComponents={moduleComponents}
1265
1320
  {...(componentResolver ? { componentResolver } : {})}
1321
+ {...(sectionComponentResolver ? { sectionComponentResolver } : {})}
1266
1322
  >
1267
1323
  {Object.keys(hydrated.regions).map((name) => (
1268
1324
  <KywiRegion key={name} name={name} />
@@ -1298,9 +1354,12 @@ export default async function PublicPage({ params, searchParams }: Params & Sear
1298
1354
  canEdit={perms.canEdit}
1299
1355
  canPublish={perms.canPublish}
1300
1356
  contentId={contentId}
1357
+ contentType={contentType}
1301
1358
  pageTitle={title}
1302
1359
  pageStatus={String(node['status'] ?? '')}
1360
+ initialLayout={layout ?? { regions: { main: [] } }}
1303
1361
  adminHref={\`/admin/content/\${contentType}/\${contentId}\`}
1362
+ moduleComponents={moduleComponents}
1304
1363
  >
1305
1364
  {content}
1306
1365
  </KywiFrontEdit>
@@ -1315,56 +1374,167 @@ export default async function PublicPage({ params, searchParams }: Params & Sear
1315
1374
  /**
1316
1375
  * Front-of-site edit overlay (client). Mounted by the public page ONLY for an
1317
1376
  * authenticated admin who opened the page with ?kywi-edit=1 (the page verifies
1318
- * the session cookie server-side first). Surfaces core's KywiEditToolbar and
1319
- * enters edit mode, which outlines the editable regions core already marks with
1320
- * the `data-kywi-*` protocol. Real content edits happen in the full admin editor
1321
- * (the toolbar's Admin link deep-links to this page there); Publish posts to the
1322
- * API for admins with publish permission.
1377
+ * the session cookie server-side first).
1378
+ *
1379
+ * Two modes, same component:
1380
+ * - **Browse** — core's slim `KywiEditToolbar` over the live page, with the
1381
+ * editable regions outlined via the `data-kywi-*` protocol.
1382
+ * - **Edit** (`?kywi-edit=1`, or the toolbar's Edit toggle) — the FULL
1383
+ * `OverlayShell` layout editor, the same one the admin app mounts, rendered
1384
+ * from the same core module registry + custom renderers. Save PUTs the layout
1385
+ * document; Publish PUTs the layout then flips status — exactly the API the
1386
+ * admin editor uses.
1387
+ *
1388
+ * The OverlayShell is lazy-loaded (`next/dynamic`, client-only): its weight
1389
+ * (dnd-kit, canvas, side panels) is only fetched when an editor actually enters
1390
+ * edit mode, so the public browse bundle stays light.
1323
1391
  */
1324
1392
  function frontEditOverlay() {
1325
1393
  return `'use client'
1326
1394
  import React from 'react'
1395
+ import dynamic from 'next/dynamic'
1327
1396
  import { KywiEditToolbar, useKywiEditMode } from '@kywi-software/core/scope-client'
1328
1397
  import { adminFetch } from '@kywi-software/core/host-client'
1398
+ import {
1399
+ createModuleRegistry,
1400
+ createThemeRegistry,
1401
+ BUILT_IN_MODULE_COMPONENTS,
1402
+ type LayoutDocument,
1403
+ type ModuleComponentMap,
1404
+ } from '@kywi-software/core/layout'
1405
+ import type { SaveAction } from '@kywi-software/core/admin'
1406
+
1407
+ // Lazy-load the full layout editor AND its stylesheet: the shell bundle (dnd-kit,
1408
+ // canvas, panels) and the ~4.7k-line admin design system are pulled INSIDE this
1409
+ // factory, so they enter the module graph only when an editor opens the overlay —
1410
+ // never in the public browse bundle a visitor downloads.
1411
+ const OverlayShell = dynamic(
1412
+ async () => {
1413
+ await import('@kywi-software/core/admin/styles.css')
1414
+ const mod = await import('@kywi-software/core/admin')
1415
+ return mod.OverlayShell
1416
+ },
1417
+ { ssr: false },
1418
+ )
1329
1419
 
1330
1420
  export interface KywiFrontEditProps {
1331
1421
  canEdit: boolean
1332
1422
  canPublish: boolean
1333
1423
  contentId: string
1424
+ contentType: string
1334
1425
  pageTitle: string
1335
1426
  pageStatus: string
1427
+ /** The page's saved layout document (empty regions when it has none yet). */
1428
+ initialLayout: LayoutDocument
1336
1429
  /** Deep link to this page in the full admin editor. */
1337
1430
  adminHref: string
1431
+ /** Custom (defineModule) renderers, shared with the public layout + admin (#48). */
1432
+ moduleComponents?: ModuleComponentMap
1338
1433
  children: React.ReactNode
1339
1434
  }
1340
1435
 
1341
1436
  /**
1342
1437
  * Wraps the public page with the front-of-site edit affordance. The page only
1343
1438
  * renders this for a signed-in admin who asked to edit (?kywi-edit=1), so the
1344
- * toolbar's own permission gate (canEdit) is always satisfied here.
1439
+ * permission gate (canEdit) is always satisfied here.
1345
1440
  */
1346
1441
  export function KywiFrontEdit({
1347
1442
  canEdit,
1348
1443
  canPublish,
1349
1444
  contentId,
1445
+ contentType,
1350
1446
  pageTitle,
1351
1447
  pageStatus,
1448
+ initialLayout,
1352
1449
  adminHref,
1450
+ moduleComponents = {},
1353
1451
  children,
1354
1452
  }: KywiFrontEditProps) {
1355
1453
  const edit = useKywiEditMode({ canEdit, canPublish })
1356
1454
 
1357
1455
  // ?kywi-edit=1 means "enter edit mode now" — flip it on once after mount.
1358
- const { startEdit } = edit
1456
+ const { startEdit, endEdit } = edit
1359
1457
  React.useEffect(() => {
1360
1458
  startEdit()
1361
1459
  }, [startEdit])
1362
1460
 
1363
- const handlePublish = React.useCallback(async () => {
1461
+ // Registries + renderers for the editor: built from core (no module list is
1462
+ // re-declared here) and merged with this app's custom module renderers from
1463
+ // lib/modules — the SAME source the admin app and public renderer use (#48).
1464
+ const moduleRegistry = React.useMemo(() => createModuleRegistry([]), [])
1465
+ const themeRegistry = React.useMemo(() => createThemeRegistry(), [])
1466
+ const editorComponents = React.useMemo(
1467
+ () => ({ ...BUILT_IN_MODULE_COMPONENTS, ...moduleComponents }),
1468
+ [moduleComponents],
1469
+ )
1470
+
1471
+ // Persist the edited layout to the same content API the admin editor uses.
1472
+ const persistLayout = React.useCallback(
1473
+ async (next: LayoutDocument) => {
1474
+ const res = await fetch(\`/api/v1/content/\${contentType}/\${contentId}/layout\`, {
1475
+ method: 'PUT',
1476
+ credentials: 'same-origin',
1477
+ headers: { 'Content-Type': 'application/json' },
1478
+ body: JSON.stringify(next),
1479
+ })
1480
+ return res.ok
1481
+ },
1482
+ [contentType, contentId],
1483
+ )
1484
+
1485
+ const handleSave = React.useCallback(
1486
+ async (next: LayoutDocument, _action: SaveAction) => {
1487
+ await persistLayout(next)
1488
+ endEdit()
1489
+ },
1490
+ [persistLayout, endEdit],
1491
+ )
1492
+
1493
+ const handlePublish = React.useCallback(
1494
+ async (next: LayoutDocument, _action: SaveAction) => {
1495
+ const ok = await persistLayout(next)
1496
+ if (ok) {
1497
+ // Best-effort status flip; the layout itself is already persisted above.
1498
+ await fetch(\`/api/v1/content/\${contentType}/\${contentId}\`, {
1499
+ method: 'PUT',
1500
+ credentials: 'same-origin',
1501
+ headers: { 'Content-Type': 'application/json' },
1502
+ body: JSON.stringify({ status: 'published' }),
1503
+ }).catch(() => undefined)
1504
+ }
1505
+ endEdit()
1506
+ },
1507
+ [persistLayout, contentType, contentId, endEdit],
1508
+ )
1509
+
1510
+ // Browse-mode one-click publish from the toolbar (no editor needed).
1511
+ const handleToolbarPublish = React.useCallback(async () => {
1364
1512
  const res = await adminFetch(\`/api/v1/content/by-id/\${contentId}/publish\`, { method: 'POST' })
1365
1513
  if (res.ok) window.location.reload()
1366
1514
  }, [contentId])
1367
1515
 
1516
+ // Edit mode: the full layout editor, in place, over the live page.
1517
+ if (edit.isEditMode && edit.canEdit) {
1518
+ return (
1519
+ <div className="kywi-admin-shell kywi-frontend-edit">
1520
+ <OverlayShell
1521
+ editMode={edit}
1522
+ initialLayout={initialLayout}
1523
+ contentId={contentId}
1524
+ contentType={contentType}
1525
+ pageTitle={pageTitle}
1526
+ themeName="default"
1527
+ themeRegistry={themeRegistry}
1528
+ moduleRegistry={moduleRegistry}
1529
+ moduleComponents={editorComponents}
1530
+ onSave={handleSave}
1531
+ onPublish={handlePublish}
1532
+ />
1533
+ </div>
1534
+ )
1535
+ }
1536
+
1537
+ // Browse mode: slim toolbar + editable-region outlines over the live page.
1368
1538
  return (
1369
1539
  <>
1370
1540
  <KywiEditToolbar
@@ -1372,7 +1542,7 @@ export function KywiFrontEdit({
1372
1542
  canEdit={edit.canEdit}
1373
1543
  canPublish={edit.canPublish}
1374
1544
  onToggleEdit={edit.toggleEdit}
1375
- onPublish={handlePublish}
1545
+ onPublish={handleToolbarPublish}
1376
1546
  pageTitle={pageTitle}
1377
1547
  pageStatus={pageStatus}
1378
1548
  adminHref={adminHref}
@@ -1441,7 +1611,7 @@ export function PersonalizationRuntime({ audiences, serverSignals, selfIdWidget
1441
1611
  currentPath,
1442
1612
  serverSignals,
1443
1613
  ...(selfIdWidget
1444
- ? { selfIdWidget: { ...selfIdWidget, audiences: audiences.map((a) => a.id), currentPath } }
1614
+ ? { selfIdWidget: { ...selfIdWidget, audiences, currentPath } }
1445
1615
  : {}),
1446
1616
  })
1447
1617
  .catch(() => {
@@ -1800,7 +1970,16 @@ their Body rich text. Reach for the Layout tab when a page needs sections,
1800
1970
  columns, or modules; use the Body for simple prose.
1801
1971
 
1802
1972
  **Front-of-site editor.** Signed in as an admin, append \`?kywi-edit=1\` to any
1803
- public page to get an edit toolbar (the \`app/(site)/kywi-front-edit.tsx\` overlay).
1973
+ public page to edit it in place. You get the full **Layout editor** (the same
1974
+ drag-and-drop canvas, module palette and props panel as the admin's Layout tab),
1975
+ mounted right over the live page — add sections and modules, then **Save** (PUTs
1976
+ the layout) or **Publish** (saves + publishes). Exit the editor for the slim
1977
+ browse toolbar with the editable-region outlines. It all lives in
1978
+ \`app/(site)/kywi-front-edit.tsx\`; the editor bundle (and the admin stylesheet) is
1979
+ lazy-loaded, so pages your visitors see never carry its weight. Note: custom
1980
+ \`defineModule\` types still RENDER on the canvas, but they don't appear in the
1981
+ front-of-site editor's insert palette (it uses the built-in module set) — add
1982
+ them from the admin's Layout tab instead.
1804
1983
 
1805
1984
  ## Personalization, A/B testing & self-ID
1806
1985
 
@@ -1919,7 +2098,7 @@ lib/config.ts single import path for kywi.config.ts
1919
2098
  app/api/v1/[...kywi]/route.ts the versioned API (delegates to core)
1920
2099
  app/admin/[[...admin]]/page.tsx mounts the FULL core admin (all surfaces) at /admin
1921
2100
  lib/modules.tsx custom (defineModule) module renderers (admin + public)
1922
- app/{llms,robots,sitemap,…} root AX routes (llms.txt, robots.txt, sitemap.xml, …)${a.mode === 'coupled' ? '\napp/(site)/layout.tsx public shell: theme tokens + your header/footer\napp/(site)/site.css your site chrome styles (edit freely)\napp/(site)/[[...slug]]/page.tsx renders published pages (layout + SEO + JSON-LD + i18n)\nlib/site.ts public-render helpers: path/locale resolution, feeds, personalization\ncomponents/personalization-runtime.tsx optional client runtime (self-ID widget, live re-eval)\napp/(site)/kywi-front-edit.tsx front-of-site edit overlay (?kywi-edit=1)' : '\napp/page.tsx returns 404 (no public rendering in this mode)'}
2101
+ app/{llms,robots,sitemap,…} root AX routes (llms.txt, robots.txt, sitemap.xml, …)${a.mode === 'coupled' ? '\napp/(site)/layout.tsx public shell: theme tokens + your header/footer\napp/(site)/site.css your site chrome styles (edit freely)\napp/(site)/[[...slug]]/page.tsx renders published pages (layout + SEO + JSON-LD + i18n)\nlib/site.ts public-render helpers: path/locale resolution, feeds, personalization\ncomponents/personalization-runtime.tsx optional client runtime (self-ID widget, live re-eval)\napp/(site)/kywi-front-edit.tsx front-of-site editor: browse toolbar + in-place Layout editor (?kywi-edit=1, lazy-loaded)' : '\napp/page.tsx returns 404 (no public rendering in this mode)'}
1923
2102
  \`\`\`
1924
2103
  `
1925
2104
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-kywi-app",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "Scaffold a new Kywi CMS project — npx create-kywi-app my-site",
5
5
  "type": "module",
6
6
  "homepage": "https://github.com/Kywi-Software/kywi-cms#readme",