create-kywi-app 0.13.0 → 0.15.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/assets/agent-patterns.md +153 -17
- package/lib/templates.mjs +447 -54
- package/package.json +1 -1
package/assets/agent-patterns.md
CHANGED
|
@@ -185,16 +185,36 @@ every request — so writing a new definition changes every page that uses it
|
|
|
185
185
|
immediately, published pages included, with nothing to re-publish. That is the
|
|
186
186
|
whole point, and it is also the thing to be careful with.
|
|
187
187
|
|
|
188
|
-
**What can be one.** A single module, a whole section (band),
|
|
189
|
-
A-B tested band with its variants intact
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
component
|
|
188
|
+
**What can be one.** A single module, a whole section (band), a personalized /
|
|
189
|
+
A-B tested band with its variants intact, or a personalized / A-B tested **module**
|
|
190
|
+
— the four `type` values are `module`, `section`, `variantContainer` (section
|
|
191
|
+
level) and `moduleVariantContainer` (column level). Variants travel with the
|
|
192
|
+
component in both container kinds; which arm a visitor sees is still decided per
|
|
193
|
+
request, per placement, so the same personalized component can sit on five pages
|
|
194
|
+
and target correctly on each.
|
|
195
|
+
|
|
196
|
+
**Which container kind?** `variantContainer` is a whole band that varies —
|
|
197
|
+
different sections, different layouts per audience. `moduleVariantContainer` is
|
|
198
|
+
ONE module in a column that varies while everything around it stays put: a hero
|
|
199
|
+
headline that changes for paid traffic, a CTA button that is A/B tested inside an
|
|
200
|
+
otherwise fixed section. Reach for the module-level one when only the content of
|
|
201
|
+
a single block differs; personalizing the whole band to change one headline makes
|
|
202
|
+
every other module in it a copy the owner has to maintain twice.
|
|
203
|
+
|
|
204
|
+
**Arms are never independently linkable.** A variant arm's module cannot be saved
|
|
205
|
+
as a component of its own, and cannot be pointed at one. An arm is a challenger
|
|
206
|
+
the owner edits freely; wiring it to shared content would mean editing one page's
|
|
207
|
+
variant rewrote every other page's, which is the opposite of what a variant is
|
|
208
|
+
for. Save the whole container instead — that is what the fourth kind is.
|
|
193
209
|
|
|
194
210
|
**In the editors** (front-of-site overlay or the admin Layout tab): select the
|
|
195
|
-
module, section or
|
|
196
|
-
replaced in place by a linked instance, marked with an outline
|
|
197
|
-
the component. Place
|
|
211
|
+
module, section, band or personalized module → **Save as component** → name it.
|
|
212
|
+
The selection is replaced in place by a linked instance, marked with an outline
|
|
213
|
+
and a pill naming the component. Place a section or band again from the section
|
|
214
|
+
picker's *Components* rows; place a module or a personalized module by dropping
|
|
215
|
+
the palette's **Component** block into a column and choosing it in the props
|
|
216
|
+
rail — one list, both kinds, because the owner's question there is "which saved
|
|
217
|
+
thing goes here".
|
|
198
218
|
|
|
199
219
|
**Editing a linked instance ends in a question.** There are no per-prop overrides
|
|
200
220
|
in v1: an instance renders its component exactly. So when you edit one and save,
|
|
@@ -224,8 +244,10 @@ per-site authorization applies.
|
|
|
224
244
|
|
|
225
245
|
1. `POST /api/v1/components` — `{ name, type, definition, moduleType?, description? }`.
|
|
226
246
|
The `definition` is the node itself: a `section` component stores the whole
|
|
227
|
-
`LayoutSection`, a `variantContainer` the whole container, a
|
|
228
|
-
|
|
247
|
+
`LayoutSection`, a `variantContainer` the whole section-level container, a
|
|
248
|
+
`moduleVariantContainer` the whole column-level one (its `defaultModule` and
|
|
249
|
+
`variants`), and a `module` component stores `{ props, style }` and names its
|
|
250
|
+
`moduleType`.
|
|
229
251
|
2. **Place it** by writing a layout that carries the link. There is no separate
|
|
230
252
|
"insert" endpoint: `PUT /api/v1/content/:type/:id/layout` with the definition's
|
|
231
253
|
own shape plus `componentId` on the node, giving the placement a fresh `id` of
|
|
@@ -247,8 +269,26 @@ per-site authorization applies.
|
|
|
247
269
|
definition saved from an editor carries no `winnerId` at all; a definition
|
|
248
270
|
POSTed straight over the API can, so read what you are placing. A
|
|
249
271
|
`personalization` container has no binding and needs none.
|
|
272
|
+
|
|
273
|
+
**The same rule, word for word, for `moduleVariantContainer`.** It is a
|
|
274
|
+
placement root exactly as its section-level sibling is: the placement's
|
|
275
|
+
`experimentId` / `winnerId` win over the definition's, so an `ab_test`
|
|
276
|
+
placement you write over the API must carry a fresh `experimentId` and no
|
|
277
|
+
`winnerId`. The node goes in a COLUMN (`section.columns[].nodes[]`), not in a
|
|
278
|
+
region, and it carries its own `id` plus `componentId`, with the definition's
|
|
279
|
+
`defaultModule` / `variants` copied inline as the fallback copy.
|
|
280
|
+
|
|
281
|
+
**Do not bury an `ab_test` module container inside a `section` component.**
|
|
282
|
+
There it is not a placement root, nothing re-binds it per placement, and every
|
|
283
|
+
page placing that section reports into one shared run with one `winnerId`
|
|
284
|
+
deciding them all. Both editors refuse it and so does `POST /components`. A
|
|
285
|
+
`personalization` module container inside a section component is fine —
|
|
286
|
+
audiences are content and travel with the component.
|
|
250
287
|
3. `GET /api/v1/components/:id/usage` — where it is placed (`instances`, `pages`,
|
|
251
|
-
the page ids and node ids,
|
|
288
|
+
the page ids and node ids, with trashed pages and **saved layouts** each
|
|
289
|
+
counted separately: `trashedInstances`/`trashedPages` and
|
|
290
|
+
`blueprints`/`blueprintInstances`/`blueprintItems`). `instances`/`pages` mean
|
|
291
|
+
live pages only, so the three partitions never double-count. This is the
|
|
252
292
|
blast radius; read it *before* changing a definition.
|
|
253
293
|
4. `PUT /api/v1/components/:id` — a body with `definition` **is the propagation
|
|
254
294
|
write**: every linked instance renders the new definition on its next request.
|
|
@@ -257,15 +297,20 @@ per-site authorization applies.
|
|
|
257
297
|
5. `POST /api/v1/components/:id/detach-all` — materialize the definition into every
|
|
258
298
|
page that links it and drop the links; each page keeps rendering identically.
|
|
259
299
|
6. `DELETE /api/v1/components/:id` — refuses (409) while instances exist; add
|
|
260
|
-
`?detachAll=1` to detach-then-delete.
|
|
300
|
+
`?detachAll=1` to detach-then-delete. "Instances" includes placements held by
|
|
301
|
+
trashed pages **and by saved layouts** (kywi-cms#156), so a component nothing
|
|
302
|
+
live uses can still refuse to delete — check `blueprints` in the usage payload
|
|
303
|
+
before assuming a one-step delete. `GET /api/v1/components?withUsage=1` folds
|
|
261
304
|
the counts into a list, in one read.
|
|
262
305
|
|
|
263
|
-
**
|
|
306
|
+
**Three refusals to design around.** A component definition may not link to another
|
|
264
307
|
component (one level only, deliberately: a nested link is invisible to usage counts
|
|
265
308
|
and the delete gate, so the editors *materialize* an inner component when you save
|
|
266
309
|
a section that contains one — the inner instance stops following its own source, and
|
|
267
310
|
the dialog says so). And a stored layout that a live layout could not hold is
|
|
268
311
|
rejected at the door, because detaching would copy it into every page that links it.
|
|
312
|
+
And a `section` or `variantContainer` definition may not contain an **A/B tested**
|
|
313
|
+
module container, for the shared-run reason above.
|
|
269
314
|
|
|
270
315
|
**Component writes bypass approvals in v1.** They are not routed through
|
|
271
316
|
changesets or the review workflow — a `PUT` with a definition is live on publish
|
|
@@ -360,11 +405,66 @@ authored elsewhere in the admin).
|
|
|
360
405
|
| `personalizationBadge` | Optional inline status pill that opens the global transparency panel — not needed on ordinary pages (§8) |
|
|
361
406
|
| `socialShare` | Share links for a URL/title across networks |
|
|
362
407
|
| `map` | Embedded map at a lat/lng or address |
|
|
363
|
-
| `cookieConsent` | Consent banner with message + privacy-policy link |
|
|
408
|
+
| `cookieConsent` | Consent banner with message + privacy-policy link. Accept All / Reject All / Customize write the `kywi_consent` cookie, and the engine's optional cookie writes are gated on it (see below) |
|
|
364
409
|
| `component` | Places a saved reusable component by slug (§4) |
|
|
365
410
|
| `form` | Renders a form built in the Forms admin (§6) |
|
|
366
411
|
| `formEmbed` | Embeds a form by slug — lighter-weight placement of the same |
|
|
367
412
|
|
|
413
|
+
#### Cookie consent is enforced, not decorative
|
|
414
|
+
|
|
415
|
+
`cookieConsent` is wired to the engine. Its buttons write `kywi_consent`, and
|
|
416
|
+
`@kywi-software/core`'s `audiences/consent.ts` holds the one map from cookie name
|
|
417
|
+
to category that every optional write is gated on:
|
|
418
|
+
|
|
419
|
+
| Cookie | Category | Gated? |
|
|
420
|
+
|---|---|---|
|
|
421
|
+
| `kywi_visitor`, `kywi_signals`, `kywi_utm`, `kywi_audience`, `kywi_known` | `personalization` | yes |
|
|
422
|
+
| `kywi_consent`, `kywi_optout`, `kywi_preview_init` | `essential` | no — necessary cookies are never gated |
|
|
423
|
+
|
|
424
|
+
A gated cookie is neither **written** nor **read** without consent: a visitor who
|
|
425
|
+
accepted last month and rejects today has those cookies deleted, and any that
|
|
426
|
+
survive (`kywi_signals` is HttpOnly) are ignored on the way in. Rejecting is a
|
|
427
|
+
revocation, not just a pause.
|
|
428
|
+
|
|
429
|
+
One exemption: an audience the visitor picks **by hand** in the transparency
|
|
430
|
+
panel is stored as an `essential` preference, on the same argument as
|
|
431
|
+
`kywi_optout` — a control they operated deliberately has to be honoured, or the
|
|
432
|
+
panel is lying about what its buttons do. Only the runtime's automatic
|
|
433
|
+
"remember what I resolved" write is gated. The cookie records which it was
|
|
434
|
+
(`switcher:<id>` vs a bare id), so the read side and the revocation sweep apply
|
|
435
|
+
the same rule: rejecting personalization deletes an automatic pin but keeps one
|
|
436
|
+
the visitor chose.
|
|
437
|
+
|
|
438
|
+
**Undecided means not consented.** Until a visitor answers the banner, none of
|
|
439
|
+
the personalization cookies are written: pages still render, audiences still
|
|
440
|
+
resolve for the request in hand, experiments still return a variant — nothing is
|
|
441
|
+
persisted, so the visitor keeps seeing default content instead of being tracked.
|
|
442
|
+
A site that wants personalization to persist must place this module (or write
|
|
443
|
+
`kywi_consent` some other way). Adding a new cookie without classifying it in
|
|
444
|
+
that map fails closed, and a core test catches the omission.
|
|
445
|
+
|
|
446
|
+
**The escape hatch.** `theme.personalization.requireConsent` in `kywi.config.ts`
|
|
447
|
+
defaults to `true` — the behaviour above. Set it to `false` only when this site
|
|
448
|
+
collects consent with EXTERNAL tooling (a third-party CMP) that suppresses
|
|
449
|
+
cookies its own way; the gate then treats every category as allowed and the site
|
|
450
|
+
takes responsibility for compliance. Because Next.js edge middleware cannot read
|
|
451
|
+
`kywi.config.ts`, mirror the same value as `KYWI_REQUIRE_CONSENT=false` in the
|
|
452
|
+
environment. **Set both or neither** — `createKywiApiHandler` throws at startup
|
|
453
|
+
when they disagree, so a mismatch fails the deploy rather than silently leaving
|
|
454
|
+
the middleware and the API at odds about whether a visitor may be tracked. On a
|
|
455
|
+
site with no other consent mechanism, `false` is simply the non-compliant
|
|
456
|
+
setting.
|
|
457
|
+
|
|
458
|
+
The banner ships **hidden** (`kywi-consent-pending` on the wrapper) and reveals
|
|
459
|
+
itself after mount only for a visitor with no decision on file — a theme must not
|
|
460
|
+
force it visible, or every returning visitor gets a flash of a consent ask they
|
|
461
|
+
already answered.
|
|
462
|
+
|
|
463
|
+
When consent is required and no page places the module, the API logs a one-time
|
|
464
|
+
warning on its first request: nobody can grant consent, so every personalization
|
|
465
|
+
cookie stays suppressed and audiences, A/B bucketing and UTM attribution serve
|
|
466
|
+
default content to everyone.
|
|
467
|
+
|
|
368
468
|
### Which do I reach for? (in order)
|
|
369
469
|
|
|
370
470
|
1. **A built-in already does it** → use it. Read the tables before inventing
|
|
@@ -409,9 +509,12 @@ module's *first* prop inline-editable on the page when its type is `text`,
|
|
|
409
509
|
directly on the live page, and `props: { variant, headline, … }` cannot.
|
|
410
510
|
|
|
411
511
|
Also: create **saved layouts** (Layouts admin) as page templates — "Landing
|
|
412
|
-
page", "Case study" — so new pages start from a consistent skeleton.
|
|
413
|
-
|
|
414
|
-
|
|
512
|
+
page", "Case study" — so new pages start from a consistent skeleton. A saved
|
|
513
|
+
layout may link reusable components like any page: the link is indexed, counted
|
|
514
|
+
in the component's usage under "saved layouts", and carried into every page
|
|
515
|
+
stamped out of that layout. Hand-written JSX stays fine for genuinely fixed
|
|
516
|
+
chrome (a bespoke 404, legal boilerplate) — but if marketing will ever want to
|
|
517
|
+
swap a headline, it's a layout page.
|
|
415
518
|
|
|
416
519
|
### Navigation: menus vs. the site tree
|
|
417
520
|
|
|
@@ -582,6 +685,39 @@ Four traps, each of which fails silently:
|
|
|
582
685
|
Standing rule: **the default arm must be complete on its own.** It is what
|
|
583
686
|
search engines, answer engines, and every opted-out visitor receive.
|
|
584
687
|
|
|
688
|
+
**What actually reaches the visitor (kywi-cms#167).** A published page ships
|
|
689
|
+
only the arm that visitor is served — the other arms of every section- and
|
|
690
|
+
module-level container are stripped on the server, before the document crosses
|
|
691
|
+
into the client renderer (`pruneLayoutToServedArms`, run last in the public
|
|
692
|
+
render path). So view-source shows one arm, not all of them, and the same is
|
|
693
|
+
true of page variants (`applyPageVariant` has always dropped those). Two things
|
|
694
|
+
follow. First, arm copy is not shared with the people it is not for, so an
|
|
695
|
+
unlaunched offer or a segment-specific price is not sitting in the HTML of
|
|
696
|
+
everyone else's page. Second, **the audience RULES still are** — the client
|
|
697
|
+
runtime is handed the active audiences so it can re-evaluate on client signals,
|
|
698
|
+
so how a visitor is classified remains public even though the content each class
|
|
699
|
+
gets is not. Author rules accordingly: they are readable.
|
|
700
|
+
|
|
701
|
+
**One exception, and it is a live one: the retired `variantContainer` MODULE.**
|
|
702
|
+
Pruning covers the two container NODES. It cannot cover the legacy module,
|
|
703
|
+
because that module's arms are not arms of a container at all — they are HTML
|
|
704
|
+
strings in its own props, and it renders every one of them into the DOM by
|
|
705
|
+
design, hiding the losing ones with `display: none` for the client runtime to
|
|
706
|
+
reveal. So a page that still carries one keeps publishing all of its arms in
|
|
707
|
+
plain markup, where `view-source` and "inspect element" both find them
|
|
708
|
+
immediately. Nothing about #167 changed that, and no amount of server-side
|
|
709
|
+
resolution can: the shape is client-swap by construction. If a page you are
|
|
710
|
+
working on has one, converting it is the fix — `kywi upgrade:variant-containers`
|
|
711
|
+
(landing separately, for kywi-cms#172) rewrites existing placements into the
|
|
712
|
+
`moduleVariantContainer` node, which is server-resolved and therefore pruned.
|
|
713
|
+
Until it is converted, treat that module's arms as public, whatever the audience
|
|
714
|
+
binding says.
|
|
715
|
+
|
|
716
|
+
The editor is the deliberate exception — the front-edit overlay and the admin
|
|
717
|
+
layout editor are handed the STORED document, every arm intact, because that is
|
|
718
|
+
what is being edited. If you are writing a host page, keep the two apart: prune
|
|
719
|
+
what you pass to `<KywiLayout>`, and pass the editor the layout you loaded.
|
|
720
|
+
|
|
585
721
|
### Transparency: automatic, not something you place
|
|
586
722
|
|
|
587
723
|
Once the client runtime is loaded, every personalized (or opted-out-with-a-
|
package/lib/templates.mjs
CHANGED
|
@@ -44,6 +44,22 @@ const CORE_RANGE = (v) => `^${v}`
|
|
|
44
44
|
|
|
45
45
|
/** @param {Answers} a */
|
|
46
46
|
function packageJson(a) {
|
|
47
|
+
// Coupled mode's front-of-site editor (kywi-front-edit.tsx) needs the admin
|
|
48
|
+
// design system's stylesheet available at a plain, fetchable URL
|
|
49
|
+
// (/kywi-admin.css) — it is injected via a runtime <link> tag rather than a
|
|
50
|
+
// JS import (kywi-cms#130 follow-up: an import, even a dynamic one, still
|
|
51
|
+
// ships the stylesheet on every public route — see ensureAdminStylesheet in
|
|
52
|
+
// kywi-front-edit.tsx). Unlike public/kywi.js (opt-in, manually placed when
|
|
53
|
+
// personalization.clientRuntime is enabled — see README), this is a CORE
|
|
54
|
+
// feature every coupled-mode app has, so the sync is automatic.
|
|
55
|
+
const syncScripts =
|
|
56
|
+
a.mode === 'coupled'
|
|
57
|
+
? {
|
|
58
|
+
'sync:kywi-admin-css': 'node scripts/sync-kywi-admin-css.mjs',
|
|
59
|
+
predev: 'npm run sync:kywi-admin-css',
|
|
60
|
+
prebuild: 'npm run sync:kywi-admin-css',
|
|
61
|
+
}
|
|
62
|
+
: {}
|
|
47
63
|
return JSON.stringify(
|
|
48
64
|
{
|
|
49
65
|
name: a.projectName,
|
|
@@ -54,6 +70,7 @@ function packageJson(a) {
|
|
|
54
70
|
dev: 'next dev',
|
|
55
71
|
build: 'next build',
|
|
56
72
|
start: 'next start',
|
|
73
|
+
...syncScripts,
|
|
57
74
|
// Plain `kywi migrate` (not --push): the CLI bootstraps a migration
|
|
58
75
|
// journal via `drizzle-kit generate` on first run and grows it on
|
|
59
76
|
// every schema-bearing release after (kywi-cms#141) — a fresh
|
|
@@ -101,6 +118,63 @@ function packageJson(a) {
|
|
|
101
118
|
) + '\n'
|
|
102
119
|
}
|
|
103
120
|
|
|
121
|
+
// ── scripts/sync-kywi-admin-css.mjs ─────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Syncs the admin design system's stylesheet (and a cache-busting version
|
|
125
|
+
* marker) into public/, so the front-edit overlay's `ensureAdminStylesheet`
|
|
126
|
+
* can load it via a plain `<link>` tag (kywi-cms#130) instead of a JS/CSS
|
|
127
|
+
* module import — which Next's App Router CSS collection would otherwise
|
|
128
|
+
* bundle into every public route regardless of dynamic() boundaries.
|
|
129
|
+
*
|
|
130
|
+
* Runs on every predev/prebuild (package.json's `sync:kywi-admin-css`) — NOT
|
|
131
|
+
* committed (see the generated .gitignore) — so a core upgrade always serves
|
|
132
|
+
* fresh admin CSS under a cache-busted URL (`?v=<core version>`) rather than
|
|
133
|
+
* a stale one a CDN/browser cached under the unversioned `/kywi-admin.css`
|
|
134
|
+
* path.
|
|
135
|
+
*/
|
|
136
|
+
function syncKywiAdminCssScript() {
|
|
137
|
+
return `#!/usr/bin/env node
|
|
138
|
+
import { copyFileSync, writeFileSync, existsSync, readFileSync } from 'node:fs'
|
|
139
|
+
import { dirname, join } from 'node:path'
|
|
140
|
+
import { createRequire } from 'node:module'
|
|
141
|
+
|
|
142
|
+
const require = createRequire(import.meta.url)
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const cssPath = require.resolve('@kywi-software/core/admin/styles.css')
|
|
146
|
+
|
|
147
|
+
// Find @kywi-software/core's OWN package.json (for its version) by walking
|
|
148
|
+
// up from the resolved stylesheet path: core's package.json "exports" map
|
|
149
|
+
// does not list "./package.json", so require.resolve() can't reach it
|
|
150
|
+
// directly.
|
|
151
|
+
let dir = dirname(cssPath)
|
|
152
|
+
let version = ''
|
|
153
|
+
for (;;) {
|
|
154
|
+
const pkgPath = join(dir, 'package.json')
|
|
155
|
+
if (existsSync(pkgPath)) {
|
|
156
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
|
157
|
+
if (pkg.name === '@kywi-software/core') {
|
|
158
|
+
version = pkg.version
|
|
159
|
+
break
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const parent = dirname(dir)
|
|
163
|
+
if (parent === dir) break // reached the filesystem root without finding it
|
|
164
|
+
dir = parent
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
copyFileSync(cssPath, 'public/kywi-admin.css')
|
|
168
|
+
// A plain-text sibling asset, not a JS/JSON import — ensureAdminStylesheet
|
|
169
|
+
// fetches it at runtime, so this never touches the CSS/JS module graph.
|
|
170
|
+
writeFileSync('public/kywi-admin.css.version', version)
|
|
171
|
+
console.log(\`synced public/kywi-admin.css (v\${version || 'unknown'})\`)
|
|
172
|
+
} catch (e) {
|
|
173
|
+
console.warn('kywi-admin.css sync skipped:', e.message)
|
|
174
|
+
}
|
|
175
|
+
`
|
|
176
|
+
}
|
|
177
|
+
|
|
104
178
|
// ── kywi.config.ts ────────────────────────────────────────────────────────────
|
|
105
179
|
|
|
106
180
|
/** @param {Answers} a */
|
|
@@ -258,25 +332,38 @@ function tsconfig() {
|
|
|
258
332
|
/**
|
|
259
333
|
* Ambient declaration for stylesheet imports.
|
|
260
334
|
*
|
|
261
|
-
* WHY THIS FILE EXISTS (found by the scaffold compile gate, kywi-cms#147 T6
|
|
262
|
-
* `app/(site)/kywi-front-edit.tsx`
|
|
263
|
-
* `await import('@kywi-software/core/admin/
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
* so without this declaration a freshly scaffolded project failed
|
|
269
|
-
* generated code — which nothing caught, because the
|
|
270
|
-
* stylesheet in statement form and the
|
|
271
|
-
* type-checked at all.
|
|
335
|
+
* WHY THIS FILE EXISTS (found by the scaffold compile gate, kywi-cms#147 T6;
|
|
336
|
+
* history below). `app/(site)/kywi-front-edit.tsx` originally pulled the
|
|
337
|
+
* admin design system in via `await import('@kywi-software/core/admin/
|
|
338
|
+
* styles.css')` inside `next/dynamic`'s factory. TypeScript treats a
|
|
339
|
+
* *dynamic* import of a non-code file as an ordinary module import and
|
|
340
|
+
* reports TS2307 for it, unlike a top-level `import 'x.css'` statement, which
|
|
341
|
+
* it lets through as a resource import. Next type-checks during `next
|
|
342
|
+
* build`, so without this declaration a freshly scaffolded project failed
|
|
343
|
+
* its own build on generated code — which nothing caught, because the
|
|
344
|
+
* reference app imported that stylesheet in statement form and the
|
|
345
|
+
* scaffold's own output had never been type-checked at all.
|
|
346
|
+
*
|
|
347
|
+
* kywi-cms#130 follow-up: that dynamic import turned out to ship the
|
|
348
|
+
* stylesheet to every public route regardless of the `next/dynamic`
|
|
349
|
+
* wrapping (Next's App Router CSS collection bundles every css import it can
|
|
350
|
+
* see in the reachable module graph, dynamic or not) — see
|
|
351
|
+
* `ensureAdminStylesheet` in `frontEditOverlay()`, which replaced it with a
|
|
352
|
+
* runtime `<link>` tag. This ambient declaration is no longer load-bearing
|
|
353
|
+
* for that one case, but it costs nothing to keep as a general-purpose `*.css`
|
|
354
|
+
* declaration for any future dynamic stylesheet import.
|
|
272
355
|
*
|
|
273
356
|
* `*.module.css` is deliberately NOT declared here: Next's own types declare it
|
|
274
357
|
* with a real `classes` record, and the more specific pattern wins.
|
|
275
358
|
*/
|
|
276
359
|
function cssTypes() {
|
|
277
|
-
return `// Stylesheet imports
|
|
278
|
-
//
|
|
279
|
-
//
|
|
360
|
+
return `// Stylesheet imports. CSS Modules are typed by Next itself — the more
|
|
361
|
+
// specific pattern wins. (History: originally required for a dynamic
|
|
362
|
+
// \`await import('…/styles.css')\` in app/(site)/kywi-front-edit.tsx, which
|
|
363
|
+
// TypeScript checks like any other module — see cssTypes' doc comment in
|
|
364
|
+
// templates.mjs. That import was replaced by a runtime \`<link>\` tag,
|
|
365
|
+
// kywi-cms#130 follow-up, so nothing in this generated app currently needs
|
|
366
|
+
// this declaration — kept for any future dynamic stylesheet import.)
|
|
280
367
|
declare module '*.css'
|
|
281
368
|
`
|
|
282
369
|
}
|
|
@@ -359,6 +446,12 @@ function gitignore() {
|
|
|
359
446
|
.env
|
|
360
447
|
.env.local
|
|
361
448
|
*.log
|
|
449
|
+
|
|
450
|
+
# Regenerated on every predev/prebuild (scripts/sync-kywi-admin-css.mjs) from
|
|
451
|
+
# the installed @kywi-software/core package — kywi-cms#130 follow-up. Coupled
|
|
452
|
+
# mode only, but harmless to list unconditionally.
|
|
453
|
+
public/kywi-admin.css
|
|
454
|
+
public/kywi-admin.css.version
|
|
362
455
|
`
|
|
363
456
|
}
|
|
364
457
|
|
|
@@ -459,6 +552,7 @@ import {
|
|
|
459
552
|
listSelfIdFields,
|
|
460
553
|
COOKIE_NAMES,
|
|
461
554
|
} from '@kywi-software/core/audiences'
|
|
555
|
+
import { cookieAllowedByHeader } from '@kywi-software/core/audiences/consent'
|
|
462
556
|
import type {
|
|
463
557
|
Audience,
|
|
464
558
|
VisitorSignals,
|
|
@@ -670,6 +764,12 @@ export interface PublicPersonalization {
|
|
|
670
764
|
audienceId: string | null
|
|
671
765
|
/** Stable visitor id (middleware cookie/header); keys A/B assignment. */
|
|
672
766
|
visitorId: string
|
|
767
|
+
/**
|
|
768
|
+
* True when \`visitorId\` is a durable identity this visitor consented to
|
|
769
|
+
* (kywi-cms#91). False means it was minted for this request alone — nothing
|
|
770
|
+
* may be written against it, including an A/B exposure.
|
|
771
|
+
*/
|
|
772
|
+
tracked: boolean
|
|
673
773
|
/** Active audiences evaluated — handed to the client runtime for re-eval. */
|
|
674
774
|
audiences: Audience[]
|
|
675
775
|
/** Server-resolved signals — handed to the client runtime as serverSignals. */
|
|
@@ -696,8 +796,26 @@ export async function resolvePersonalization(
|
|
|
696
796
|
layout: LayoutDocument | null | undefined,
|
|
697
797
|
): Promise<PublicPersonalization> {
|
|
698
798
|
const [h, cookieStore] = await Promise.all([headers(), cookies()])
|
|
799
|
+
// One resolution of \`theme.personalization.requireConsent\` for the whole
|
|
800
|
+
// request, handed to every gate below — the visitor-id read AND the audience
|
|
801
|
+
// engine's own collectors. They read the same cookies; disagreeing about
|
|
802
|
+
// whether consent is required would personalize half a page.
|
|
803
|
+
const gate = { requireConsent: requireConsentEnabled() }
|
|
804
|
+
// \`kywi_visitor\` is a \`personalization\` cookie, so without consent it is
|
|
805
|
+
// neither written nor read (kywi-cms#91) — the middleware hands this request a
|
|
806
|
+
// THROWAWAY id instead, freshly minted and different on the next request.
|
|
807
|
+
// \`tracked\` carries that fact to everything downstream that would otherwise
|
|
808
|
+
// write the id to the database.
|
|
809
|
+
const tracked = cookieAllowedByHeader(COOKIE_NAMES.VISITOR, h.get('cookie'), gate)
|
|
810
|
+
// The stored cookie is read THROUGH that gate too, not just written through
|
|
811
|
+
// it: a visitor who accepted last month and has since rejected still carries
|
|
812
|
+
// one, and honouring it would re-identify them under the identity they
|
|
813
|
+
// revoked. The header bridge is unaffected — the middleware already applied
|
|
814
|
+
// the same rule when it minted the id this request carries.
|
|
699
815
|
const visitorId =
|
|
700
|
-
h.get('x-kywi-visitor') ??
|
|
816
|
+
h.get('x-kywi-visitor') ??
|
|
817
|
+
(tracked ? cookieStore.get(COOKIE_NAMES.VISITOR)?.value : undefined) ??
|
|
818
|
+
'vis-anon'
|
|
701
819
|
const host = h.get('host') ?? 'localhost'
|
|
702
820
|
const url = h.get('x-kywi-url') ?? \`http://\${host}/\`
|
|
703
821
|
|
|
@@ -706,17 +824,28 @@ export async function resolvePersonalization(
|
|
|
706
824
|
const request = new Request(url, { headers: reqHeaders })
|
|
707
825
|
|
|
708
826
|
const { db, siteId } = runtime
|
|
709
|
-
|
|
827
|
+
// \`gate\` reaches the engine's server collectors, so a site whose consent is
|
|
828
|
+
// owned by an external CMP (\`requireConsent: false\`) has its stored UTM /
|
|
829
|
+
// visitor / known / pinned-audience cookies read here too, not just by the
|
|
830
|
+
// middleware and the runtime routes.
|
|
831
|
+
const { result, audiences } = await evaluateActiveAudiences(db, siteId, request, gate)
|
|
710
832
|
|
|
711
833
|
const previewId = cookieStore.get(COOKIE_NAMES.PREVIEW_INIT)?.value || null
|
|
712
834
|
const audienceId = isActiveAudience(previewId, audiences) ? previewId : result.winningAudienceId
|
|
713
835
|
|
|
714
|
-
const experimentAssignments = await resolveExperimentAssignments(
|
|
836
|
+
const experimentAssignments = await resolveExperimentAssignments(
|
|
837
|
+
runtime,
|
|
838
|
+
layout,
|
|
839
|
+
audienceId,
|
|
840
|
+
visitorId,
|
|
841
|
+
tracked,
|
|
842
|
+
)
|
|
715
843
|
|
|
716
844
|
return {
|
|
717
845
|
personalization: { resolvedAudienceId: audienceId, experimentAssignments },
|
|
718
846
|
audienceId,
|
|
719
847
|
visitorId,
|
|
848
|
+
tracked,
|
|
720
849
|
audiences,
|
|
721
850
|
signals: result.signals,
|
|
722
851
|
}
|
|
@@ -736,12 +865,19 @@ function* variantContainersInRegions(regions: Record<string, RegionNode[]>): Gen
|
|
|
736
865
|
* exposure. Stable on visitorId → the visitor keeps the same arm across requests;
|
|
737
866
|
* the write is idempotent on (experiment, visitor). Returns experimentId →
|
|
738
867
|
* chosen variant key for <KywiLayout personalization.experimentAssignments>.
|
|
868
|
+
*
|
|
869
|
+
* \`tracked\` is the consent gate (kywi-cms#91): false means \`visitorId\` is a
|
|
870
|
+
* throwaway the middleware minted for this request alone, and the idempotent
|
|
871
|
+
* write stops being idempotent — every anonymous page view would insert another
|
|
872
|
+
* assignment row. The ARM IS STILL CHOSEN, so the page renders exactly as it
|
|
873
|
+
* does for anyone else; only the counting stops.
|
|
739
874
|
*/
|
|
740
875
|
async function resolveExperimentAssignments(
|
|
741
876
|
runtime: KywiRuntime,
|
|
742
877
|
layout: LayoutDocument | null | undefined,
|
|
743
878
|
audienceId: string | null,
|
|
744
879
|
visitorId: string,
|
|
880
|
+
tracked: boolean,
|
|
745
881
|
): Promise<Record<string, string | null>> {
|
|
746
882
|
if (!layout) return {}
|
|
747
883
|
// Pre-resolve FIRST, then read the containers. A container component saved
|
|
@@ -766,7 +902,7 @@ async function resolveExperimentAssignments(
|
|
|
766
902
|
for (const id of experimentIds) {
|
|
767
903
|
const exp = byId.get(id)
|
|
768
904
|
if (!exp) continue
|
|
769
|
-
const res = await resolveExperimentForContainer(runtime.db, exp, visitorId)
|
|
905
|
+
const res = await resolveExperimentForContainer(runtime.db, exp, visitorId, { recordExposure: tracked })
|
|
770
906
|
assignments[id] = res.variantKey
|
|
771
907
|
}
|
|
772
908
|
return assignments
|
|
@@ -775,8 +911,12 @@ async function resolveExperimentAssignments(
|
|
|
775
911
|
/**
|
|
776
912
|
* Return the audience-resolved layout: page variants (audience → whole-page
|
|
777
913
|
* region override) applied, and \`pageVariants\`/\`abExperiments\` stripped so no
|
|
778
|
-
* other audience's content is serialized to this visitor.
|
|
779
|
-
*
|
|
914
|
+
* other audience's content is serialized to this visitor.
|
|
915
|
+
*
|
|
916
|
+
* This is only the PAGE-level half. Container-level arms (variantContainer /
|
|
917
|
+
* moduleVariantContainer) are stripped by \`pruneLayoutToServedArms\` as the last
|
|
918
|
+
* pass before \`<KywiLayout>\` — see the public page (#167). Run both, or the
|
|
919
|
+
* losing arms are still readable in the page's RSC flight payload.
|
|
780
920
|
*/
|
|
781
921
|
export function personalizeLayout(
|
|
782
922
|
layout: LayoutDocument,
|
|
@@ -806,6 +946,21 @@ export function transparencyNoticeEnabled(): boolean {
|
|
|
806
946
|
return theme?.personalization?.transparencyNotice?.enabled !== false
|
|
807
947
|
}
|
|
808
948
|
|
|
949
|
+
/**
|
|
950
|
+
* Must a visitor consent before this site writes a \`personalization\` cookie
|
|
951
|
+
* (\`kywi_visitor\`, \`kywi_signals\`, \`kywi_utm\`, \`kywi_audience\`)?
|
|
952
|
+
* \`personalization.requireConsent\` — unset defaults to TRUE; only an explicit
|
|
953
|
+
* \`false\` turns Kywi's consent gate off, for a site that collects consent with
|
|
954
|
+
* external tooling (kywi-cms#91).
|
|
955
|
+
*
|
|
956
|
+
* \`middleware.ts\` runs on the edge and cannot read this config, so it mirrors
|
|
957
|
+
* the same flag with \`KYWI_REQUIRE_CONSENT=false\`. Set both, or neither.
|
|
958
|
+
*/
|
|
959
|
+
export function requireConsentEnabled(): boolean {
|
|
960
|
+
const theme = config.themes.find((t) => t.name === config.sites[0]?.theme) ?? config.themes[0]
|
|
961
|
+
return theme?.personalization?.requireConsent !== false
|
|
962
|
+
}
|
|
963
|
+
|
|
809
964
|
// ─── Self-ID widget (#50) ────────────────────────────────────────────────────
|
|
810
965
|
|
|
811
966
|
/** The self-ID widget config in the serializable shape the client runtime reads. */
|
|
@@ -900,6 +1055,23 @@ import {
|
|
|
900
1055
|
// back. Its own dependency-free entry (never the DB-backed audiences barrel), so
|
|
901
1056
|
// it is importable from this edge middleware.
|
|
902
1057
|
import { UTM_COOKIE, utmCookieValue } from '@kywi-software/core/audiences/utm-persistence'
|
|
1058
|
+
// Same kind of edge-safe leaf entry: the cookie-consent category map + gate. The
|
|
1059
|
+
// visitor's decision (written by the \`cookieConsent\` layout module) decides
|
|
1060
|
+
// whether the personalization cookies below may be persisted at all.
|
|
1061
|
+
import { cookieAllowedByHeader } from '@kywi-software/core/audiences/consent'
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* The edge mirror of \`theme.personalization.requireConsent\` (kywi-cms#91).
|
|
1065
|
+
* Middleware runs on the edge and must not import \`kywi.config.ts\` (a config is
|
|
1066
|
+
* free to pull in plugins and DB-backed code that cannot run there), so the one
|
|
1067
|
+
* flag that lives in config is mirrored here as an env var.
|
|
1068
|
+
* \`KYWI_REQUIRE_CONSENT=false\` turns the gate off for the two cookies this file
|
|
1069
|
+
* writes. Set it together with the theme flag, never one alone —
|
|
1070
|
+
* createKywiApiHandler THROWS at startup when the two disagree, so a mismatch
|
|
1071
|
+
* fails the deploy instead of silently making the middleware and the API
|
|
1072
|
+
* disagree about whether a visitor is tracked.
|
|
1073
|
+
*/
|
|
1074
|
+
const REQUIRE_CONSENT = process.env.KYWI_REQUIRE_CONSENT !== 'false'
|
|
903
1075
|
|
|
904
1076
|
/**
|
|
905
1077
|
* Server-side auth enforcement + transparent session refresh for /admin and the
|
|
@@ -952,21 +1124,34 @@ const VISITOR_MAX_AGE = 60 * 60 * 24 * 365 * 2 // 2 years
|
|
|
952
1124
|
* would never fire on the landing page. Everything else passes through.
|
|
953
1125
|
*/
|
|
954
1126
|
function handlePublicRequest(req: NextRequest): NextResponse {
|
|
955
|
-
|
|
1127
|
+
// Both cookies below are \`personalization\` cookies in core's consent map, so
|
|
1128
|
+
// neither is written OR READ until the visitor accepts. The header bridge is
|
|
1129
|
+
// unaffected — this request still gets a visitor id and still personalizes off
|
|
1130
|
+
// its own URL — so a site with no consent banner still renders, it just serves
|
|
1131
|
+
// default content instead of tracking anyone. Place the \`cookieConsent\`
|
|
1132
|
+
// module to let visitors turn personalization on.
|
|
1133
|
+
const cookieHeader = req.headers.get('cookie')
|
|
1134
|
+
const gate = { requireConsent: REQUIRE_CONSENT }
|
|
1135
|
+
const mayPersonalize = cookieAllowedByHeader(VISITOR_COOKIE, cookieHeader, gate)
|
|
1136
|
+
// Without consent the stored id is IGNORED, not just left un-refreshed: a
|
|
1137
|
+
// visitor who accepted last month and rejected today still carries the cookie,
|
|
1138
|
+
// and reading it would keep them tracked under the identity they just revoked.
|
|
1139
|
+
const existing = mayPersonalize ? req.cookies.get(VISITOR_COOKIE)?.value : undefined
|
|
956
1140
|
const visitorId = existing ?? crypto.randomUUID()
|
|
957
1141
|
const headers = new Headers(req.headers)
|
|
958
1142
|
headers.set('x-kywi-visitor', visitorId)
|
|
959
1143
|
headers.set('x-kywi-url', req.nextUrl.href)
|
|
960
1144
|
const res = NextResponse.next({ request: { headers } })
|
|
961
|
-
if (!existing) {
|
|
1145
|
+
if (!existing && mayPersonalize) {
|
|
962
1146
|
res.cookies.set(VISITOR_COOKIE, visitorId, { path: '/', maxAge: VISITOR_MAX_AGE, sameSite: 'lax' })
|
|
963
1147
|
}
|
|
964
1148
|
// Persist campaign UTM params so audience matching survives internal
|
|
965
1149
|
// navigation: core's collectUtm reads this cookie when the URL has no utm_*
|
|
966
1150
|
// query string. Only written when the request carries utm_* params, so an
|
|
967
1151
|
// ordinary page view never clobbers a persisted campaign (utmCookieValue
|
|
968
|
-
// returns null → the existing cookie is left in place)
|
|
969
|
-
|
|
1152
|
+
// returns null → the existing cookie is left in place) — and only once the
|
|
1153
|
+
// visitor has consented, which utmCookieValue reads off the header we pass.
|
|
1154
|
+
const utmValue = utmCookieValue(req.nextUrl.href, { cookieHeader, ...gate })
|
|
970
1155
|
if (utmValue) {
|
|
971
1156
|
res.cookies.set(UTM_COOKIE, utmValue, { path: '/', maxAge: 60 * 60 * 24 * 30, sameSite: 'lax' })
|
|
972
1157
|
}
|
|
@@ -1161,6 +1346,7 @@ import './site.css'
|
|
|
1161
1346
|
import config from '../../kywi.config'
|
|
1162
1347
|
import { getKywi } from '../../lib/kywi'
|
|
1163
1348
|
import { SiteNav } from '../../components/site-nav'
|
|
1349
|
+
import { KywiJsLoader } from '../../components/kywi-js-loader'
|
|
1164
1350
|
|
|
1165
1351
|
/**
|
|
1166
1352
|
* Public site shell. Header + footer carry this project's brand; edit them (and
|
|
@@ -1208,6 +1394,13 @@ export default async function SiteLayout({ children }: { children: React.ReactNo
|
|
|
1208
1394
|
return (
|
|
1209
1395
|
<div className="site-shell">
|
|
1210
1396
|
{themeVars ? <style dangerouslySetInnerHTML={{ __html: themeVars }} /> : null}
|
|
1397
|
+
{/* /kywi.js for the nav's hover-intent/keyboard/viewport-flip enhancement
|
|
1398
|
+
(kywi-cms#114) — independent of personalization, gated on the exact
|
|
1399
|
+
same condition <SiteNav> renders on below (a non-empty header menu),
|
|
1400
|
+
so it loads whenever a \`[data-kywi-nav]\` root actually exists. Shares
|
|
1401
|
+
its dedup with <PersonalizationRuntime> (lib/kywi-js-loader.ts), so a
|
|
1402
|
+
page with both nav AND the client runtime enabled loads it once. */}
|
|
1403
|
+
{headerItems.length > 0 && <KywiJsLoader />}
|
|
1211
1404
|
|
|
1212
1405
|
<header className="site-header">
|
|
1213
1406
|
<div className="site-header__inner">
|
|
@@ -1346,7 +1539,15 @@ import type { Metadata } from 'next'
|
|
|
1346
1539
|
import { notFound } from 'next/navigation'
|
|
1347
1540
|
import { cookies, headers } from 'next/headers'
|
|
1348
1541
|
import { KywiBody, KywiEditableAttribute, KywiEditableRegion } from '@kywi-software/core/scope-client'
|
|
1349
|
-
import {
|
|
1542
|
+
import {
|
|
1543
|
+
KywiLayout,
|
|
1544
|
+
KywiRegion,
|
|
1545
|
+
AudienceMetaTags,
|
|
1546
|
+
hydrateLayoutFeeds,
|
|
1547
|
+
hydrateLayoutNav,
|
|
1548
|
+
pruneLayoutToServedArms,
|
|
1549
|
+
type LayoutDocument,
|
|
1550
|
+
} from '@kywi-software/core/layout'
|
|
1350
1551
|
import { KywiJsonLd } from '@kywi-software/core/scope'
|
|
1351
1552
|
import { ACCESS_COOKIE, canAccessContent, readSessionClaims } from '@kywi-software/core/host'
|
|
1352
1553
|
import config from '../../../lib/config'
|
|
@@ -1358,6 +1559,7 @@ import {
|
|
|
1358
1559
|
resolveSelfIdWidget,
|
|
1359
1560
|
clientRuntimeEnabled,
|
|
1360
1561
|
transparencyNoticeEnabled,
|
|
1562
|
+
requireConsentEnabled,
|
|
1361
1563
|
buildFeedResolver,
|
|
1362
1564
|
resolveLayoutComponents,
|
|
1363
1565
|
localeAlternates,
|
|
@@ -1490,6 +1692,7 @@ export default async function PublicPage({ params, searchParams }: Params & Sear
|
|
|
1490
1692
|
serverSignals={perso.signals}
|
|
1491
1693
|
selfIdWidget={selfIdWidget}
|
|
1492
1694
|
transparencyNotice={transparencyNoticeEnabled()}
|
|
1695
|
+
requireConsent={requireConsentEnabled()}
|
|
1493
1696
|
/>
|
|
1494
1697
|
) : null}
|
|
1495
1698
|
</>
|
|
@@ -1519,15 +1722,28 @@ export default async function PublicPage({ params, searchParams }: Params & Sear
|
|
|
1519
1722
|
// KywiLayout is a client component and the renderer's resolver props are
|
|
1520
1723
|
// functions, which cannot cross the RSC boundary (#147).
|
|
1521
1724
|
const hydrated = await resolveLayoutComponents(navHydrated, runtime)
|
|
1725
|
+
// LAST pass before the client boundary (#167): keep ONLY the arm this
|
|
1726
|
+
// visitor is served in every variantContainer / moduleVariantContainer.
|
|
1727
|
+
// KywiLayout is a client component, so anything still on the document here
|
|
1728
|
+
// is serialized into the page's RSC flight payload — arm SELECTION was
|
|
1729
|
+
// always server-side, but the losing arms crossed with it and were readable
|
|
1730
|
+
// in view-source. Pruning uses the same \`perso.personalization\` the
|
|
1731
|
+
// renderer is handed, so the markup is unchanged.
|
|
1732
|
+
//
|
|
1733
|
+
// Order matters: after component resolution (a container that is only a
|
|
1734
|
+
// \`componentId\` has no arms of its own to prune yet) and after the feed/nav
|
|
1735
|
+
// hydration passes. The front-edit overlay below is given the STORED
|
|
1736
|
+
// \`layout\`, never this — an editor needs every arm.
|
|
1737
|
+
const served = pruneLayoutToServedArms(hydrated, perso.personalization)
|
|
1522
1738
|
content = (
|
|
1523
1739
|
<article className="page page--layout" data-kywi-content-id={contentId}>
|
|
1524
1740
|
{head}
|
|
1525
1741
|
<KywiLayout
|
|
1526
|
-
layout={
|
|
1742
|
+
layout={served}
|
|
1527
1743
|
personalization={perso.personalization}
|
|
1528
1744
|
moduleComponents={moduleComponents}
|
|
1529
1745
|
>
|
|
1530
|
-
{Object.keys(
|
|
1746
|
+
{Object.keys(served.regions).map((name) => (
|
|
1531
1747
|
<KywiRegion key={name} name={name} />
|
|
1532
1748
|
))}
|
|
1533
1749
|
</KywiLayout>
|
|
@@ -1624,14 +1840,84 @@ import {
|
|
|
1624
1840
|
} from '@kywi-software/core/layout'
|
|
1625
1841
|
import type { SaveAction } from '@kywi-software/core/admin'
|
|
1626
1842
|
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1843
|
+
/**
|
|
1844
|
+
* Injects the admin design system's stylesheet as a plain \`<link>\` tag rather
|
|
1845
|
+
* than an ES \`import\` (kywi-cms#130 follow-up). A dynamic \`import('…/admin/
|
|
1846
|
+
* styles.css')\` still shipped the stylesheet on every public route that
|
|
1847
|
+
* reaches this factory, regardless of the \`next/dynamic\` wrapping around
|
|
1848
|
+
* it. A \`<link>\` created imperatively at runtime has no \`import\` statement
|
|
1849
|
+
* for Next's CSS collector to see, so it never enters the build's CSS graph.
|
|
1850
|
+
*
|
|
1851
|
+
* VERIFIED (in the reference app this template mirrors) against a real
|
|
1852
|
+
* \`next build && next start\`: an anonymous fetch of a real published content
|
|
1853
|
+
* page shows zero OverlayShell/editor.css/admin.css references in any of its
|
|
1854
|
+
* fetched resources. Fetching the SAME page against \`next dev\` still shows
|
|
1855
|
+
* every one of those markers regardless of this fix — \`next dev\` bundles far
|
|
1856
|
+
* more eagerly than a production build, for ALL routes, independent of any
|
|
1857
|
+
* dynamic()/runtime-injection technique. A generated app's own e2e coverage
|
|
1858
|
+
* of this (if any) needs to build for real, not assert against \`next dev\`.
|
|
1859
|
+
*
|
|
1860
|
+
* Two follow-ups from review:
|
|
1861
|
+
* - CACHE-BUSTING: \`/kywi-admin.css\` and \`/kywi-admin.css.version\` are both
|
|
1862
|
+
* synced from the installed \`@kywi-software/core\` package
|
|
1863
|
+
* (scripts/sync-kywi-admin-css.mjs, run via package.json's
|
|
1864
|
+
* \`sync:kywi-admin-css\` on every \`predev\`/\`prebuild\`) — mirroring how
|
|
1865
|
+
* \`public/kywi.js\` is synced from \`@kywi-software/js\`. The version
|
|
1866
|
+
* file's content becomes the \`?v=\` query string below, so a core upgrade
|
|
1867
|
+
* that changes the stylesheet always serves a URL a CDN/browser has never
|
|
1868
|
+
* cached, instead of a stale \`/kywi-admin.css\` response.
|
|
1869
|
+
* - FOUC: returns a Promise that resolves once the tag has actually loaded
|
|
1870
|
+
* (or failed to) — an existing, already-loaded tag resolves immediately;
|
|
1871
|
+
* a fresh or still-loading one resolves on its \`load\`/\`error\` event. The
|
|
1872
|
+
* dynamic() factory below awaits this ALONGSIDE the OverlayShell JS
|
|
1873
|
+
* import, so the editor's first paint never races its own stylesheet.
|
|
1874
|
+
*/
|
|
1875
|
+
function ensureAdminStylesheet(): Promise<void> {
|
|
1876
|
+
if (typeof document === 'undefined') return Promise.resolve()
|
|
1877
|
+
const existing = document.querySelector<HTMLLinkElement>('link[data-kywi-admin-css]')
|
|
1878
|
+
if (existing) {
|
|
1879
|
+
// \`.sheet\` is non-null only once the stylesheet has actually parsed —
|
|
1880
|
+
// resolve immediately rather than re-attaching listeners to an event
|
|
1881
|
+
// that already fired.
|
|
1882
|
+
if (existing.sheet) return Promise.resolve()
|
|
1883
|
+
return new Promise((resolve) => {
|
|
1884
|
+
existing.addEventListener('load', () => resolve(), { once: true })
|
|
1885
|
+
existing.addEventListener('error', () => resolve(), { once: true })
|
|
1886
|
+
})
|
|
1887
|
+
}
|
|
1888
|
+
return fetch('/kywi-admin.css.version')
|
|
1889
|
+
.then((res) => (res.ok ? res.text() : ''))
|
|
1890
|
+
.catch(() => '')
|
|
1891
|
+
.then(
|
|
1892
|
+
(version) =>
|
|
1893
|
+
new Promise<void>((resolve) => {
|
|
1894
|
+
const link = document.createElement('link')
|
|
1895
|
+
link.rel = 'stylesheet'
|
|
1896
|
+
const v = version.trim()
|
|
1897
|
+
link.href = v ? \`/kywi-admin.css?v=\${encodeURIComponent(v)}\` : '/kywi-admin.css'
|
|
1898
|
+
link.dataset.kywiAdminCss = 'true'
|
|
1899
|
+
// A load OR error either way lets the caller proceed — a stylesheet
|
|
1900
|
+
// that 404s must never permanently block the editor from opening.
|
|
1901
|
+
link.addEventListener('load', () => resolve(), { once: true })
|
|
1902
|
+
link.addEventListener('error', () => resolve(), { once: true })
|
|
1903
|
+
document.head.appendChild(link)
|
|
1904
|
+
}),
|
|
1905
|
+
)
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
// Lazy-load the full layout editor bundle: the shell's JS (dnd-kit, canvas,
|
|
1909
|
+
// panels) is pulled INSIDE this factory, so it enters the module graph only
|
|
1910
|
+
// when an editor opens the overlay — never in the public browse bundle a
|
|
1911
|
+
// visitor downloads. The stylesheet is handled separately — see
|
|
1912
|
+
// \`ensureAdminStylesheet\` above — since a css import here, even a dynamic
|
|
1913
|
+
// one, does not behave like the JS import right below it. Both are awaited
|
|
1914
|
+
// together so the editor never paints ahead of its own stylesheet (FOUC).
|
|
1631
1915
|
const OverlayShell = dynamic(
|
|
1632
1916
|
async () => {
|
|
1633
|
-
await
|
|
1634
|
-
|
|
1917
|
+
const [, mod] = await Promise.all([
|
|
1918
|
+
ensureAdminStylesheet(),
|
|
1919
|
+
import('@kywi-software/core/admin'),
|
|
1920
|
+
])
|
|
1635
1921
|
return mod.OverlayShell
|
|
1636
1922
|
},
|
|
1637
1923
|
{ ssr: false },
|
|
@@ -1804,6 +2090,62 @@ export function KywiFrontEdit({
|
|
|
1804
2090
|
`
|
|
1805
2091
|
}
|
|
1806
2092
|
|
|
2093
|
+
/**
|
|
2094
|
+
* Shared `/kywi.js` script-injection primitive (kywi-cms#114). Two independent
|
|
2095
|
+
* triggers can each want the browser bundle on the page: the site layout's nav
|
|
2096
|
+
* (`<KywiJsLoader>`, purely for the hover-intent/keyboard/viewport-flip
|
|
2097
|
+
* enhancement `initNavMenus()` applies unconditionally once the script boots)
|
|
2098
|
+
* and `<PersonalizationRuntime>` (which additionally waits on
|
|
2099
|
+
* `window.Kywi.bootAudienceEngine`). Before this fix only the personalization
|
|
2100
|
+
* runtime ever injected the tag, so a site with `personalization.clientRuntime`
|
|
2101
|
+
* off — or simply no active audiences — never got the nav enhancement either,
|
|
2102
|
+
* even though the nav renders fully without it (kywi-cms#111's JS-off contract)
|
|
2103
|
+
* and would only ever gain from it. `ensureKywiJsScript` is idempotent:
|
|
2104
|
+
* whichever caller mounts first creates the ONE `<script data-kywi-js>` tag,
|
|
2105
|
+
* every other caller finds it already there — so both triggers being true at
|
|
2106
|
+
* once never loads the bundle twice.
|
|
2107
|
+
*/
|
|
2108
|
+
function kywiJsLoaderLib() {
|
|
2109
|
+
return `export function ensureKywiJsScript(): HTMLScriptElement | null {
|
|
2110
|
+
if (typeof document === 'undefined') return null
|
|
2111
|
+
const existing = document.querySelector<HTMLScriptElement>('script[data-kywi-js]')
|
|
2112
|
+
if (existing) return existing
|
|
2113
|
+
const script = document.createElement('script')
|
|
2114
|
+
script.src = '/kywi.js'
|
|
2115
|
+
script.async = true
|
|
2116
|
+
script.dataset.kywiJs = 'true'
|
|
2117
|
+
document.body.appendChild(script)
|
|
2118
|
+
return script
|
|
2119
|
+
}
|
|
2120
|
+
`
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
/**
|
|
2124
|
+
* Loads `/kywi.js` for its nav enhancement alone, independent of
|
|
2125
|
+
* personalization (kywi-cms#114). Rendered by the site layout whenever the
|
|
2126
|
+
* resolved header menu is non-empty — the same condition `<SiteNav>` itself
|
|
2127
|
+
* gates on before it renders a `[data-kywi-nav]` root — so this mounts exactly
|
|
2128
|
+
* when there is a nav for `initNavMenus()` to enhance. Does nothing beyond
|
|
2129
|
+
* ensuring the script tag exists: `Kywi.boot()` runs `initNavMenus()`
|
|
2130
|
+
* unconditionally as part of its own bootstrap once the script loads, and
|
|
2131
|
+
* never touches personalization on its own.
|
|
2132
|
+
*/
|
|
2133
|
+
function kywiJsLoaderComponent() {
|
|
2134
|
+
return `'use client'
|
|
2135
|
+
|
|
2136
|
+
import { useEffect } from 'react'
|
|
2137
|
+
import { ensureKywiJsScript } from '../lib/kywi-js-loader'
|
|
2138
|
+
|
|
2139
|
+
export function KywiJsLoader() {
|
|
2140
|
+
useEffect(() => {
|
|
2141
|
+
ensureKywiJsScript()
|
|
2142
|
+
}, [])
|
|
2143
|
+
|
|
2144
|
+
return null
|
|
2145
|
+
}
|
|
2146
|
+
`
|
|
2147
|
+
}
|
|
2148
|
+
|
|
1807
2149
|
/**
|
|
1808
2150
|
* Client personalization runtime (kywi-cms#50). The SERVER already resolved and
|
|
1809
2151
|
* rendered the correct audience/experiment variant and set the ids in <head>;
|
|
@@ -1814,6 +2156,12 @@ export function KywiFrontEdit({
|
|
|
1814
2156
|
* stays dependency-free by default: drop the built @kywi-software/js bundle at
|
|
1815
2157
|
* public/kywi.js to enable it (see README → "Personalization"). Without it the
|
|
1816
2158
|
* server-rendered variant is exactly what every visitor sees.
|
|
2159
|
+
*
|
|
2160
|
+
* kywi-cms#114: `/kywi.js` may already be on the page independent of this
|
|
2161
|
+
* component — the site layout's `<KywiJsLoader>` loads it purely for nav
|
|
2162
|
+
* enhancement whenever the site has a nav. `ensureKywiJsScript` (shared with
|
|
2163
|
+
* that component, lib/kywi-js-loader.ts) is idempotent, so this never injects
|
|
2164
|
+
* a second copy.
|
|
1817
2165
|
*/
|
|
1818
2166
|
function personalizationRuntime() {
|
|
1819
2167
|
return `'use client'
|
|
@@ -1821,6 +2169,7 @@ function personalizationRuntime() {
|
|
|
1821
2169
|
import React, { useEffect } from 'react'
|
|
1822
2170
|
import type { Audience, VisitorSignals } from '@kywi-software/core/audiences/types'
|
|
1823
2171
|
import type { PublicSelfIdWidget } from '../lib/site'
|
|
2172
|
+
import { ensureKywiJsScript } from '../lib/kywi-js-loader'
|
|
1824
2173
|
|
|
1825
2174
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
1826
2175
|
declare global {
|
|
@@ -1843,15 +2192,27 @@ interface PersonalizationRuntimeProps {
|
|
|
1843
2192
|
* deliberately through a \`personalizationBadge\` module. Defaults to true.
|
|
1844
2193
|
*/
|
|
1845
2194
|
transparencyNotice?: boolean
|
|
2195
|
+
/**
|
|
2196
|
+
* \`personalization.requireConsent\` — false turns the client's consent gate
|
|
2197
|
+
* off for a site that collects consent with external tooling. Defaults to
|
|
2198
|
+
* true, so the resolved audience is only remembered for a consenting visitor
|
|
2199
|
+
* (kywi-cms#91).
|
|
2200
|
+
*/
|
|
2201
|
+
requireConsent?: boolean
|
|
1846
2202
|
}
|
|
1847
2203
|
|
|
1848
2204
|
/**
|
|
1849
|
-
*
|
|
1850
|
-
* opt-out cookie itself and resolves opted-out
|
|
1851
|
-
* experience, so opt-out is respected end to end.
|
|
1852
|
-
* theme.personalization.clientRuntime is on.
|
|
2205
|
+
* Ensures /kywi.js is on the page, then boots the audience data-layer.
|
|
2206
|
+
* bootAudienceEngine reads the opt-out cookie itself and resolves opted-out
|
|
2207
|
+
* visitors to the default experience, so opt-out is respected end to end.
|
|
2208
|
+
* Rendered only when theme.personalization.clientRuntime is on.
|
|
2209
|
+
*
|
|
2210
|
+
* kywi-cms#114: the script tag may already be on the page independent of this
|
|
2211
|
+
* component — the site layout's <KywiJsLoader> loads it purely for nav
|
|
2212
|
+
* enhancement whenever the site has a nav. ensureKywiJsScript (shared with
|
|
2213
|
+
* that component) is idempotent, so this never injects a second copy.
|
|
1853
2214
|
*/
|
|
1854
|
-
export function PersonalizationRuntime({ audiences, serverSignals, selfIdWidget, transparencyNotice }: PersonalizationRuntimeProps) {
|
|
2215
|
+
export function PersonalizationRuntime({ audiences, serverSignals, selfIdWidget, transparencyNotice, requireConsent }: PersonalizationRuntimeProps) {
|
|
1855
2216
|
useEffect(() => {
|
|
1856
2217
|
let cancelled = false
|
|
1857
2218
|
const ready = () => typeof window.Kywi?.bootAudienceEngine === 'function'
|
|
@@ -1868,6 +2229,7 @@ export function PersonalizationRuntime({ audiences, serverSignals, selfIdWidget,
|
|
|
1868
2229
|
? { selfIdWidget: { ...selfIdWidget, audiences, currentPath } }
|
|
1869
2230
|
: {}),
|
|
1870
2231
|
...(transparencyNotice === false ? { transparencyNotice: false } : {}),
|
|
2232
|
+
...(requireConsent === false ? { requireConsent: false } : {}),
|
|
1871
2233
|
})
|
|
1872
2234
|
.catch(() => {
|
|
1873
2235
|
/* client personalization is best-effort; the server already rendered defaults */
|
|
@@ -1888,18 +2250,14 @@ export function PersonalizationRuntime({ audiences, serverSignals, selfIdWidget,
|
|
|
1888
2250
|
}, 60)
|
|
1889
2251
|
}
|
|
1890
2252
|
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
script.dataset.kywiJs = 'true'
|
|
1900
|
-
script.addEventListener('load', whenReady)
|
|
1901
|
-
document.body.appendChild(script)
|
|
1902
|
-
}
|
|
2253
|
+
// kywi-cms#114: the script tag itself may already be on the page — the
|
|
2254
|
+
// site layout's <KywiJsLoader> injects it independently, purely for nav
|
|
2255
|
+
// enhancement, whenever the site has a nav. \`ensureKywiJsScript\` is the
|
|
2256
|
+
// shared dedup primitive: it returns the existing tag if one is already
|
|
2257
|
+
// there, so this never creates a second one.
|
|
2258
|
+
const script = ensureKywiJsScript()
|
|
2259
|
+
if (ready()) boot()
|
|
2260
|
+
else script?.addEventListener('load', whenReady)
|
|
1903
2261
|
|
|
1904
2262
|
return () => {
|
|
1905
2263
|
cancelled = true
|
|
@@ -1962,7 +2320,7 @@ function apiRoute() {
|
|
|
1962
2320
|
return `import { NextRequest, NextResponse } from 'next/server'
|
|
1963
2321
|
import { getKywiHandler } from '../../../../lib/kywi'
|
|
1964
2322
|
import {
|
|
1965
|
-
|
|
2323
|
+
isTokenIssuingPath,
|
|
1966
2324
|
parseSessionTokens,
|
|
1967
2325
|
setAccessCookie,
|
|
1968
2326
|
setRefreshCookie,
|
|
@@ -1993,7 +2351,7 @@ async function handleRequest(
|
|
|
1993
2351
|
return out
|
|
1994
2352
|
}
|
|
1995
2353
|
|
|
1996
|
-
if (
|
|
2354
|
+
if (isTokenIssuingPath(path) && res.ok) {
|
|
1997
2355
|
const bodyText = await res.text()
|
|
1998
2356
|
const { accessToken, refreshToken } = parseSessionTokens(bodyText)
|
|
1999
2357
|
const out = new NextResponse(bodyText, { status: res.status, headers: res.headers })
|
|
@@ -2243,6 +2601,14 @@ or read-only visitor gets no toolbar and no extra client JS. Custom
|
|
|
2243
2601
|
editor's insert palette and are fully editable in place — the props rail and
|
|
2244
2602
|
inline text editing work exactly as they do in the admin's Layout tab.
|
|
2245
2603
|
|
|
2604
|
+
The admin stylesheet (\`public/kywi-admin.css\`) is synced automatically from
|
|
2605
|
+
your installed \`@kywi-software/core\` version on every \`dev\`/\`build\` (via
|
|
2606
|
+
\`predev\`/\`prebuild\` — see \`scripts/sync-kywi-admin-css.mjs\`), not on
|
|
2607
|
+
\`install\`, and is gitignored — it is a build artifact of whichever core
|
|
2608
|
+
version you have, not project source. Run \`pnpm sync:kywi-admin-css\`
|
|
2609
|
+
yourself if you ever need it without a full dev/build cycle (e.g. before
|
|
2610
|
+
\`next start\` against an already-built \`.next\`).
|
|
2611
|
+
|
|
2246
2612
|
## Personalization, A/B testing & self-ID
|
|
2247
2613
|
|
|
2248
2614
|
Audiences, experiments and the self-ID widget you configure in the admin resolve
|
|
@@ -2252,7 +2618,16 @@ Audiences, experiments and the self-ID widget you configure in the admin resolve
|
|
|
2252
2618
|
audience \`variantContainer\` render for the winning audience.
|
|
2253
2619
|
- **A/B experiments** — each visitor is assigned a variant arm deterministically
|
|
2254
2620
|
and the exposure is recorded. The assignment is stable because the
|
|
2255
|
-
\`middleware.ts\` gives every visitor a persistent \`kywi_visitor\` id
|
|
2621
|
+
\`middleware.ts\` gives every visitor a persistent \`kywi_visitor\` id — once
|
|
2622
|
+
they have consented. \`kywi_visitor\`, \`kywi_utm\`, \`kywi_signals\` and
|
|
2623
|
+
\`kywi_audience\` are \`personalization\` cookies, and the engine will not write
|
|
2624
|
+
any of them until the visitor accepts. Place the \`cookieConsent\` module to
|
|
2625
|
+
give them that choice; until then pages render their default content and
|
|
2626
|
+
nobody is tracked. If this site collects consent with EXTERNAL
|
|
2627
|
+
tooling instead, set \`personalization: { requireConsent: false }\` on your
|
|
2628
|
+
theme in \`kywi.config.ts\` **and** \`KYWI_REQUIRE_CONSENT=false\` in the
|
|
2629
|
+
environment (the edge middleware cannot read the config) — that hands
|
|
2630
|
+
compliance to your tooling and restores the un-gated cookie behaviour.
|
|
2256
2631
|
- **Preview links** — an admin preview link sets \`kywi_preview_init\` and the page
|
|
2257
2632
|
honours it, so you can preview an audience's experience.
|
|
2258
2633
|
|
|
@@ -2263,6 +2638,15 @@ dropping the built \`@kywi-software/js\` browser bundle at \`public/kywi.js\`; t
|
|
|
2263
2638
|
generated \`components/personalization-runtime.tsx\` loads it best-effort. Without
|
|
2264
2639
|
it, the server-rendered variant is what every visitor sees.
|
|
2265
2640
|
|
|
2641
|
+
The same \`public/kywi.js\` bundle also drives the header nav's hover-intent,
|
|
2642
|
+
Escape/arrow-key and viewport-edge-flip enhancement — independent of
|
|
2643
|
+
personalization. \`components/kywi-js-loader.tsx\` loads it whenever the site has
|
|
2644
|
+
a nav, whether or not \`clientRuntime\` is on, so the nav gets that polish on
|
|
2645
|
+
every site once the bundle is in place; the nav renders and works fully without
|
|
2646
|
+
it either way. (\`public/kywi-admin.css\`, the front-of-site editor's stylesheet,
|
|
2647
|
+
is a separate file synced automatically on \`dev\`/\`build\` — see "Front-of-site
|
|
2648
|
+
editor" above — unlike \`kywi.js\`, which you place yourself.)
|
|
2649
|
+
|
|
2266
2650
|
## Locales
|
|
2267
2651
|
|
|
2268
2652
|
Set \`locales\` on your site in \`kywi.config.ts\` (e.g. \`locales: ['en', 'es']\`),
|
|
@@ -2361,7 +2745,7 @@ lib/config.ts single import path for kywi.config.ts
|
|
|
2361
2745
|
app/api/v1/[...kywi]/route.ts the versioned API (delegates to core)
|
|
2362
2746
|
app/admin/[[...admin]]/page.tsx mounts the FULL core admin (all surfaces) at /admin
|
|
2363
2747
|
lib/modules.tsx custom (defineModule) module renderers (admin + public)
|
|
2364
|
-
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/site-nav.tsx client wrapper around the shared nav renderer (header + footer)\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)'}
|
|
2748
|
+
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/site-nav.tsx client wrapper around the shared nav renderer (header + footer)\ncomponents/kywi-js-loader.tsx loads /kywi.js for nav enhancement whenever the site has a nav\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)'}
|
|
2365
2749
|
\`\`\`
|
|
2366
2750
|
`
|
|
2367
2751
|
}
|
|
@@ -2757,10 +3141,19 @@ export function buildFileSet(answers) {
|
|
|
2757
3141
|
// Public-render helpers: path/locale resolution, feed + component resolvers,
|
|
2758
3142
|
// personalization + experiments.
|
|
2759
3143
|
files['lib/site.ts'] = libSite()
|
|
3144
|
+
// Shared /kywi.js script-injection primitive (kywi-cms#114) — the site
|
|
3145
|
+
// layout's nav loader and the personalization runtime below both use it,
|
|
3146
|
+
// so loading the bundle for either reason never double-loads it.
|
|
3147
|
+
files['lib/kywi-js-loader.ts'] = kywiJsLoaderLib()
|
|
3148
|
+
files['components/kywi-js-loader.tsx'] = kywiJsLoaderComponent()
|
|
2760
3149
|
// Optional client personalization runtime (self-ID widget, live re-eval). #50
|
|
2761
3150
|
files['components/personalization-runtime.tsx'] = personalizationRuntime()
|
|
2762
3151
|
// Front-of-site edit overlay (?kywi-edit=1), mounted by the page above.
|
|
2763
3152
|
files['app/(site)/kywi-front-edit.tsx'] = frontEditOverlay()
|
|
3153
|
+
// Syncs the admin stylesheet + a cache-busting version marker into
|
|
3154
|
+
// public/ on every predev/prebuild — see the doc comment on
|
|
3155
|
+
// syncKywiAdminCssScript and packageJson's syncScripts.
|
|
3156
|
+
files['scripts/sync-kywi-admin-css.mjs'] = syncKywiAdminCssScript()
|
|
2764
3157
|
} else {
|
|
2765
3158
|
// headless + decoupled: no public rendering.
|
|
2766
3159
|
files['app/page.tsx'] = headlessHomePage(answers)
|
package/package.json
CHANGED