create-kywi-app 0.19.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/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'
@@ -468,66 +469,162 @@ public/kywi-admin.css.version
468
469
  // ── the generated render path: version stamp + landmarks ─────────────────────
469
470
 
470
471
  /**
471
- * Version of the GENERATED RENDER PATH's SHAPE — the four thin delegating files
472
- * below (`lib/kywi.ts`, `app/(site)/layout.tsx`,
473
- * `app/(site)/[[...slug]]/page.tsx`, `middleware.ts`), independent of the
474
- * package version. Bump it when their shape changes in a way an existing
475
- * project should pick up, so a project can compare its stamp against a newer
476
- * create-kywi-app and know its render path is stale.
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.
477
+ *
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.
477
485
  *
478
486
  * Exactly the {@link GUIDANCE_VERSION} / {@link guidanceStamp} convention, one
479
487
  * namespace over: `kywi-render` where the guidance files say
480
488
  * `kywi-agent-guidance`. `layered-architecture DESIGN.md` §6 names that
481
- * mechanism as the one `create-kywi-app upgrade` (plan E3) extends to these
482
- * files — this is the half E1 owns, the emission of a detectable stamp.
489
+ * mechanism as the one `create-kywi-app upgrade` extends to these files.
490
+ */
491
+ export const RENDER_VERSION = 2
492
+
493
+ /**
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
501
+ */
502
+ export function hashRenderBody(body) {
503
+ return createHash('sha256').update(body, 'utf8').digest('hex').slice(0, 12)
504
+ }
505
+
506
+ /**
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}
483
513
  */
484
- export const RENDER_VERSION = 1
514
+ export function renderStamp(body) {
515
+ return `// kywi-render v${RENDER_VERSION} (create-kywi-app ${packageVersion()}) sha256:${hashRenderBody(body)}`
516
+ }
485
517
 
486
518
  /**
487
- * The staleness stamp every generated render-path file opens with. A `//`
488
- * comment rather than the guidance files' `<!-- … -->` (these are .ts/.tsx),
489
- * so it is inert to the compiler but greppable by tooling and by an agent
490
- * asking "is this project's render path current?".
519
+ * A body plus its own stamp — what every render-path emitter returns.
520
+ * @param {string} body
491
521
  * @returns {string}
492
522
  */
493
- export function renderStamp() {
494
- return `// kywi-render v${RENDER_VERSION} (create-kywi-app ${packageVersion()})`
523
+ export function stamped(body) {
524
+ return `${renderStamp(body)}\n${body}`
525
+ }
526
+
527
+ /**
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 }}
533
+ */
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) }
495
539
  }
496
540
 
497
541
  /**
498
542
  * Parses a {@link renderStamp} back out of a file's text:
499
- * `[, renderVersion, cliVersion]`. Anchored to a whole line so a mention of the
500
- * stamp inside prose cannot match.
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.
501
547
  */
502
- export const RENDER_STAMP_RE = /^\/\/ kywi-render v(\d+) \(create-kywi-app ([^)]+)\)$/m
548
+ export const RENDER_STAMP_RE = /^\/\/ kywi-render v(\d+) \(create-kywi-app ([^)]+)\)(?: sha256:([0-9a-f]{12}))?$/m
549
+
550
+ /** @typedef {'runtime'|'middleware'|'siteLayout'|'sitePage'|'layers'|'apiRoute'|'axLlms'|'axLlmsFull'|'axSitemap'|'axRobots'|'adminHost'} RenderLandmark */
503
551
 
504
552
  /**
505
- * Landmark → its relative path, for the RENDER PATH: one entry per thin,
506
- * version-stamped file the scaffold emits at a path Next demands. Same role as
507
- * {@link AGENTS_LANDMARK_PATHS} — one source of truth for the path strings —
508
- * shared by the emitter ({@link buildFileSet}), the E8 drift guard
509
- * (`__tests__/render-drift.test.mjs`) and, in plan E3, the `upgrade` command.
510
- * @type {Record<'runtime'|'middleware'|'siteLayout'|'sitePage', string>}
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>}
511
569
  */
512
570
  export const RENDER_LANDMARK_PATHS = {
513
571
  runtime: 'lib/kywi.ts',
514
572
  middleware: 'middleware.ts',
515
573
  siteLayout: 'app/(site)/layout.tsx',
516
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',
517
582
  }
518
583
 
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
+ ]
600
+
519
601
  /**
520
602
  * Which render-path files a FRESH scaffold of the given mode emits — derived
521
603
  * statically from the mode, never from the filesystem ({@link
522
- * scaffoldLandmarks}'s counterpart for the render path). The wiring runtime and
523
- * the middleware exist in every mode; the two `app/(site)` routes are coupled
524
- * mode only.
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.
525
610
  * @param {'coupled'|'headless'|'decoupled'} mode
526
- * @returns {Record<'runtime'|'middleware'|'siteLayout'|'sitePage', boolean>}
611
+ * @returns {Record<RenderLandmark, boolean>}
527
612
  */
528
613
  export function renderLandmarks(mode) {
529
614
  const coupled = mode === 'coupled'
530
- return { runtime: true, middleware: true, siteLayout: coupled, sitePage: 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,
627
+ }
531
628
  }
532
629
 
533
630
  /**
@@ -538,26 +635,101 @@ export function renderLandmarks(mode) {
538
635
  * while an unstamped one was hand-written or predates the layered shape
539
636
  * (`layered-architecture DESIGN.md` §8) and must be ejected, not clobbered.
540
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.
541
648
  * @param {string} projectDir absolute path to the project root
542
- * @returns {Record<string, { path: string, present: boolean, version: number|null, cliVersion: string|null }>}
649
+ * @returns {Record<string, { path: string, present: boolean, version: number|null, cliVersion: string|null, hash: string|null, intact: boolean|null }>}
543
650
  */
544
651
  export function detectRenderLandmarks(projectDir) {
545
- /** @type {Record<string, { path: string, present: boolean, version: number|null, cliVersion: string|null }>} */
652
+ /** @type {Record<string, { path: string, present: boolean, version: number|null, cliVersion: string|null, hash: string|null, intact: boolean|null }>} */
546
653
  const out = {}
547
654
  for (const [key, rel] of Object.entries(RENDER_LANDMARK_PATHS)) {
548
655
  const abs = join(projectDir, rel)
549
656
  const present = existsSync(abs)
550
- const match = present ? RENDER_STAMP_RE.exec(readFileSync(abs, 'utf8')) : null
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
551
661
  out[key] = {
552
662
  path: rel,
553
663
  present,
554
664
  version: match ? Number(match[1]) : null,
555
665
  cliVersion: match ? match[2] : null,
666
+ hash,
667
+ intact: hash === null ? null : hash === hashRenderBody(body),
556
668
  }
557
669
  }
558
670
  return out
559
671
  }
560
672
 
673
+ /**
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.
689
+ *
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.
698
+ *
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.
714
+ *
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>>}
723
+ */
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' },
731
+ }
732
+
561
733
  // ── the wiring runtime (lib/kywi.ts) ──────────────────────────────────────────
562
734
 
563
735
  /**
@@ -572,8 +744,7 @@ export function detectRenderLandmarks(projectDir) {
572
744
  * config in — see `packages/core/src/next/runtime.ts`'s header.
573
745
  */
574
746
  function libKywi() {
575
- return `${renderStamp()}
576
- // This app's ONE Kywi wiring point. The render path lives in
747
+ return stamped(`// This app's ONE Kywi wiring point. The render path lives in
577
748
  // @kywi-software/core/next and reads kywi.config.ts THROUGH this runtime — a
578
749
  // package cannot import your config itself. Regenerated by
579
750
  // \`create-kywi-app upgrade\`: make your changes in kywi.config.ts, not here.
@@ -585,7 +756,7 @@ export const kywi = createKywiNextRuntime(config)
585
756
  // Named accessors for the API + AX route handlers, which want the handler
586
757
  // rather than the whole runtime.
587
758
  export const { getKywi, getKywiHandler, getActiveSite } = kywi
588
- `
759
+ `)
589
760
  }
590
761
 
591
762
  // ── middleware.ts (a re-export of core's own) ─────────────────────────────────
@@ -602,15 +773,198 @@ export const { getKywi, getKywiHandler, getActiveSite } = kywi
602
773
  * `./host` split (see that file's header in core).
603
774
  */
604
775
  function middleware() {
605
- return `${renderStamp()}
606
- // Auth gate, session refresh, the cookie→bearer bridge, the visitor/UTM/entry
776
+ return stamped(`// Auth gate, session refresh, the cookie→bearer bridge, the visitor/UTM/entry
607
777
  // cookies and .md content negotiation — all core's, all upgraded with the
608
778
  // package. Imported from its OWN entry, never the \`/next\` barrel: that barrel
609
779
  // reaches the database and sharp, which no edge bundle can carry.
610
780
  export { middleware, config } from '@kywi-software/core/next/middleware'
781
+ `)
782
+ }
783
+
784
+ // ── kywi.layers.ts + the sites/ layer files ──────────────────────────────────
785
+
786
+ /**
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
797
+ */
798
+
799
+ /**
800
+ * What a fresh scaffold emits: one site, one theme, both named `default`.
801
+ * @type {RegistryLayout}
802
+ */
803
+ export const DEFAULT_REGISTRY_LAYOUT = {
804
+ sites: [{ id: 'default', theme: 'default' }],
805
+ themes: [{ name: 'default' }],
806
+ }
807
+
808
+ /**
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}
816
+ */
817
+ export function registryLayout(scan) {
818
+ return {
819
+ sites: (scan?.sites ?? []).map(({ id, theme }) => ({ id, theme: theme ?? null })),
820
+ themes: (scan?.themes ?? []).map(({ name }) => ({ name })),
821
+ }
822
+ }
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, "\\'")}'`
830
+ }
831
+
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
+ }
843
+
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
+ }
851
+
852
+ /**
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.
858
+ */
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
+ }
878
+
879
+ /**
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
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
+ })
903
+
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')}
907
+
908
+ export const layers = {
909
+ ${body.join('\n')}
910
+ } satisfies KywiLayerRegistry
911
+ `)
912
+ }
913
+
914
+ /**
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.
918
+ */
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'
611
924
  `
612
925
  }
613
926
 
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'
936
+
937
+ export const templates = {} satisfies NonNullable<KywiLayerModule['templates']>
938
+ `
939
+ }
940
+ return `import { SiteNav } from '../../../../components/site-nav'
941
+ import type { KywiLayerModule } from '@kywi-software/core/next'
942
+
943
+ export const templates = {
944
+ nav: SiteNav,
945
+ } satisfies NonNullable<KywiLayerModule['templates']>
946
+ `
947
+ }
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
+
614
968
  // ── root layout + public site ────────────────────────────────────────────────
615
969
 
616
970
  function rootLayout() {
@@ -685,35 +1039,28 @@ export function SiteNav({ items, variant, ariaLabel, depth }: SiteNavProps) {
685
1039
  * 1. **Both CSS imports.** Next admits a global stylesheet only from a file
686
1040
  * inside `app/`, and a published package cannot import the project's own
687
1041
  * `site.css` at all (`layered-architecture DESIGN.md` §4.1).
688
- * 2. **The brand label**, because the scaffold bakes the project name in.
689
- * Core defaults it to the ACTIVE site's configured `name`; passing it
690
- * explicitly preserves what the template has always emitted.
691
- * 3. **The nav component**, wired explicitly to `components/site-nav.tsx`.
692
- * Core ships an identical default, so this line is redundant at runtime —
693
- * it is here so the customization point is VISIBLE in the generated code:
694
- * a project restyling its header edits that file and nothing else.
695
- * @param {Answers} a
1042
+ * 2. **The layer registry**, which gives core the active site/theme override
1043
+ * modules and templates while keeping this route a thin delegate.
696
1044
  */
697
- function siteLayout(a) {
698
- return `${renderStamp()}
699
- // The public site shell — header, footer, data-driven nav and the active site's
1045
+ function siteLayout() {
1046
+ return stamped(`// The public site shell — header, footer, data-driven nav and the active site's
700
1047
  // theme tokens, all resolved per request. It lives in @kywi-software/core/next;
701
- // this file wires in THIS app's runtime, brand and nav component, and imports
702
- // the stylesheets (Next takes a global stylesheet only from a file in app/, and
703
- // a package cannot import yours). Restyle via site.css, theme tokens
704
- // (kywi.config.ts) and components/site-nav.tsx — not by editing this file.
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.
705
1052
  import { createSiteLayout } from '@kywi-software/core/next'
706
1053
  import '@kywi-software/core/site/styles.css'
707
1054
  import './site.css'
708
1055
  import { kywi } from '../../lib/kywi'
709
- import { SiteNav } from '../../components/site-nav'
1056
+ import { layers } from '../../kywi.layers'
710
1057
 
711
- const layout = createSiteLayout({ runtime: kywi, brand: { label: '${escapeJsString(a.projectName)}' }, nav: SiteNav })
1058
+ const layout = createSiteLayout({ runtime: kywi, layers })
712
1059
 
713
1060
  export default layout.default
714
1061
  // A LITERAL — same reason as the page route below it in the tree.
715
1062
  export const dynamic = 'force-dynamic'
716
- `
1063
+ `)
717
1064
  }
718
1065
 
719
1066
  /** @param {Answers} a — the app's OWN public-site chrome CSS (header/footer/page). */
@@ -834,23 +1181,22 @@ function siteStyles(a) {
834
1181
  * literal `'force-dynamic'` so the two still cannot drift.
835
1182
  */
836
1183
  function siteSlugPage() {
837
- return `${renderStamp()}
838
- // The public catch-all: "/" and every published page at its Site Tree path.
1184
+ return stamped(`// The public catch-all: "/" and every published page at its Site Tree path.
839
1185
  // Content resolution, SEO metadata, JSON-LD, personalization + A/B assignment
840
1186
  // and the front-edit overlay all live in @kywi-software/core/next; this file
841
- // wires in THIS app's runtime and its custom module renderers (lib/modules.tsx).
1187
+ // wires in THIS app's runtime and generated layer registry.
842
1188
  import { createPublicPage } from '@kywi-software/core/next'
843
1189
  import { kywi } from '../../../lib/kywi'
844
- import { moduleComponents } from '../../../lib/modules'
1190
+ import { layers } from '../../../kywi.layers'
845
1191
 
846
- const page = createPublicPage({ runtime: kywi, modules: moduleComponents })
1192
+ const page = createPublicPage({ runtime: kywi, layers })
847
1193
 
848
1194
  export default page.default
849
1195
  export const generateMetadata = page.generateMetadata
850
1196
  // A LITERAL, never \`page.dynamic\`: Next parses route segment config statically
851
1197
  // and fails the build on a MemberExpression.
852
1198
  export const dynamic = 'force-dynamic'
853
- `
1199
+ `)
854
1200
  }
855
1201
 
856
1202
  /** @param {Answers} a — headless/decoupled root page (no public rendering). */
@@ -884,30 +1230,23 @@ export default function HomePage() {
884
1230
  * /api/v1/[axPath] directly.
885
1231
  */
886
1232
  function axRootRoute(axPath) {
887
- return `import { getKywiHandler } from '../../lib/kywi'
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}' })
888
1237
 
889
1238
  /** Root-level \`/${axPath}\` — delegates to the core AX generator (kywi-cms#55). */
890
- export const dynamic = 'force-dynamic'
1239
+ export const GET = route.GET
891
1240
 
892
- export async function GET(req: Request): Promise<Response> {
893
- const handler = await getKywiHandler()
894
- return handler.handle(req, ['${axPath}'])
895
- }
896
- `
1241
+ export const dynamic = 'force-dynamic'
1242
+ `)
897
1243
  }
898
1244
 
899
1245
  // ── API route (thin proxy over @kywi-software/core/host) ──────────────────────
900
1246
 
901
1247
  function apiRoute() {
902
- return `import { NextRequest, NextResponse } from 'next/server'
903
- import { getKywiHandler } from '../../../../lib/kywi'
904
- import {
905
- isTokenIssuingPath,
906
- parseSessionTokens,
907
- setAccessCookie,
908
- setRefreshCookie,
909
- clearSessionCookies,
910
- } from '@kywi-software/core/host'
1248
+ return stamped(`import { createApiRoute } from '@kywi-software/core/next'
1249
+ import { kywi } from '../../../../lib/kywi'
911
1250
 
912
1251
  /**
913
1252
  * The versioned Kywi API. Delegates every verb to core's DB-backed handler, with
@@ -917,126 +1256,52 @@ import {
917
1256
  * refresh side lives in middleware.ts. The cookie contract is shared via
918
1257
  * @kywi-software/core/host — core itself is untouched.
919
1258
  */
920
- async function handleRequest(
921
- req: NextRequest,
922
- ctx: { params: Promise<{ kywi: string[] }> },
923
- ): Promise<Response> {
924
- const segments = (await ctx.params).kywi
925
- const path = segments.join('/')
926
- const handler = await getKywiHandler()
927
- const res = await handler.handle(req, segments)
928
-
929
- if (path === 'auth/logout') {
930
- const bodyText = await res.text()
931
- const out = new NextResponse(bodyText, { status: res.status, headers: res.headers })
932
- clearSessionCookies(out)
933
- return out
934
- }
1259
+ const route = createApiRoute({ runtime: kywi })
935
1260
 
936
- if (isTokenIssuingPath(path) && res.ok) {
937
- const bodyText = await res.text()
938
- const { accessToken, refreshToken } = parseSessionTokens(bodyText)
939
- const out = new NextResponse(bodyText, { status: res.status, headers: res.headers })
940
- if (accessToken) setAccessCookie(out, accessToken)
941
- if (refreshToken) setRefreshCookie(out, refreshToken)
942
- return out
943
- }
944
-
945
- return res
946
- }
947
-
948
- export const GET = handleRequest
949
- export const POST = handleRequest
950
- export const PUT = handleRequest
951
- export const PATCH = handleRequest
952
- export const DELETE = handleRequest
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
953
1266
 
954
1267
  export const dynamic = 'force-dynamic'
955
- `
1268
+ `)
956
1269
  }
957
1270
 
958
1271
  // ── admin (the full core admin, mounted at /admin) ─────────────────────────────
959
1272
 
960
1273
  /**
961
- * The single optional-catch-all host page that mounts the ENTIRE core admin
962
- * (all surfaces) at /admin. Modeled on apps/reference — the whole scaffolded
963
- * admin is `KywiAdminApp`, upgraded via `npm update @kywi-software/core`, not
964
- * hand-written per-surface pages.
965
- */
966
- function adminCatchAllPage() {
967
- return `/**
968
- * Host page for the core-mounted admin (\`KywiAdminApp\`).
1274
+ * The admin host page — a thin delegate over `createAdminPage` (plan E3 T4).
969
1275
  *
970
- * This ONE optional-catch-all serves your entire admin at /admin — dashboard,
971
- * site tree, media, users, forms, categories, tags, audiences, settings, and
972
- * every other surface. It is a server component: it projects the full,
973
- * server-side kywi.config.ts down to the secret-free AdminRuntimeConfig
974
- * (toAdminRuntimeConfig), extracts ?next= for the login surface, and renders the
975
- * one client KywiAdminApp, which maps the URL segments to a surface and mounts
976
- * it inside the core admin chrome.
977
- *
978
- * The @kywi-software/core/admin/styles.css import is LOAD-BEARING: the admin
979
- * design-system tokens/inputs live there, so without it every surface (and the
980
- * login page) renders unstyled. Do not remove it.
981
- *
982
- * Server-side 404 (#58): the segments are resolved with the three-state
983
- * resolveSurfaceState helper. Next's notFound() fires for a real HTTP 404 ONLY
984
- * on a genuinely unknown path (status 'not-found'). A KNOWN surface held off by
985
- * the config ceiling (status 'config-disabled') is NOT hard-404'd here — it
986
- * falls through to KywiAdminApp, which renders the same in-shell "disabled"
987
- * message a runtime-disabled surface gets, so both disable paths present
988
- * consistently instead of hard-404 vs soft-hide. The \`login\` segment is exempt
989
- * (not a registry surface — the middleware's auth exception renders it
990
- * chrome-less through KywiAdminApp); the bare /admin index (segments === [])
991
- * resolves to the dashboard, so it never 404s.
992
- *
993
- * You should not need to edit this file. To disable surfaces per deployment, set
994
- * \`admin.features\` in kywi.config.ts; superAdmins can also toggle surfaces at
995
- * runtime from the "Admin Features" surface.
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).
996
1283
  */
997
- import { notFound } from 'next/navigation'
998
- import { KywiAdminApp } from '@kywi-software/core/admin/app'
999
- import { toAdminRuntimeConfig, resolveSurfaceState } from '@kywi-software/core/admin/server'
1000
- // CRITICAL: load the admin design system once for the whole mount.
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'
1001
1294
  import '@kywi-software/core/admin/styles.css'
1002
- import kywiConfig from '../../../kywi.config'
1003
- // Custom (defineModule) renderers, shared with the public layout so the Layout
1004
- // editor canvas renders them the same as the live site (#48). Empty by default.
1005
- import { moduleComponents } from '../../../lib/modules'
1006
-
1007
- export default async function AdminCatchAll({
1008
- params,
1009
- searchParams,
1010
- }: {
1011
- params: Promise<{ admin?: string[] }>
1012
- searchParams: Promise<Record<string, string | string[] | undefined>>
1013
- }) {
1014
- const { admin = [] } = await params
1015
- const sp = await searchParams
1016
- const next = typeof sp.next === 'string' ? sp.next : null
1017
-
1018
- const runtime = toAdminRuntimeConfig(kywiConfig)
1019
-
1020
- // Real HTTP 404 only for a genuinely unknown admin path. A config-disabled
1021
- // surface is a known route held off by the ceiling — it renders the consistent
1022
- // in-shell "disabled" message via KywiAdminApp rather than a hard 404 (#58).
1023
- // The login segment is not a surface (chrome-less middleware exception); the
1024
- // bare index resolves to the dashboard, so both skip the 404 pre-check.
1025
- const isLogin = admin.length === 1 && admin[0] === 'login'
1026
- if (!isLogin && resolveSurfaceState(admin, runtime.admin).status === 'not-found') {
1027
- notFound()
1028
- }
1295
+ import { kywi } from '../../../lib/kywi'
1296
+ import { layers } from '../../../kywi.layers'
1029
1297
 
1030
- return (
1031
- <KywiAdminApp
1032
- runtime={runtime}
1033
- segments={admin}
1034
- loginNext={next}
1035
- moduleComponents={moduleComponents}
1036
- />
1037
- )
1038
- }
1039
- `
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
+ `)
1040
1305
  }
1041
1306
 
1042
1307
  // ── custom module renderers (lib/modules.tsx) ─────────────────────────────────
@@ -1402,7 +1667,7 @@ export const GUIDANCE_VERSION = 1
1402
1667
  * @returns {string}
1403
1668
  */
1404
1669
  let _pkgVersion
1405
- function packageVersion() {
1670
+ export function packageVersion() {
1406
1671
  if (_pkgVersion === undefined) {
1407
1672
  const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json')
1408
1673
  _pkgVersion = JSON.parse(readFileSync(pkgPath, 'utf8')).version
@@ -1731,6 +1996,12 @@ export function buildFileSet(answers) {
1731
1996
  'lib/kywi.ts': libKywi(),
1732
1997
  // custom (defineModule) module renderers, shared by admin + public layout (#48)
1733
1998
  'lib/modules.tsx': libModules(),
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),
1734
2005
  // host wiring: a re-export of core's own middleware
1735
2006
  // (RENDER_LANDMARK_PATHS.middleware).
1736
2007
  'middleware.ts': middleware(),
@@ -1759,7 +2030,7 @@ export function buildFileSet(answers) {
1759
2030
  // `pnpm up @kywi-software/core` reaches it. Nothing here may grow a copy of
1760
2031
  // a core helper; `__tests__/render-drift.test.mjs` (verification E8) fails
1761
2032
  // the build if it does.
1762
- files['app/(site)/layout.tsx'] = siteLayout(answers)
2033
+ files['app/(site)/layout.tsx'] = siteLayout()
1763
2034
  files['app/(site)/site.css'] = siteStyles(answers)
1764
2035
  // 'use client' wrapper around core's shared nav renderer — required because
1765
2036
  // BUILT_IN_MODULE_COMPONENTS is exported from a 'use client' core module, so