create-kywi-app 0.6.2 → 0.6.4
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 +221 -16
- package/assets/kywi-custom-modules-skill.md +172 -0
- package/lib/templates.mjs +199 -46
- package/package.json +1 -1
package/assets/agent-patterns.md
CHANGED
|
@@ -17,6 +17,27 @@ edit content in the admin, or do they need a developer?"* If the answer is
|
|
|
17
17
|
Static code is for structure and brand chrome. Content — anything with words,
|
|
18
18
|
images, prices, names, dates — belongs in Kywi.
|
|
19
19
|
|
|
20
|
+
## Where the owner edits — the surfaces you're building for
|
|
21
|
+
|
|
22
|
+
"Editable" isn't an abstraction. It means the thing appears in one of three
|
|
23
|
+
shipped surfaces:
|
|
24
|
+
|
|
25
|
+
- **Admin → Content / Site Tree** — the fields on a node (which fields exist is
|
|
26
|
+
decided by the type, §2).
|
|
27
|
+
- **Admin → the layout editor** — regions, sections, columns, modules, page
|
|
28
|
+
variants, and section-level personalized/A-B containers with an arm switcher.
|
|
29
|
+
- **The public site itself** — a signed-in admin gets a toolbar on every public
|
|
30
|
+
page (page title, status, **Edit this page**), which opens that same layout
|
|
31
|
+
editor *in place*: the real page, the site's own stylesheet, chrome docked to
|
|
32
|
+
the viewport edges. Text-bearing modules are edited by typing on the page.
|
|
33
|
+
|
|
34
|
+
The build rule that follows: **prefer structures that show up in those
|
|
35
|
+
surfaces.** A headline in a `heading` module is editable in all three; the same
|
|
36
|
+
headline in a hand-written component is editable in none. Custom modules (§5)
|
|
37
|
+
must expose their copy as first-class props for the same reason — and a module
|
|
38
|
+
whose *first* declared prop is `text`, `textarea` or `richText` becomes
|
|
39
|
+
inline-editable on the front of the site for free.
|
|
40
|
+
|
|
20
41
|
## Before you build: design the content model and get sign-off
|
|
21
42
|
|
|
22
43
|
The most expensive failure in a CMS build is a poorly planned content model —
|
|
@@ -57,6 +78,8 @@ the full proposal — ten minutes of sign-off beats re-entering fifty nodes.
|
|
|
57
78
|
| Filter/group content by topic, industry, audience | Categories & tags, queried by feeds (§3) |
|
|
58
79
|
| Copy-paste a CTA/banner/snippet across pages | Save it as a reusable component (§4) |
|
|
59
80
|
| Write page sections as JSX | Build them as layout sections/modules the owner can edit (§5) |
|
|
81
|
+
| Build a page as one column of full-width headings and paragraphs | Compose: section bands, columns, object modules (§5) |
|
|
82
|
+
| Put a custom module's editable copy in a `json` prop | One `text`/`textarea`/`richText` prop per field the owner edits (§5) |
|
|
60
83
|
| Hand-code a `<form>` | Build it in the Forms admin, place the Form module (§6) |
|
|
61
84
|
| Drop images into `/public` | Upload to the Media library (§7) |
|
|
62
85
|
| Build segment-specific or experimental UI unprompted | Ask the owner first — offer personalization/A-B with examples (§8) |
|
|
@@ -113,6 +136,28 @@ by `kywi migrate`. When in doubt, admin-created.
|
|
|
113
136
|
node's own page is the detail view. Listing + detail with zero custom code.
|
|
114
137
|
- Don't shadow built-in fields (slug, body) with custom ones — Kywi will block
|
|
115
138
|
it; use the built-ins.
|
|
139
|
+
- The scaffold ships one config type (`page`) so a fresh site can publish
|
|
140
|
+
immediately. That's the floor, not the pattern — add your types in the Type
|
|
141
|
+
Designer, and only put a type in `contentTypes` when code depends on its shape.
|
|
142
|
+
|
|
143
|
+
**Creating types programmatically.** The admin UI is one client of the REST API;
|
|
144
|
+
an agent uses the same endpoints, and the order matters:
|
|
145
|
+
|
|
146
|
+
1. `POST /api/v1/content-types` — create the type.
|
|
147
|
+
2. `POST /api/v1/content-types/:type/fields` — add each field.
|
|
148
|
+
3. `POST /api/v1/content-types/apply` — **materialize the schema** (diffs the
|
|
149
|
+
type and runs the CREATE/ALTER for its side table).
|
|
150
|
+
4. *Then* create nodes — before the apply step the side table doesn't exist and
|
|
151
|
+
the write fails (cleanly rolled back since 0.6.3, but still a failure).
|
|
152
|
+
|
|
153
|
+
**Seed with an idempotent script.** Put structural setup in a
|
|
154
|
+
`scripts/seed-content.mjs` that logs in (`POST /api/v1/auth/login`) and *checks
|
|
155
|
+
before creating* every object — list types → create the missing, list fields →
|
|
156
|
+
add the missing, same for taxonomy, audiences, experiments, self-ID. It is
|
|
157
|
+
re-runnable, reviewable in git, and rebuilds the site on a fresh database. Build
|
|
158
|
+
pages, layouts and feeds over MCP or in the admin instead. The script seeds
|
|
159
|
+
structure; it doesn't own content — once the owner edits a node, a re-run must
|
|
160
|
+
not clobber it.
|
|
116
161
|
|
|
117
162
|
## 3. Taxonomy: categories & tags
|
|
118
163
|
|
|
@@ -146,22 +191,138 @@ newsletter signup blocks, campaign snippets, "as seen in" strips — should be a
|
|
|
146
191
|
## 5. Page layouts & modules — not hardcoded JSX
|
|
147
192
|
|
|
148
193
|
Pages the owner should be able to restructure — landing pages, the home page,
|
|
149
|
-
campaign pages — should be built **in the layout editor** (sections
|
|
150
|
-
modules), rendered through Kywi's layout renderer. A scaffolded app
|
|
151
|
-
renders saved layouts on every content page; keep it that way.
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
194
|
+
campaign pages — should be built **in the layout editor** (regions → sections →
|
|
195
|
+
columns → modules), rendered through Kywi's layout renderer. A scaffolded app
|
|
196
|
+
already renders saved layouts on every content page; keep it that way.
|
|
197
|
+
|
|
198
|
+
**Compose.** A page is not a stack of full-width `heading` + `richText` blocks.
|
|
199
|
+
That is the shape an agent falls into when it doesn't know the palette, and it
|
|
200
|
+
reads as a styled document rather than a site. Sections are bands (each owns its
|
|
201
|
+
background and padding), `columns` splits them, and the object modules below do
|
|
202
|
+
the rest.
|
|
203
|
+
|
|
204
|
+
### The built-in palette
|
|
205
|
+
|
|
206
|
+
`name` is the exact `type` value in a layout node and in MCP `add_module`. A
|
|
207
|
+
custom module that reuses one of these names **overrides** the built-in
|
|
208
|
+
everywhere.
|
|
209
|
+
|
|
210
|
+
**Layout** — the skeleton of a page.
|
|
211
|
+
|
|
212
|
+
| `name` | Purpose |
|
|
213
|
+
|---|---|
|
|
214
|
+
| `section` | Top-level band in a region; owns background colour and vertical padding |
|
|
215
|
+
| `container` | Width-capped, padded wrapper inside a section |
|
|
216
|
+
| `columns` | Multi-column split (`preset` picks the ratio, `gap` the spacing) |
|
|
217
|
+
| `spacer` | Fixed vertical gap |
|
|
218
|
+
| `divider` | Horizontal rule (colour, thickness) |
|
|
219
|
+
|
|
220
|
+
**Elements** — the primitives.
|
|
221
|
+
|
|
222
|
+
| `name` | Purpose |
|
|
223
|
+
|---|---|
|
|
224
|
+
| `richText` | WYSIWYG prose — the default home for owner-authored copy |
|
|
225
|
+
| `heading` | One heading, with its level (h1–h6) |
|
|
226
|
+
| `text` | Plain paragraph text |
|
|
227
|
+
| `image` | Media-library image with alt text and caption |
|
|
228
|
+
| `button` | Labelled link with a style variant |
|
|
229
|
+
| `html` | Raw HTML escape hatch (sanitized) — not a layout tool |
|
|
230
|
+
| `blockquote` | Quote with author and citation URL |
|
|
231
|
+
| `badge` | Small label/pill, optionally linked |
|
|
232
|
+
| `icon` | Named icon with size, colour, accessible label |
|
|
233
|
+
| `video` | Video file with poster, autoplay/loop/muted |
|
|
234
|
+
| `embed` | Third-party embed code |
|
|
235
|
+
|
|
236
|
+
**Objects** — the composed blocks.
|
|
237
|
+
|
|
238
|
+
| `name` | Purpose |
|
|
239
|
+
|---|---|
|
|
240
|
+
| `card` | Title + body + image + link — the generic tile |
|
|
241
|
+
| `accordion` | One collapsible title/content panel |
|
|
242
|
+
| `tabs` | Tabbed panels (`tabs` JSON prop) |
|
|
243
|
+
| `carousel` | Rotating slides (`items` JSON prop, autoplay/interval) |
|
|
244
|
+
| `testimonial` | Quote + author + role + avatar |
|
|
245
|
+
| `hero` | Headline, subtext, background image, one CTA button |
|
|
246
|
+
| `cta` | Headline + subtext + button — the conversion band |
|
|
247
|
+
| `pricing` | One plan: name, price, period, features, button |
|
|
248
|
+
| `faq` | One question/answer pair, optionally open by default |
|
|
249
|
+
| `alert` | Inline notice (info/warning/…), optionally dismissible |
|
|
250
|
+
| `countdown` | Counts down to a target date |
|
|
251
|
+
| `feedDisplay` | Renders a Feed (§1) — title, summary, image per item |
|
|
252
|
+
| `tagCloud` | Weighted tag list |
|
|
253
|
+
| `relatedContent` | Manual list of related links (`items` JSON prop) |
|
|
254
|
+
| `categorySummary` | One category: name, description, image, count |
|
|
255
|
+
| `categoryList` | Auto-listed child categories under a parent slug |
|
|
256
|
+
| `comments` | Moderated comment thread for the current node |
|
|
257
|
+
|
|
258
|
+
**Navigation** — wayfinding.
|
|
259
|
+
|
|
260
|
+
| `name` | Purpose |
|
|
261
|
+
|---|---|
|
|
262
|
+
| `breadcrumbs` | Trail of ancestor links |
|
|
263
|
+
| `navMenu` | A named menu (`menuSlug`) rendered to a depth |
|
|
264
|
+
| `navigation` | Site navigation with a style variant and depth |
|
|
265
|
+
| `siteMap` | Nested list of the site tree |
|
|
266
|
+
| `searchBox` | Compact search input posting to a results URL |
|
|
267
|
+
| `search` | Full search form (method, label, results URL) |
|
|
268
|
+
|
|
269
|
+
**Personalization**, **Compliance**, and **Saved** (references to things
|
|
270
|
+
authored elsewhere in the admin).
|
|
271
|
+
|
|
272
|
+
| `name` | Purpose |
|
|
273
|
+
|---|---|
|
|
274
|
+
| `variantContainer` | Module-level arms revealed client-side per audience — read §8 first |
|
|
275
|
+
| `personalizationBadge` | Mount point for the "you're seeing X" transparency control |
|
|
276
|
+
| `socialShare` | Share links for a URL/title across networks |
|
|
277
|
+
| `map` | Embedded map at a lat/lng or address |
|
|
278
|
+
| `cookieConsent` | Consent banner with message + privacy-policy link |
|
|
279
|
+
| `component` | Places a saved reusable component by slug (§4) |
|
|
280
|
+
| `form` | Renders a form built in the Forms admin (§6) |
|
|
281
|
+
| `formEmbed` | Embeds a form by slug — lighter-weight placement of the same |
|
|
282
|
+
|
|
283
|
+
### Which do I reach for? (in order)
|
|
284
|
+
|
|
285
|
+
1. **A built-in already does it** → use it. Read the tables before inventing
|
|
286
|
+
anything; "the palette is thin" is almost always "I didn't look".
|
|
287
|
+
2. **Prose, a heading, an image, a link** → `richText` / `heading` / `image` /
|
|
288
|
+
`button`. Never build a module to hold copy.
|
|
289
|
+
3. **A repeating set of items** → a collection: folder + Feed + `feedDisplay`
|
|
290
|
+
(§1). Know the ceiling: Feed Display renders **title, summary and image
|
|
291
|
+
only**. If the cards need a price or a job title, denormalize it into
|
|
292
|
+
`summary` at authoring time, or build a custom module that reads the feed.
|
|
293
|
+
4. **The same designed section on several pages** → build it once and **Save as
|
|
294
|
+
Component** (§4). Zero code, live references, owner-editable.
|
|
295
|
+
5. **A composition or brand block the palette genuinely lacks** — media⇄text
|
|
296
|
+
split, stat callout, steps band, tabbed code sample, diagram frame → **one
|
|
297
|
+
custom module, reused**. Not a one-off `html` blob pasted per page.
|
|
298
|
+
|
|
299
|
+
### Custom modules
|
|
300
|
+
|
|
301
|
+
Three steps, all required: `defineModule({ name, label, component, category,
|
|
302
|
+
props })` in `kywi.config.ts` `modules: []`; a `'use client'` React component
|
|
303
|
+
taking `{ props }: { props: Record<string, unknown> }`; an entry in
|
|
304
|
+
`lib/modules.tsx` `moduleComponents` keyed by the same `name`. Skip the third
|
|
305
|
+
and the module renders "Unknown module" in the editor *and* on the live page.
|
|
306
|
+
Full recipe, worked example and hazards: the **`kywi-custom-modules` skill**.
|
|
307
|
+
|
|
308
|
+
**Design the props for the owner, not for yourself.** The props panel gives the
|
|
309
|
+
owner real controls for `text`/`textarea`/`richText` (text inputs),
|
|
310
|
+
`image`/`file` (media picker), `boolean` (checkbox) and `number`/`date`/`color`
|
|
311
|
+
(native inputs); `select`, `slug`, `relationship` and `multiSelect` fall back to
|
|
312
|
+
a plain text input, so name the allowed values in the label. A `json` prop is a
|
|
313
|
+
raw JSON textarea — developer territory. A "three stats" module with `stats: {
|
|
314
|
+
type: 'json' }` has taken the copy away from the owner; the same module with
|
|
315
|
+
`stat1Value` / `stat1Label` … as `text` props hasn't.
|
|
316
|
+
|
|
317
|
+
**Declare the primary copy prop first.** The front-of-site editor makes a
|
|
318
|
+
module's *first* prop inline-editable on the page when its type is `text`,
|
|
319
|
+
`textarea` or `richText` — so `props: { headline, … }` can be typed over
|
|
320
|
+
directly on the live page, and `props: { variant, headline, … }` cannot.
|
|
321
|
+
|
|
322
|
+
Also: create **saved layouts** (Layouts admin) as page templates — "Landing
|
|
323
|
+
page", "Case study" — so new pages start from a consistent skeleton. Hand-written
|
|
324
|
+
JSX stays fine for genuinely fixed chrome (a bespoke 404, legal boilerplate) —
|
|
325
|
+
but if marketing will ever want to swap a headline, it's a layout page.
|
|
165
326
|
|
|
166
327
|
## 6. Forms: always the Forms builder
|
|
167
328
|
|
|
@@ -209,6 +370,48 @@ default variant must stand alone (personalization is progressive enhancement);
|
|
|
209
370
|
one experiment per conversion goal, and let it conclude before layering more.
|
|
210
371
|
If no, skip it — the machinery is there when they grow into it.
|
|
211
372
|
|
|
373
|
+
### Variant containers: pick the right shape
|
|
374
|
+
|
|
375
|
+
Two different things are called `variantContainer`, and they are not
|
|
376
|
+
interchangeable.
|
|
377
|
+
|
|
378
|
+
- **Section-level** — a *region* node, sibling of sections (`defaultSections` +
|
|
379
|
+
`variants[].sections`). Resolved **on the server** from the visitor's audience
|
|
380
|
+
or experiment arm, so the first paint is already correct: no flash, and the
|
|
381
|
+
page is complete with JavaScript off. This is the default for anything the
|
|
382
|
+
server can know — rule-based audiences (UTM, referrer, the `kywi_audience`
|
|
383
|
+
pin) and A/B experiments. Since 0.6.3 it also renders in the layout editor as
|
|
384
|
+
a badged block with an **arm switcher**, each arm editable with the ordinary
|
|
385
|
+
section/column/module tools, so server-resolution no longer costs the owner
|
|
386
|
+
their editing surface.
|
|
387
|
+
- **Module-level** — the `variantContainer` *module*, placed in a column; its
|
|
388
|
+
arms are HTML strings (`defaultContent`, `variants: [{audienceId, label,
|
|
389
|
+
content}]`). Every arm ships in the HTML (default visible, the rest
|
|
390
|
+
`display:none`) and the browser runtime reveals the matching one. Use it
|
|
391
|
+
**only for signals the server cannot read at first paint** — in practice, a
|
|
392
|
+
self-ID answer held in `localStorage`.
|
|
393
|
+
|
|
394
|
+
Four traps, each of which fails silently:
|
|
395
|
+
|
|
396
|
+
1. **Use the layout module, not the React component.** `VariantContainer.tsx`
|
|
397
|
+
(`core/src/components/personalization/`) has a loading branch that renders
|
|
398
|
+
the skeleton and nothing else — a JS-off visitor gets an empty container —
|
|
399
|
+
and its `ab_test` path draws a fresh `Math.random()` per render, so the same
|
|
400
|
+
visitor sees a different arm on every refresh. The layout module
|
|
401
|
+
(`VariantContainerModule`) always renders the default arm.
|
|
402
|
+
2. **Leave `skeleton` off.** With `skeleton: true` the container ships
|
|
403
|
+
`aria-busy="true"` and only the client runtime clears it — a JS-off visitor
|
|
404
|
+
sits in a loading state forever.
|
|
405
|
+
3. **An audience match beats the experiment.** `resolveVariant` returns the
|
|
406
|
+
audience arm first, so a visitor who matches an audience never enters the
|
|
407
|
+
split. Don't run an experiment and an audience variant on the same container.
|
|
408
|
+
4. **The client runtime needs `/kywi.js`.** Self-ID, behavioral re-evaluation
|
|
409
|
+
and the transparency badge no-op silently unless the built
|
|
410
|
+
`@kywi-software/js` bundle is at `public/kywi.js`.
|
|
411
|
+
|
|
412
|
+
Standing rule: **the default arm must be complete on its own.** It is what
|
|
413
|
+
search engines, answer engines, and every opted-out visitor receive.
|
|
414
|
+
|
|
212
415
|
## 9. Editorial workflow: drafts, review, versions
|
|
213
416
|
|
|
214
417
|
For any site with more than one author — or an owner who wants a safety net —
|
|
@@ -288,6 +491,8 @@ early, so ask at scoping time.
|
|
|
288
491
|
- Marketing imagery in `/public`.
|
|
289
492
|
- A "blog" that is a folder of `.mdx` files the owner can't edit.
|
|
290
493
|
- One-off content types created in config for shapes the owner will evolve.
|
|
494
|
+
- A page built as a flat stack of full-width `heading` + `richText` modules.
|
|
495
|
+
- A custom module whose owner-facing copy lives in a `json` prop.
|
|
291
496
|
- Personalization built speculatively, with no owner request behind it.
|
|
292
497
|
- Direct-publish-only workflow on a multi-author site.
|
|
293
498
|
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: kywi-custom-modules
|
|
3
|
+
description: Use when a page needs a block the built-in module palette doesn't have — a media/text split, stat callout, steps band, tabbed code sample, diagram frame, branded hero — or whenever you catch yourself pasting bespoke markup into an `html` module or writing a page section as JSX. Covers when a custom module is the right call, the three-step recipe, owner-editable props, and the hazards that break SSR, JS-off visitors, or the owner's editing surface.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Build a custom module (only when the palette falls short)
|
|
7
|
+
|
|
8
|
+
A custom module is a React component the *developer* owns and the *owner*
|
|
9
|
+
places and configures. It's the right tool for composition the built-in palette
|
|
10
|
+
lacks — and the wrong tool for anything the palette already does, for copy, and
|
|
11
|
+
for one-off page decoration.
|
|
12
|
+
|
|
13
|
+
## Workflow
|
|
14
|
+
|
|
15
|
+
### 1. Confirm you need one
|
|
16
|
+
|
|
17
|
+
Walk the ladder in AGENTS.md §5 first: built-in module → `richText`/`heading`/
|
|
18
|
+
`image`/`button` → collection + `feedDisplay` → saved component → custom module.
|
|
19
|
+
|
|
20
|
+
**Build one when:**
|
|
21
|
+
|
|
22
|
+
- The page needs a *composition* the palette has no shape for: media⇄text
|
|
23
|
+
split, stat/metric callout row, numbered steps band, tabbed code sample,
|
|
24
|
+
diagram frame, a hero with a product visual beside the copy.
|
|
25
|
+
- The same brand-specific block will recur across pages and needs consistent
|
|
26
|
+
markup and styling.
|
|
27
|
+
- A built-in is 90% right but needs different markup — register a module with
|
|
28
|
+
the built-in's `name` to **override** it everywhere (editor and public site).
|
|
29
|
+
|
|
30
|
+
**Don't build one when:**
|
|
31
|
+
|
|
32
|
+
- A built-in covers it. Read the §5 palette tables; "the palette is thin" is
|
|
33
|
+
almost always "I didn't look".
|
|
34
|
+
- It's copy. Copy goes in `richText` — a module per paragraph is a smell.
|
|
35
|
+
- It's a repeating set of items. That's a collection (folder + feed +
|
|
36
|
+
`feedDisplay`), not a module with a hardcoded array.
|
|
37
|
+
- It'd be used once, on one page. Compose it from built-ins, or if the design
|
|
38
|
+
is genuinely bespoke chrome, keep it in the page component and accept that
|
|
39
|
+
the owner can't edit it — deliberately, and say so at handover.
|
|
40
|
+
- You'd need it because the owner's content doesn't fit `feedDisplay`'s
|
|
41
|
+
title/summary/image ceiling. Denormalizing into `summary` is usually cheaper.
|
|
42
|
+
|
|
43
|
+
### 2. Design the props before writing the component
|
|
44
|
+
|
|
45
|
+
The props *are* the owner's editing surface. Decide, field by field, who edits
|
|
46
|
+
it — then pick the type:
|
|
47
|
+
|
|
48
|
+
| Prop type | Editor control | Use for |
|
|
49
|
+
|---|---|---|
|
|
50
|
+
| `text` | text input | headlines, eyebrows, labels, stat values |
|
|
51
|
+
| `textarea` | multi-line input | short body copy, quotes |
|
|
52
|
+
| `richText` | rich-text input | owner-authored HTML body copy |
|
|
53
|
+
| `image` / `file` | media picker | anything from the Media library |
|
|
54
|
+
| `boolean` | checkbox | layout flips (`reverse`), show/hide |
|
|
55
|
+
| `number` / `date` / `color` | native inputs | counts, deadlines, accents |
|
|
56
|
+
| `select` / `slug` / `relationship` | plain text input (today) | constrained values — name the options in the label |
|
|
57
|
+
| `json` | raw JSON textarea | **developer territory** — structure the owner never edits |
|
|
58
|
+
|
|
59
|
+
Two rules that decide whether the module is really editable:
|
|
60
|
+
|
|
61
|
+
- **Keep owner copy out of `json`.** `stats: {type:'json'}` hands the owner a
|
|
62
|
+
JSON blob; `stat1Value` / `stat1Label` / `stat2Value` … as `text` props hands
|
|
63
|
+
them fields. Use `json` only for structure that never changes after build.
|
|
64
|
+
- **Declare the primary copy prop first.** The front-of-site editor makes a
|
|
65
|
+
module's *first* prop inline-editable on the live page when its type is
|
|
66
|
+
`text`, `textarea` or `richText`. Put the headline first; put `variant` and
|
|
67
|
+
layout switches after it.
|
|
68
|
+
|
|
69
|
+
### 3. Register it — `defineModule` in `kywi.config.ts`
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import { defineKywiConfig, defineModule } from '@kywi-software/core/config'
|
|
73
|
+
|
|
74
|
+
export default defineKywiConfig({
|
|
75
|
+
// …sites, themes, contentTypes…
|
|
76
|
+
modules: [
|
|
77
|
+
defineModule({
|
|
78
|
+
name: 'splitFeature', // the layout node `type` + MCP add_module type
|
|
79
|
+
label: 'Media / text split', // what the owner sees in the palette
|
|
80
|
+
component: 'splitFeature', // the key you'll map in lib/modules.tsx
|
|
81
|
+
category: 'Marketing', // groups it in the palette
|
|
82
|
+
props: {
|
|
83
|
+
heading: { type: 'text', label: 'Heading' }, // first = inline-editable
|
|
84
|
+
body: { type: 'richText', label: 'Body' },
|
|
85
|
+
image: { type: 'image', label: 'Image' },
|
|
86
|
+
imageAlt: { type: 'text', label: 'Image alt text' },
|
|
87
|
+
reverse: { type: 'boolean', label: 'Image on the left', defaultValue: false },
|
|
88
|
+
},
|
|
89
|
+
}),
|
|
90
|
+
],
|
|
91
|
+
})
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### 4. Write the component
|
|
95
|
+
|
|
96
|
+
```tsx
|
|
97
|
+
// lib/split-feature.tsx
|
|
98
|
+
'use client'
|
|
99
|
+
import { resolveMediaRef } from '@kywi-software/core/layout'
|
|
100
|
+
|
|
101
|
+
export function SplitFeature({ props }: { props: Record<string, unknown> }) {
|
|
102
|
+
const src = resolveMediaRef(props.image) // media UUID → public file URL
|
|
103
|
+
return (
|
|
104
|
+
<div className={`split-feature${props.reverse === true ? ' is-reversed' : ''}`}>
|
|
105
|
+
<div className="split-feature__copy">
|
|
106
|
+
<h2>{String(props.heading ?? '')}</h2>
|
|
107
|
+
<div dangerouslySetInnerHTML={{ __html: String(props.body ?? '') }} />
|
|
108
|
+
</div>
|
|
109
|
+
{src ? <img src={src} alt={String(props.imageAlt ?? '')} /> : null}
|
|
110
|
+
</div>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Every prop is `unknown` — coerce and default each one. A module that throws on a
|
|
116
|
+
missing prop breaks the editor canvas as well as the page.
|
|
117
|
+
|
|
118
|
+
### 5. Map it in `lib/modules.tsx`
|
|
119
|
+
|
|
120
|
+
```tsx
|
|
121
|
+
import { SplitFeature } from './split-feature'
|
|
122
|
+
|
|
123
|
+
export const moduleComponents: ModuleComponentMap = {
|
|
124
|
+
splitFeature: SplitFeature,
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
This one map is passed to both `KywiAdminApp` and `KywiLayout`, so the module
|
|
129
|
+
renders identically in the editor canvas and live. **Skip this step and the
|
|
130
|
+
module renders "Unknown module" in both** — the most common custom-module bug.
|
|
131
|
+
The host merges `{ ...BUILT_IN_MODULE_COMPONENTS, ...moduleComponents }`, so an
|
|
132
|
+
entry named after a built-in replaces it.
|
|
133
|
+
|
|
134
|
+
### 6. Hazards checklist
|
|
135
|
+
|
|
136
|
+
- **Unique ids need `React.useId()`.** A module-scope counter produces different
|
|
137
|
+
ids on server and client and breaks hydration. Same for `Date.now()` and
|
|
138
|
+
`Math.random()` in render.
|
|
139
|
+
- **Escape owner text inside `<script type="application/ld+json">`.** Replace
|
|
140
|
+
every `<` with the JS escape `\u003c`, break up `-->`, and escape the line
|
|
141
|
+
separators U+2028/U+2029 (matched via
|
|
142
|
+
`String.fromCharCode(0x2028, 0x2029)`) — otherwise a stray character in owner
|
|
143
|
+
copy ends the script tag early.
|
|
144
|
+
- **`dangerouslySetInnerHTML` is the pattern for `richText`/HTML props** — the
|
|
145
|
+
values are sanitized by Kywi before storage and on render. Don't reach for it
|
|
146
|
+
for plain `text` props.
|
|
147
|
+
- **Stay safe with JavaScript off.** A `'use client'` module still server-renders
|
|
148
|
+
its initial HTML — but anything that only appears after an effect is invisible
|
|
149
|
+
to crawlers and JS-off visitors. Interactivity is progressive enhancement over
|
|
150
|
+
complete server-rendered markup.
|
|
151
|
+
- **Style through theme tokens** (`var(--kywi-color-primary)`, `--kywi-spacing-*`)
|
|
152
|
+
in the site stylesheet, not hardcoded hex — the owner retunes the theme from
|
|
153
|
+
`kywi.config.ts` and your module should follow.
|
|
154
|
+
- **Don't fetch in the module.** Data comes from props (or, for feeds, the
|
|
155
|
+
host's server-side hydration). A module that fetches breaks the editor canvas.
|
|
156
|
+
|
|
157
|
+
### 7. Verify
|
|
158
|
+
|
|
159
|
+
1. Restart the dev server (config changes need a restart).
|
|
160
|
+
2. Open the layout editor: the module appears in the palette under its
|
|
161
|
+
`category`, with its `label`.
|
|
162
|
+
3. Place it, fill every prop from the props panel, save — it renders in the
|
|
163
|
+
canvas (not "Unknown module") and on the public page.
|
|
164
|
+
4. Open the public page as a signed-in admin → **Edit this page**: the first
|
|
165
|
+
text prop is editable by typing on the page.
|
|
166
|
+
5. Load the page with JavaScript disabled: the content is all there.
|
|
167
|
+
|
|
168
|
+
### 8. Handover note
|
|
169
|
+
|
|
170
|
+
Record the module in `CONTENT-MODEL.md` (reusable components / modules) with one
|
|
171
|
+
line per prop: what the owner changes and where. A module the owner doesn't know
|
|
172
|
+
exists is a module they'll ask a developer to change.
|
package/lib/templates.mjs
CHANGED
|
@@ -102,7 +102,7 @@ function packageJson(a) {
|
|
|
102
102
|
/** @param {Answers} a */
|
|
103
103
|
function kywiConfig(a) {
|
|
104
104
|
const providers = a.authProviders.map((p) => `'${p}'`).join(', ')
|
|
105
|
-
return `import { defineKywiConfig, defineSite, defineTheme } from '@kywi-software/core/config'
|
|
105
|
+
return `import { defineKywiConfig, defineSite, defineTheme, defineModule } from '@kywi-software/core/config'
|
|
106
106
|
import { assertProductionAuthSecret } from '@kywi-software/core/host'
|
|
107
107
|
|
|
108
108
|
// Refuse to build/start in production with a missing or dev-fallback AUTH_SECRET
|
|
@@ -179,9 +179,14 @@ export default defineKywiConfig({
|
|
|
179
179
|
}),
|
|
180
180
|
],
|
|
181
181
|
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
182
|
+
// Config-defined content types. "Page" ships so a fresh site can create and
|
|
183
|
+
// publish pages immediately — that's the floor, not the pattern.
|
|
184
|
+
//
|
|
185
|
+
// DEFAULT: create your own types in the admin's **Type Designer**
|
|
186
|
+
// (admin → Content Types). Admin-created types are runtime-managed, so the
|
|
187
|
+
// site owner can add a field next year without a developer or a migration.
|
|
188
|
+
// Add a type HERE only when the *developer* must own its shape — code depends
|
|
189
|
+
// on it, it's reviewed in git, it migrates with \`pnpm migrate\`.
|
|
185
190
|
contentTypes: [
|
|
186
191
|
{
|
|
187
192
|
name: 'page',
|
|
@@ -190,6 +195,29 @@ export default defineKywiConfig({
|
|
|
190
195
|
fields: [],
|
|
191
196
|
},
|
|
192
197
|
],
|
|
198
|
+
|
|
199
|
+
// Custom layout modules (\`defineModule\`) — the palette additions this project
|
|
200
|
+
// owns. Register a module here, then map its \`name\` → the React component that
|
|
201
|
+
// renders it in \`lib/modules.tsx\` (both the admin editor and the public site
|
|
202
|
+
// read that map). Reach for one only when the built-in palette genuinely lacks
|
|
203
|
+
// the composition; see AGENTS.md §5 and the \`kywi-custom-modules\` skill.
|
|
204
|
+
//
|
|
205
|
+
// modules: [
|
|
206
|
+
// defineModule({
|
|
207
|
+
// name: 'splitFeature', // layout node \`type\` + MCP add_module type
|
|
208
|
+
// label: 'Media / text split', // what the owner sees in the palette
|
|
209
|
+
// component: 'splitFeature', // the key you map in lib/modules.tsx
|
|
210
|
+
// category: 'Marketing',
|
|
211
|
+
// props: {
|
|
212
|
+
// // First text prop = inline-editable on the live page. Keep owner
|
|
213
|
+
// // copy in text/textarea/richText props, never in a json blob.
|
|
214
|
+
// heading: { type: 'text', label: 'Heading' },
|
|
215
|
+
// body: { type: 'richText', label: 'Body' },
|
|
216
|
+
// image: { type: 'image', label: 'Image' },
|
|
217
|
+
// },
|
|
218
|
+
// }),
|
|
219
|
+
// ],
|
|
220
|
+
modules: [],
|
|
193
221
|
})
|
|
194
222
|
`
|
|
195
223
|
}
|
|
@@ -755,20 +783,30 @@ export function clientRuntimeEnabled(): boolean {
|
|
|
755
783
|
|
|
756
784
|
/** The self-ID widget config in the serializable shape the client runtime reads. */
|
|
757
785
|
export interface PublicSelfIdWidget {
|
|
758
|
-
fields: Array<{
|
|
786
|
+
fields: Array<{
|
|
787
|
+
id: string
|
|
788
|
+
label: string
|
|
789
|
+
type: 'select'
|
|
790
|
+
options?: Array<{ value: string; label: string }>
|
|
791
|
+
required?: boolean
|
|
792
|
+
}>
|
|
759
793
|
headline: string
|
|
760
794
|
subheadline?: string
|
|
761
795
|
submitLabel: string
|
|
762
796
|
skipLabel?: string
|
|
763
|
-
displayMode: 'modal' | 'inline' | 'slide-in'
|
|
797
|
+
displayMode: 'modal' | 'inline' | 'slide-in' | 'hello_bar_top' | 'hello_bar_bottom' | 'drawer'
|
|
764
798
|
frequency: 'once' | 'session' | 'always'
|
|
765
799
|
trigger: SelfIdTrigger
|
|
766
800
|
}
|
|
767
801
|
|
|
802
|
+
// The admin's five display modes (SelfIdWidgetConfig['displayMode']) and the
|
|
803
|
+
// client runtime's (PublicSelfIdWidget['displayMode'], @kywi-software/js) are the
|
|
804
|
+
// same set — the runtime implements hello_bar_top/hello_bar_bottom/drawer as
|
|
805
|
+
// their own layouts, not folded into slide-in (kywi-cms#97) — so this is a
|
|
806
|
+
// passthrough. Kept as a named function (rather than assigning displayMode
|
|
807
|
+
// directly) so the two types stay checked against each other at compile time.
|
|
768
808
|
function mapDisplayMode(mode: SelfIdWidgetConfig['displayMode']): PublicSelfIdWidget['displayMode'] {
|
|
769
|
-
|
|
770
|
-
if (mode === 'inline') return 'inline'
|
|
771
|
-
return 'slide-in' // hello bars + drawer both animate in from an edge
|
|
809
|
+
return mode
|
|
772
810
|
}
|
|
773
811
|
|
|
774
812
|
function mapFrequency(freq: SelfIdWidgetConfig['frequency']): PublicSelfIdWidget['frequency'] {
|
|
@@ -797,7 +835,10 @@ export async function resolveSelfIdWidget(runtime: KywiRuntime): Promise<PublicS
|
|
|
797
835
|
id: f.id,
|
|
798
836
|
label: f.label,
|
|
799
837
|
type: 'select' as const,
|
|
800
|
-
|
|
838
|
+
// {value, label} pairs, not bare values — the client runtime renders the
|
|
839
|
+
// label and submits the value (kywi-cms#96); dropping the label here is
|
|
840
|
+
// what made every option render as its raw value.
|
|
841
|
+
options: f.picklist,
|
|
801
842
|
required: f.required,
|
|
802
843
|
}))
|
|
803
844
|
if (fields.length === 0) return null
|
|
@@ -921,10 +962,33 @@ async function fetchFreshAccessToken(origin: string, refreshToken: string): Prom
|
|
|
921
962
|
}
|
|
922
963
|
}
|
|
923
964
|
|
|
965
|
+
// Markdown content negotiation by URL suffix. Core serves markdown at
|
|
966
|
+
// \`/api/v1/ax/md/slug/<slug>\` and the AX layer enables it (ax.markdown), but the
|
|
967
|
+
// scaffold never wires the friendly \`/<path>.md\` URL the AX pitch (and the
|
|
968
|
+
// developer copy) names — so it 404s despite the feature being on. Rewrite any
|
|
969
|
+
// GET/HEAD for \`/<path>.md\` onto the core route so the named URL actually
|
|
970
|
+
// resolves. Nested paths keep their full slug (e.g. /docs/foo.md -> docs/foo).
|
|
971
|
+
// Never intercepts Next internals or the API — those never carry this suffix,
|
|
972
|
+
// but are excluded explicitly since the matcher's own exclusion is broad.
|
|
973
|
+
function handleMarkdownNegotiation(req: NextRequest): NextResponse | null {
|
|
974
|
+
const { pathname } = req.nextUrl
|
|
975
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return null
|
|
976
|
+
if (!pathname.endsWith('.md')) return null
|
|
977
|
+
if (pathname.startsWith('/api/') || pathname.startsWith('/_next/')) return null
|
|
978
|
+
const slug = pathname.replace(/^\\//, '').replace(/\\.md$/, '')
|
|
979
|
+
if (!slug) return null
|
|
980
|
+
const url = req.nextUrl.clone()
|
|
981
|
+
url.pathname = '/api/v1/ax/md/slug/' + slug
|
|
982
|
+
return NextResponse.rewrite(url)
|
|
983
|
+
}
|
|
984
|
+
|
|
924
985
|
export async function middleware(req: NextRequest): Promise<NextResponse> {
|
|
925
986
|
const { pathname } = req.nextUrl
|
|
926
987
|
if (isAuthEndpoint(pathname)) return NextResponse.next()
|
|
927
988
|
|
|
989
|
+
const md = handleMarkdownNegotiation(req)
|
|
990
|
+
if (md) return md
|
|
991
|
+
|
|
928
992
|
// Public pages are never auth-gated — just give them a stable visitor id.
|
|
929
993
|
const isAdmin = pathname === '/admin' || pathname.startsWith('/admin/')
|
|
930
994
|
const isApi = pathname.startsWith('/api/v1/')
|
|
@@ -980,6 +1044,10 @@ export const config = {
|
|
|
980
1044
|
// any path with a file extension (static assets, /favicon.ico, /kywi.js, and
|
|
981
1045
|
// the AX files /robots.txt, /sitemap.xml, /llms*.txt).
|
|
982
1046
|
'/((?!_next/|.*\\\\..*).*)',
|
|
1047
|
+
// \`/<path>.md\` — the markdown-negotiation URL. The extension-excluding
|
|
1048
|
+
// pattern above skips it, so it needs its own entry to reach
|
|
1049
|
+
// handleMarkdownNegotiation.
|
|
1050
|
+
'/((?!_next/|api/).*\\\\.md)',
|
|
983
1051
|
],
|
|
984
1052
|
}
|
|
985
1053
|
`
|
|
@@ -1239,12 +1307,17 @@ function hasRenderableLayout(layout: LayoutDocument | null | undefined): layout
|
|
|
1239
1307
|
// Is the current visitor a signed-in admin who may edit? The public route is
|
|
1240
1308
|
// outside the middleware's auth matcher, so verify the httpOnly session cookie
|
|
1241
1309
|
// here and derive the content permissions the overlay needs. Returns null for
|
|
1242
|
-
// anyone who cannot edit — no edit DOM
|
|
1310
|
+
// anyone who cannot edit — no edit DOM (not even the browse-mode toolbar) is
|
|
1311
|
+
// emitted for them. Called on EVERY request (not just ?kywi-edit=1 ones) so the
|
|
1312
|
+
// toolbar can surface itself for a signed-in admin who is just browsing — this
|
|
1313
|
+
// is a cookie read + JWT verify, no DB round-trip, so the cost on an anonymous
|
|
1314
|
+
// visitor's fast path is a fast null return (\`readSessionClaims\` bails
|
|
1315
|
+
// immediately when there's no cookie).
|
|
1243
1316
|
async function resolveEditPermissions() {
|
|
1244
1317
|
const token = (await cookies()).get(ACCESS_COOKIE)?.value
|
|
1245
1318
|
const claims = await readSessionClaims(token, config.auth.secret)
|
|
1246
1319
|
if (!claims || !canAccessContent(claims.role, 'write')) return null
|
|
1247
|
-
return { canEdit: true, canPublish: canAccessContent(claims.role, 'publish') }
|
|
1320
|
+
return { canEdit: true, canPublish: canAccessContent(claims.role, 'publish'), role: claims.role }
|
|
1248
1321
|
}
|
|
1249
1322
|
|
|
1250
1323
|
// "/" resolves the seeded Home node; any other URL resolves the published node at
|
|
@@ -1264,9 +1337,13 @@ export default async function PublicPage({ params, searchParams }: Params & Sear
|
|
|
1264
1337
|
const featured = mediaUrl(node['featuredImageId'])
|
|
1265
1338
|
const layout = node['layout'] as LayoutDocument | null | undefined
|
|
1266
1339
|
|
|
1267
|
-
//
|
|
1340
|
+
// Resolved on every request so a signed-in admin gets the persistent browse
|
|
1341
|
+
// toolbar even when just browsing — ?kywi-edit=1 only decides whether the
|
|
1342
|
+
// page auto-enters the full overlay editor on mount (kywi-cms#93). An
|
|
1343
|
+
// anonymous/read-only visitor resolves to perms === null: zero extra client
|
|
1344
|
+
// JS, identical output to before this route ever heard of the overlay.
|
|
1268
1345
|
const editRequested = (await searchParams)['kywi-edit'] === '1'
|
|
1269
|
-
const perms =
|
|
1346
|
+
const perms = await resolveEditPermissions()
|
|
1270
1347
|
|
|
1271
1348
|
// Server-side personalization (#50), evaluated once: the winning audience (or a
|
|
1272
1349
|
// kywi_preview_init preview) drives page variants + variantContainers, and this
|
|
@@ -1347,12 +1424,16 @@ export default async function PublicPage({ params, searchParams }: Params & Sear
|
|
|
1347
1424
|
)
|
|
1348
1425
|
}
|
|
1349
1426
|
|
|
1350
|
-
// Mount the overlay (client)
|
|
1427
|
+
// Mount the overlay (client) for any authenticated admin (write role+) —
|
|
1428
|
+
// browse mode renders just the slim toolbar; ?kywi-edit=1 (editRequested)
|
|
1429
|
+
// additionally auto-starts the full overlay editor. Nothing mounts for an
|
|
1430
|
+
// anonymous or read-only visitor.
|
|
1351
1431
|
if (perms) {
|
|
1352
1432
|
return (
|
|
1353
1433
|
<KywiFrontEdit
|
|
1354
1434
|
canEdit={perms.canEdit}
|
|
1355
1435
|
canPublish={perms.canPublish}
|
|
1436
|
+
editRequested={editRequested}
|
|
1356
1437
|
contentId={contentId}
|
|
1357
1438
|
contentType={contentType}
|
|
1358
1439
|
pageTitle={title}
|
|
@@ -1372,18 +1453,25 @@ export default async function PublicPage({ params, searchParams }: Params & Sear
|
|
|
1372
1453
|
}
|
|
1373
1454
|
|
|
1374
1455
|
/**
|
|
1375
|
-
* Front-of-site edit overlay (client). Mounted by the public page
|
|
1376
|
-
* authenticated admin
|
|
1377
|
-
*
|
|
1456
|
+
* Front-of-site edit overlay (client). Mounted by the public page for ANY
|
|
1457
|
+
* authenticated admin (write role+) — the page verifies the session cookie
|
|
1458
|
+
* server-side first (resolveEditPermissions, called unconditionally) and only
|
|
1459
|
+
* mounts this when that check passes, so canEdit is always true here.
|
|
1378
1460
|
*
|
|
1379
1461
|
* Two modes, same component:
|
|
1380
|
-
* - **Browse** — core's slim `KywiEditToolbar
|
|
1381
|
-
*
|
|
1382
|
-
*
|
|
1383
|
-
*
|
|
1384
|
-
*
|
|
1385
|
-
*
|
|
1386
|
-
*
|
|
1462
|
+
* - **Browse** (default) — core's slim `KywiEditToolbar`, a persistent
|
|
1463
|
+
* WordPress-admin-bar-style strip over the live page (page title, draft/
|
|
1464
|
+
* published status, "Edit this page", "Go to admin") — this is the
|
|
1465
|
+
* discoverability fix (kywi-cms#93): an admin browsing the public site
|
|
1466
|
+
* normally, with no query param, now sees the entry point. The editable
|
|
1467
|
+
* regions are also outlined via the `data-kywi-*` protocol.
|
|
1468
|
+
* - **Edit** (the toolbar's "Edit this page", or landing with
|
|
1469
|
+
* `?kywi-edit=1`) — the FULL `OverlayShell` layout editor, the same one the
|
|
1470
|
+
* admin app mounts, rendered from the same core module registry + custom
|
|
1471
|
+
* renderers. Save PUTs the layout document; Publish PUTs the layout then
|
|
1472
|
+
* flips status — exactly the API the admin editor uses. Entering/leaving
|
|
1473
|
+
* edit mode keeps `?kywi-edit=1` in sync via history.replaceState, so a
|
|
1474
|
+
* reload (or a shared link) lands back in the same mode.
|
|
1387
1475
|
*
|
|
1388
1476
|
* The OverlayShell is lazy-loaded (`next/dynamic`, client-only): its weight
|
|
1389
1477
|
* (dnd-kit, canvas, side panels) is only fetched when an editor actually enters
|
|
@@ -1420,6 +1508,8 @@ const OverlayShell = dynamic(
|
|
|
1420
1508
|
export interface KywiFrontEditProps {
|
|
1421
1509
|
canEdit: boolean
|
|
1422
1510
|
canPublish: boolean
|
|
1511
|
+
/** True when the page was requested with \`?kywi-edit=1\` — auto-starts the full overlay editor on mount. */
|
|
1512
|
+
editRequested: boolean
|
|
1423
1513
|
contentId: string
|
|
1424
1514
|
contentType: string
|
|
1425
1515
|
pageTitle: string
|
|
@@ -1434,13 +1524,19 @@ export interface KywiFrontEditProps {
|
|
|
1434
1524
|
}
|
|
1435
1525
|
|
|
1436
1526
|
/**
|
|
1437
|
-
* Wraps the public page with the front-of-site edit affordance. The page
|
|
1438
|
-
* renders this for
|
|
1439
|
-
*
|
|
1527
|
+
* Wraps the public page with the front-of-site edit affordance. The page
|
|
1528
|
+
* renders this for ANY signed-in admin (write role+) — canEdit is always true
|
|
1529
|
+
* here, since the page only mounts KywiFrontEdit once resolveEditPermissions
|
|
1530
|
+
* has already confirmed it server-side. Browse mode shows the persistent
|
|
1531
|
+
* KywiEditToolbar (the primary discoverability fix, kywi-cms#93); the full
|
|
1532
|
+
* OverlayShell editor only mounts once edit mode actually starts — either the
|
|
1533
|
+
* toolbar's "Edit this page" button, or landing with \`?kywi-edit=1\`
|
|
1534
|
+
* (editRequested), which auto-starts it once on mount.
|
|
1440
1535
|
*/
|
|
1441
1536
|
export function KywiFrontEdit({
|
|
1442
1537
|
canEdit,
|
|
1443
1538
|
canPublish,
|
|
1539
|
+
editRequested,
|
|
1444
1540
|
contentId,
|
|
1445
1541
|
contentType,
|
|
1446
1542
|
pageTitle,
|
|
@@ -1452,11 +1548,14 @@ export function KywiFrontEdit({
|
|
|
1452
1548
|
}: KywiFrontEditProps) {
|
|
1453
1549
|
const edit = useKywiEditMode({ canEdit, canPublish })
|
|
1454
1550
|
|
|
1455
|
-
// ?kywi-edit=1 means "enter edit mode now" — flip it on once
|
|
1551
|
+
// ?kywi-edit=1 (editRequested) means "enter edit mode now" — flip it on once
|
|
1552
|
+
// after mount. Without it the page mounts straight into browse mode (the
|
|
1553
|
+
// persistent toolbar), which is the common case now that KywiFrontEdit
|
|
1554
|
+
// renders for every signed-in admin, not only deep-linked ones.
|
|
1456
1555
|
const { startEdit, endEdit } = edit
|
|
1457
1556
|
React.useEffect(() => {
|
|
1458
|
-
startEdit()
|
|
1459
|
-
}, [startEdit])
|
|
1557
|
+
if (editRequested) startEdit()
|
|
1558
|
+
}, [editRequested, startEdit])
|
|
1460
1559
|
|
|
1461
1560
|
// Registries + renderers for the editor: built from core (no module list is
|
|
1462
1561
|
// re-declared here) and merged with this app's custom module renderers from
|
|
@@ -1514,15 +1613,23 @@ export function KywiFrontEdit({
|
|
|
1514
1613
|
}, [contentId])
|
|
1515
1614
|
|
|
1516
1615
|
// Edit mode: the full layout editor, in place, over the live page.
|
|
1616
|
+
// NOTE: no \`kywi-admin-shell\` here (kywi-cms#94). That class is the admin
|
|
1617
|
+
// design system's base+reset — font family, font size, colours, heading
|
|
1618
|
+
// resets — and wrapping the page in it re-typesets the very content the
|
|
1619
|
+
// owner is trying to judge at real width. The editor chrome carries its own
|
|
1620
|
+
// styling; the page keeps the site's.
|
|
1517
1621
|
if (edit.isEditMode && edit.canEdit) {
|
|
1518
1622
|
return (
|
|
1519
|
-
<div className="kywi-
|
|
1623
|
+
<div className="kywi-frontend-edit">
|
|
1520
1624
|
<OverlayShell
|
|
1521
1625
|
editMode={edit}
|
|
1522
1626
|
initialLayout={initialLayout}
|
|
1523
1627
|
contentId={contentId}
|
|
1524
1628
|
contentType={contentType}
|
|
1525
1629
|
pageTitle={pageTitle}
|
|
1630
|
+
/* The page's own body wrapper, so the site's page-level CSS (width,
|
|
1631
|
+
gutters, rhythm) still applies while editing in place. */
|
|
1632
|
+
pageClassName="page page--layout"
|
|
1526
1633
|
themeName="default"
|
|
1527
1634
|
themeRegistry={themeRegistry}
|
|
1528
1635
|
moduleRegistry={moduleRegistry}
|
|
@@ -1969,14 +2076,21 @@ wired in \`app/(site)/[[...slug]]/page.tsx\`). Pages without a layout fall back
|
|
|
1969
2076
|
their Body rich text. Reach for the Layout tab when a page needs sections,
|
|
1970
2077
|
columns, or modules; use the Body for simple prose.
|
|
1971
2078
|
|
|
1972
|
-
**Front-of-site editor.** Signed in as an admin,
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
2079
|
+
**Front-of-site editor.** Signed in as an admin, every public page shows a slim
|
|
2080
|
+
toolbar across the top — the page title, its draft/published status, **Edit
|
|
2081
|
+
this page**, and **Go to admin** (a WordPress-admin-bar equivalent, and the
|
|
2082
|
+
primary way to discover in-place editing; there's also an **Edit on site**
|
|
2083
|
+
link on each item in the admin content editor, for the reverse direction).
|
|
2084
|
+
Click **Edit this page** — or open a page with \`?kywi-edit=1\` as a deep link —
|
|
2085
|
+
to get the full **Layout editor** (the same drag-and-drop canvas, module
|
|
2086
|
+
palette and props panel as the admin's Layout tab) mounted right over the live
|
|
2087
|
+
page: add sections and modules, then **Save** (PUTs the layout) or **Publish**
|
|
2088
|
+
(saves + publishes). **Done** returns you to the browse toolbar. Entering or
|
|
2089
|
+
leaving the editor keeps \`?kywi-edit=1\` in the URL in sync, so reloading (or
|
|
2090
|
+
sharing the link) lands back in the same mode. It all lives in
|
|
1978
2091
|
\`app/(site)/kywi-front-edit.tsx\`; the editor bundle (and the admin stylesheet) is
|
|
1979
|
-
lazy-loaded, so pages your visitors see never carry its weight
|
|
2092
|
+
lazy-loaded, so pages your visitors see never carry its weight — an anonymous
|
|
2093
|
+
or read-only visitor gets no toolbar and no extra client JS. Note: custom
|
|
1980
2094
|
\`defineModule\` types still RENDER on the canvas, but they don't appear in the
|
|
1981
2095
|
front-of-site editor's insert palette (it uses the built-in module set) — add
|
|
1982
2096
|
them from the admin's Layout tab instead.
|
|
@@ -2064,7 +2178,10 @@ in \`kywi.config.ts\` (\`modules: [defineModule({ name: 'pricingTable', … })]\
|
|
|
2064
2178
|
then map its \`name\` to the React component that renders it in \`lib/modules.tsx\`.
|
|
2065
2179
|
That one map is passed to both the admin editor and the public site, so a custom
|
|
2066
2180
|
module renders identically in the canvas and live (it shows an "Unknown module"
|
|
2067
|
-
placeholder until you add the entry). See the
|
|
2181
|
+
placeholder until you add the entry). See the commented example in
|
|
2182
|
+
\`kywi.config.ts\`, the worked example in \`lib/modules.tsx\`, and the
|
|
2183
|
+
\`kywi-custom-modules\` skill for when a custom module is the right call at all —
|
|
2184
|
+
the built-in palette (47 modules) covers most of what a site needs.
|
|
2068
2185
|
|
|
2069
2186
|
### Create your first page
|
|
2070
2187
|
|
|
@@ -2085,12 +2202,10 @@ ${themingBlock}
|
|
|
2085
2202
|
## Project layout
|
|
2086
2203
|
|
|
2087
2204
|
\`\`\`
|
|
2088
|
-
kywi.config.ts your config: sites, themes, content types, auth, mode, admin.features
|
|
2205
|
+
kywi.config.ts your config: sites, themes, content types, custom modules, auth, mode, admin.features
|
|
2089
2206
|
AGENTS.md guidance for AI agents working on this site (Kywi's building patterns)
|
|
2090
2207
|
CLAUDE.md points AI agents to AGENTS.md
|
|
2091
|
-
.claude/skills/
|
|
2092
|
-
.claude/skills/kywi-collections/SKILL.md collections skill (loaded automatically)
|
|
2093
|
-
.claude/skills/kywi-personalization/SKILL.md personalization skill (loaded automatically)
|
|
2208
|
+
${SKILLS.map(({ slug }) => `.claude/skills/${slug}/SKILL.md ${slug.replace(/^kywi-/, '')} skill (loaded automatically)`).join('\n')}
|
|
2094
2209
|
middleware.ts auth gate + session refresh + cookie→bearer bridge
|
|
2095
2210
|
next.config.mjs required Next config to consume @kywi-software/core
|
|
2096
2211
|
lib/kywi.ts server runtime (DB, API handler, content scope)
|
|
@@ -2137,6 +2252,38 @@ function agentPatternsDoc() {
|
|
|
2137
2252
|
return _agentPatternsDoc
|
|
2138
2253
|
}
|
|
2139
2254
|
|
|
2255
|
+
/**
|
|
2256
|
+
* Version of the agent-guidance SURFACE (this AGENTS.md shape + the skill set),
|
|
2257
|
+
* independent of the package version. Bump when the guidance changes in a way a
|
|
2258
|
+
* retrofitted app should pick up (new skill, restructured patterns doc), so an
|
|
2259
|
+
* app can compare its stamp against a newer create-kywi-app and know it's stale.
|
|
2260
|
+
*/
|
|
2261
|
+
export const GUIDANCE_VERSION = 1
|
|
2262
|
+
|
|
2263
|
+
/**
|
|
2264
|
+
* This package's own version, read from its package.json (resolved relative to
|
|
2265
|
+
* THIS module, same mechanism as {@link agentPatternsDoc}). Cached.
|
|
2266
|
+
* @returns {string}
|
|
2267
|
+
*/
|
|
2268
|
+
let _pkgVersion
|
|
2269
|
+
function packageVersion() {
|
|
2270
|
+
if (_pkgVersion === undefined) {
|
|
2271
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json')
|
|
2272
|
+
_pkgVersion = JSON.parse(readFileSync(pkgPath, 'utf8')).version
|
|
2273
|
+
}
|
|
2274
|
+
return _pkgVersion
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
/**
|
|
2278
|
+
* The staleness stamp emitted as AGENTS.md's first line — an HTML comment, so it
|
|
2279
|
+
* is invisible when the file is rendered but greppable by tooling and by an agent
|
|
2280
|
+
* asking "is this project's guidance current?".
|
|
2281
|
+
* @returns {string}
|
|
2282
|
+
*/
|
|
2283
|
+
export function guidanceStamp() {
|
|
2284
|
+
return `<!-- kywi-agent-guidance v${GUIDANCE_VERSION} (create-kywi-app ${packageVersion()}) -->`
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2140
2287
|
/**
|
|
2141
2288
|
* Kywi's Claude Code project skills — each ships as a package asset and is
|
|
2142
2289
|
* embedded verbatim into every generated app's .claude/skills/<slug>/SKILL.md
|
|
@@ -2149,6 +2296,9 @@ export const SKILLS = [
|
|
|
2149
2296
|
{ slug: 'kywi-content-model', asset: 'kywi-content-model-skill.md' },
|
|
2150
2297
|
// When adding any collection: folder + feed + Feed Display, end to end.
|
|
2151
2298
|
{ slug: 'kywi-collections', asset: 'kywi-collections-skill.md' },
|
|
2299
|
+
// When the built-in module palette falls short: the defineModule recipe,
|
|
2300
|
+
// owner-editable props, and the SSR/JS-off hazards.
|
|
2301
|
+
{ slug: 'kywi-custom-modules', asset: 'kywi-custom-modules-skill.md' },
|
|
2152
2302
|
// When the owner wants personalization/A-B: confirm the use case, then build.
|
|
2153
2303
|
{ slug: 'kywi-personalization', asset: 'kywi-personalization-skill.md' },
|
|
2154
2304
|
]
|
|
@@ -2320,10 +2470,13 @@ export function agentsMd(a, landmarks = scaffoldLandmarks(a.mode)) {
|
|
|
2320
2470
|
skill, loaded automatically before building anything.`)
|
|
2321
2471
|
bullets.push(`- \`.claude/skills/kywi-collections/SKILL.md\` — collections skill, loaded
|
|
2322
2472
|
automatically when adding any collection.`)
|
|
2473
|
+
bullets.push(`- \`.claude/skills/kywi-custom-modules/SKILL.md\` — custom-module skill, loaded
|
|
2474
|
+
automatically when the built-in module palette falls short.`)
|
|
2323
2475
|
bullets.push(`- \`.claude/skills/kywi-personalization/SKILL.md\` — personalization skill,
|
|
2324
2476
|
loaded automatically when the owner wants personalization or A/B testing.`)
|
|
2325
2477
|
|
|
2326
|
-
const header =
|
|
2478
|
+
const header = `${guidanceStamp()}
|
|
2479
|
+
# Agent guide — ${a.projectName}
|
|
2327
2480
|
|
|
2328
2481
|
${intro}
|
|
2329
2482
|
${modeSentence}
|
|
@@ -2365,7 +2518,7 @@ holds Kywi's official building patterns and a map of where things live in this a
|
|
|
2365
2518
|
|
|
2366
2519
|
Core principle: **model content in the CMS instead of hardcoding it** — see \`AGENTS.md\`.
|
|
2367
2520
|
|
|
2368
|
-
Before building out a new content area, use the \`kywi-content-model\` skill (design the model, get sign-off) — skills in \`.claude/skills/\` also cover collections and personalization.
|
|
2521
|
+
Before building out a new content area, use the \`kywi-content-model\` skill (design the model, get sign-off) — skills in \`.claude/skills/\` also cover collections, custom modules, and personalization.
|
|
2369
2522
|
`
|
|
2370
2523
|
}
|
|
2371
2524
|
|
package/package.json
CHANGED