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/README.md +156 -12
- 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 +458 -187
- 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'
|
|
@@ -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
|
|
472
|
-
*
|
|
473
|
-
*
|
|
474
|
-
*
|
|
475
|
-
*
|
|
476
|
-
*
|
|
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`
|
|
482
|
-
|
|
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
|
|
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
|
-
*
|
|
488
|
-
*
|
|
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
|
|
494
|
-
return
|
|
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
|
|
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 ([^)]+)\)
|
|
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
|
|
506
|
-
*
|
|
507
|
-
* {@link AGENTS_LANDMARK_PATHS} — one source of
|
|
508
|
-
* shared by the emitter ({@link buildFileSet}),
|
|
509
|
-
* (`__tests__/render-drift.test.mjs`) and, in plan E3, the
|
|
510
|
-
*
|
|
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
|
|
523
|
-
* the
|
|
524
|
-
* mode
|
|
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<
|
|
611
|
+
* @returns {Record<RenderLandmark, boolean>}
|
|
527
612
|
*/
|
|
528
613
|
export function renderLandmarks(mode) {
|
|
529
614
|
const coupled = mode === 'coupled'
|
|
530
|
-
return {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
689
|
-
*
|
|
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(
|
|
698
|
-
return
|
|
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
|
|
702
|
-
// the stylesheets (Next takes a global stylesheet only from a file in
|
|
703
|
-
// a package cannot import yours). Restyle via site.css, theme tokens
|
|
704
|
-
// (kywi.config.ts) and components/site-nav.tsx
|
|
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 {
|
|
1056
|
+
import { layers } from '../../kywi.layers'
|
|
710
1057
|
|
|
711
|
-
const layout = createSiteLayout({ runtime: kywi,
|
|
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
|
|
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
|
|
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 {
|
|
1190
|
+
import { layers } from '../../../kywi.layers'
|
|
845
1191
|
|
|
846
|
-
const page = createPublicPage({ runtime: kywi,
|
|
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 {
|
|
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
|
|
1239
|
+
export const GET = route.GET
|
|
891
1240
|
|
|
892
|
-
export
|
|
893
|
-
|
|
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 {
|
|
903
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
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
|
|
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
|
-
*
|
|
971
|
-
*
|
|
972
|
-
*
|
|
973
|
-
*
|
|
974
|
-
*
|
|
975
|
-
*
|
|
976
|
-
*
|
|
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
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
//
|
|
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
|
|
1003
|
-
|
|
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
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
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(
|
|
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
|