okno 1.0.0-alpha.2 → 1.0.0-alpha.21
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/dist/acorn-CZBJD705.js +3143 -0
- package/dist/boot.d.ts +88 -0
- package/dist/boot.d.ts.map +1 -0
- package/dist/boot.js +98 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +22 -0
- package/dist/codegen/generate.d.ts +5 -1
- package/dist/codegen/generate.d.ts.map +1 -1
- package/dist/codegen/scan-imports.d.ts +9 -0
- package/dist/codegen/scan-imports.d.ts.map +1 -0
- package/dist/content-encode-DoojdsVU.js +80 -0
- package/dist/core/content-encode.d.ts +44 -0
- package/dist/core/content-encode.d.ts.map +1 -0
- package/dist/core/order.d.ts +18 -0
- package/dist/core/order.d.ts.map +1 -0
- package/dist/core/parse.d.ts +15 -0
- package/dist/core/parse.d.ts.map +1 -0
- package/dist/editor/index.js +29645 -15293
- package/dist/index-B1PShnsc.js +132 -0
- package/dist/index-B2WzdTzI.js +14263 -0
- package/dist/index-ButdD9Pf.js +1825 -0
- package/dist/index-DKF0siIz.js +5497 -0
- package/dist/index-q-7MEoHz.js +47 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/init.d.ts +39 -0
- package/dist/init.d.ts.map +1 -0
- package/dist/init.js +221 -0
- package/dist/mcp.d.ts +4 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +140 -0
- package/dist/okno.css +1 -0
- package/dist/order-bAkdncZC.js +21 -0
- package/dist/plugin-Cy62173B.js +1080 -0
- package/dist/runtime/wrap.d.ts +48 -1
- package/dist/runtime/wrap.d.ts.map +1 -1
- package/dist/runtime/wrap.js +274 -0
- package/dist/types/fields.d.ts +54 -6
- package/dist/types/fields.d.ts.map +1 -1
- package/dist/types/index.d.ts +3 -2
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/permissions.d.ts +22 -0
- package/dist/types/permissions.d.ts.map +1 -0
- package/dist/types/schema.d.ts +41 -6
- package/dist/types/schema.d.ts.map +1 -1
- package/dist/vite/dev-server.d.ts +8 -0
- package/dist/vite/dev-server.d.ts.map +1 -1
- package/dist/vite/index.js +1 -1
- package/dist/vite/local-engine.d.ts +34 -0
- package/dist/vite/local-engine.d.ts.map +1 -0
- package/dist/vite/plugin.d.ts.map +1 -1
- package/package.json +42 -11
- package/skills/connect-okno/SKILL.md +367 -0
- package/src/runtime/wrap.test.ts +82 -0
- package/src/runtime/wrap.ts +684 -0
- package/dist/plugin-Cwwh6E1P.js +0 -440
package/package.json
CHANGED
|
@@ -2,51 +2,82 @@
|
|
|
2
2
|
"name": "okno",
|
|
3
3
|
"description": "Git-backed CMS with live editing for any Vite framework",
|
|
4
4
|
"author": "Gabriel Uhlíř",
|
|
5
|
-
"version": "1.0.0-alpha.
|
|
5
|
+
"version": "1.0.0-alpha.21",
|
|
6
6
|
"homepage": "https://okno.build",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"types": "./dist/index.d.ts",
|
|
9
9
|
"main": "./dist/index.js",
|
|
10
10
|
"module": "./dist/index.js",
|
|
11
|
+
"bin": {
|
|
12
|
+
"okno": "./dist/cli.js"
|
|
13
|
+
},
|
|
11
14
|
"exports": {
|
|
12
15
|
".": {
|
|
13
|
-
"
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
14
17
|
"default": "./dist/index.js"
|
|
15
18
|
},
|
|
16
19
|
"./vite": {
|
|
17
|
-
"
|
|
20
|
+
"types": "./dist/vite/index.d.ts",
|
|
18
21
|
"default": "./dist/vite/index.js"
|
|
19
22
|
},
|
|
20
23
|
"./editor": {
|
|
21
|
-
"
|
|
24
|
+
"types": "./dist/editor/index.d.ts",
|
|
22
25
|
"default": "./dist/editor/index.js"
|
|
26
|
+
},
|
|
27
|
+
"./boot": {
|
|
28
|
+
"types": "./dist/boot.d.ts",
|
|
29
|
+
"default": "./dist/boot.js"
|
|
23
30
|
}
|
|
24
31
|
},
|
|
25
|
-
"files": [
|
|
32
|
+
"files": [
|
|
33
|
+
"dist",
|
|
34
|
+
"src/runtime",
|
|
35
|
+
"skills"
|
|
36
|
+
],
|
|
26
37
|
"scripts": {
|
|
27
38
|
"dev": "vite build --watch",
|
|
28
|
-
"
|
|
39
|
+
"dev:watch": "vite build --watch --no-emptyOutDir",
|
|
40
|
+
"dev:types": "tsc -w --emitDeclarationOnly --preserveWatchOutput",
|
|
41
|
+
"build:js": "vite build",
|
|
42
|
+
"build": "vite build && tsc",
|
|
43
|
+
"check": "tsc --noEmit",
|
|
44
|
+
"pub": "bun run publish.js"
|
|
29
45
|
},
|
|
30
46
|
"dependencies": {
|
|
47
|
+
"@clack/prompts": "^0.7.0",
|
|
48
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
49
|
+
"zod": "^3.23.8",
|
|
50
|
+
"@internationalized/date": "^3.12.2",
|
|
51
|
+
"@tiptap/core": "^3.27.1",
|
|
52
|
+
"@tiptap/extension-image": "3.27.1",
|
|
53
|
+
"@tiptap/extension-table": "3.27.1",
|
|
54
|
+
"@tiptap/extension-text-align": "^3",
|
|
55
|
+
"@tiptap/pm": "^3.27.1",
|
|
56
|
+
"@tiptap/starter-kit": "^3.27.1",
|
|
57
|
+
"acorn": "^8.11.3",
|
|
31
58
|
"bits-ui": "^2.18.0",
|
|
32
59
|
"runed": "^0.23.0",
|
|
33
|
-
"simple-git": "^3.
|
|
34
|
-
"svelte": "^5.
|
|
60
|
+
"simple-git": "^3.32.0",
|
|
61
|
+
"svelte": "^5.55.0",
|
|
35
62
|
"svelte-dnd-action": "^0.9.69"
|
|
36
63
|
},
|
|
37
64
|
"devDependencies": {
|
|
38
|
-
"@
|
|
65
|
+
"@hugeicons-pro/core-bulk-rounded": "*",
|
|
66
|
+
"@hugeicons-pro/core-solid-rounded": "*",
|
|
67
|
+
"@hugeicons-pro/core-stroke-rounded": "*",
|
|
68
|
+
"@hugeicons-pro/core-stroke-standard": "*",
|
|
69
|
+
"@hugeicons/svelte": "*",
|
|
39
70
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
|
40
71
|
"@tailwindcss/vite": "^4.0.0",
|
|
41
72
|
"@types/node": "^20.19.13",
|
|
42
73
|
"tailwindcss": "^4.0.0",
|
|
43
74
|
"tailwindcss-animate": "^1.0.7",
|
|
44
75
|
"typescript": "^5.9.2",
|
|
45
|
-
"vite": "6.
|
|
76
|
+
"vite": "^6.4.3"
|
|
46
77
|
},
|
|
47
78
|
"peerDependencies": {
|
|
48
79
|
"svelte": "^5.0.0",
|
|
49
|
-
"vite": "^5.0.0 || ^6.0.0"
|
|
80
|
+
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
|
|
50
81
|
},
|
|
51
82
|
"keywords": [
|
|
52
83
|
"cms",
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: connect-okno
|
|
3
|
+
description: >
|
|
4
|
+
Model and build content with okno, the Git-backed CMS. Load this whenever you
|
|
5
|
+
are designing an okno schema, deciding how to structure content (pages,
|
|
6
|
+
collections, globals, blocks, references), adding entries through the okno MCP,
|
|
7
|
+
or wiring content into templates with the spread-to-edit pattern. Use it when a
|
|
8
|
+
user asks to "build/add/connect content with okno", set up a blog/articles/
|
|
9
|
+
authors/categories, define a content model, or render okno content in Astro/
|
|
10
|
+
SvelteKit/Nuxt/Vite. Teaches the mental model and when to use which primitive —
|
|
11
|
+
not just recipes.
|
|
12
|
+
metadata:
|
|
13
|
+
author: okno
|
|
14
|
+
version: "1.0.0"
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
# Connect okno
|
|
18
|
+
|
|
19
|
+
okno is a **Git-backed CMS with live, in-page editing**. Content is plain `.ts`
|
|
20
|
+
files in the project's repo. There is no database. The editor injects onto the
|
|
21
|
+
real, deployed page — you spread a field onto the element that renders it, and
|
|
22
|
+
that element becomes editable in place.
|
|
23
|
+
|
|
24
|
+
Your job with this skill is to **model content well** and **wire it into
|
|
25
|
+
templates** so every value is editable. Modeling is the part that compounds: a
|
|
26
|
+
good model makes the editor obvious and the templates trivial; a bad one fights
|
|
27
|
+
you forever. Spend your judgment there.
|
|
28
|
+
|
|
29
|
+
## The golden rules
|
|
30
|
+
|
|
31
|
+
1. **You cannot publish.** Every write (`write_entry`, `write_schema`) lands as a
|
|
32
|
+
*draft* on the project's working branch. A human reviews and publishes
|
|
33
|
+
(a merge). Never imply you deployed content live.
|
|
34
|
+
2. **`get_schema` first, always.** `write_schema` replaces the entire schema.
|
|
35
|
+
Read it, merge your change into the whole object, write it all back. Never
|
|
36
|
+
send a partial schema — you will delete everything you omitted.
|
|
37
|
+
3. **Model before you fill.** Decide pages/collections/globals/blocks first,
|
|
38
|
+
write the schema, *then* add entries. Entries must match the schema.
|
|
39
|
+
|
|
40
|
+
## The workflow
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
get_schema → learn the current content model
|
|
44
|
+
(decide the model) → use the decision frameworks below
|
|
45
|
+
write_schema(full object) → create/extend pages, collections, globals, blocks
|
|
46
|
+
write_entry(path, fields) → add the actual content, one entry per call
|
|
47
|
+
(wire templates) → spread fields onto elements (see "Rendering")
|
|
48
|
+
(human publishes) → not you
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Entry paths are relative to the `okno/` dir:
|
|
52
|
+
`pages/home.ts`, `collections/articles/<slug>.ts`, `globals/navbar.ts`.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## The four primitives
|
|
57
|
+
|
|
58
|
+
| Primitive | What it is | One of, or many? | Example |
|
|
59
|
+
|-----------|-----------|------------------|---------|
|
|
60
|
+
| **page** | A singleton with a fixed route | exactly one | Home, About, Pricing |
|
|
61
|
+
| **collection** | A repeatable type; every entry shares a shape and has a slug | many | Articles, Authors, Products |
|
|
62
|
+
| **global** | A singleton value read across the *whole* site | exactly one, everywhere | Navbar, Footer, SEO/Meta |
|
|
63
|
+
| **block** | A reusable *field shape* embedded into a page/collection | as many placements as you drop | Hero, Card, FAQ item, Nav link |
|
|
64
|
+
|
|
65
|
+
Everything else (`reference`, `group`, the field types) is how you connect and
|
|
66
|
+
shape these four.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Decision frameworks
|
|
71
|
+
|
|
72
|
+
These are the questions that actually come up. Answer them in order.
|
|
73
|
+
|
|
74
|
+
### 1. Page or collection?
|
|
75
|
+
|
|
76
|
+
> **Is this one specific thing, or many things that share a shape?**
|
|
77
|
+
|
|
78
|
+
- **One specific thing** → a **page**. There is exactly one Home, one About. It
|
|
79
|
+
has its own route. Its fields are flat (no slug needed).
|
|
80
|
+
- **Many things, same shape** → a **collection**. Articles, team members,
|
|
81
|
+
products. Each entry gets a `slug` field and the collection gets a `url`
|
|
82
|
+
template (`/blog/[slug]`). You list them and link to them.
|
|
83
|
+
|
|
84
|
+
If you find yourself making `about`, `about-2`, `about-team`… stop — that's a
|
|
85
|
+
collection.
|
|
86
|
+
|
|
87
|
+
### 2. Collection + reference, or a block? (the important one)
|
|
88
|
+
|
|
89
|
+
Both let you have "several of something". The difference is **identity**.
|
|
90
|
+
|
|
91
|
+
> **Does this thing have its own identity — a life outside the page it appears on?**
|
|
92
|
+
|
|
93
|
+
- **Yes → collection + reference.** It's an *entity*: it has its own URL, you
|
|
94
|
+
list it, you link to it, the *same instance* is reused in several places, and
|
|
95
|
+
editing it once updates it everywhere. Authors, categories, products, articles.
|
|
96
|
+
This is normalized data — one source of truth, pointed at by `reference`.
|
|
97
|
+
|
|
98
|
+
*Author is a collection. An article references its author. Change the author's
|
|
99
|
+
bio once → every article reflects it.*
|
|
100
|
+
|
|
101
|
+
- **No → block.** It's *composition inside one entry*: page-building sections,
|
|
102
|
+
cards, a hero, FAQ rows. It exists only as part of its parent, it is not reused
|
|
103
|
+
elsewhere as the same instance, and it has no standalone URL. Each placement
|
|
104
|
+
**owns its own values** — copy, not link. This is denormalized, local content.
|
|
105
|
+
|
|
106
|
+
*A landing page's feature cards are a block (`multiple`). They only matter on
|
|
107
|
+
that page; nobody links to "card #2".*
|
|
108
|
+
|
|
109
|
+
Rule of thumb — ask **"Would I ever link to it, list it on its own, or reuse the
|
|
110
|
+
exact same instance in two places?"**
|
|
111
|
+
Yes → collection+reference. No → block.
|
|
112
|
+
|
|
113
|
+
Tie-breaker: if two pages must show the *same, synced* thing, it's a collection.
|
|
114
|
+
If each page wants its *own copy* to edit freely, it's a block.
|
|
115
|
+
|
|
116
|
+
### 3. Global or page?
|
|
117
|
+
|
|
118
|
+
> **Does the same value appear on every page?**
|
|
119
|
+
|
|
120
|
+
- **Same everywhere → global.** Navbar, footer, social links, SEO/Meta defaults,
|
|
121
|
+
brand. Edit once, the whole site updates. Globals are imported anywhere.
|
|
122
|
+
- **Specific to one route → page.** Home's hero, the About story.
|
|
123
|
+
|
|
124
|
+
### 4. Group or block?
|
|
125
|
+
|
|
126
|
+
Both nest fields. The difference is **repetition**.
|
|
127
|
+
|
|
128
|
+
- **`group`** — a *fixed, single* cluster of related fields, just for organizing:
|
|
129
|
+
an address (`street`, `city`, `zip`), a CTA (`label`, `url`). One of them, not
|
|
130
|
+
a list. Use it to keep related fields together.
|
|
131
|
+
- **`block` (`multiple: true`)** — a *repeatable* list of composed instances:
|
|
132
|
+
sections, cards, rows. Use it whenever the editor needs "add another".
|
|
133
|
+
|
|
134
|
+
(Need only an editor-side section heading, not nesting? Use a **`divider`** —
|
|
135
|
+
it's purely presentational; fields after it stay flat.)
|
|
136
|
+
|
|
137
|
+
### 5. Which field type for a value?
|
|
138
|
+
|
|
139
|
+
| Need | Type |
|
|
140
|
+
|------|------|
|
|
141
|
+
| Short text (title, label, name) | `string` |
|
|
142
|
+
| Long prose with formatting, links, images inline | `richtext` |
|
|
143
|
+
| A picture / video / file | `media` (`accept`, `multiple?`) |
|
|
144
|
+
| A URL with link text + external handling | `link` |
|
|
145
|
+
| A point to another collection entry | `reference` (`collection`, `multiple?`) |
|
|
146
|
+
| A fixed set of choices | `enum` (`options`) |
|
|
147
|
+
| On/off | `boolean` |
|
|
148
|
+
| A number / a date | `number` / `date` |
|
|
149
|
+
| A CSS color (theming) | `color` |
|
|
150
|
+
| URL-safe id derived from another field | `slug` (`from`) — **required on collections** |
|
|
151
|
+
| A repeatable composed section | `block` (`multiple`) |
|
|
152
|
+
| A fixed cluster of related fields | `group` |
|
|
153
|
+
| Editor-only section heading | `divider` |
|
|
154
|
+
|
|
155
|
+
Prefer **`richtext`** for body copy (it carries its own formatting) and
|
|
156
|
+
**`string`** for anything you'll style yourself or compare/sort on.
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## Conventions (what the Starter scaffolds)
|
|
161
|
+
|
|
162
|
+
`okno init` scaffolds a **Starter**: `Meta`, `Navbar`, `Footer` globals and a
|
|
163
|
+
`Home` page, ready to edit. You don't need to recreate them — but understand
|
|
164
|
+
*why* they're globals (rule 3: same on every page), so you place new things
|
|
165
|
+
correctly:
|
|
166
|
+
|
|
167
|
+
- **`Meta`** (global) — `title`, `description`, `shareImage` (media), `favicon`
|
|
168
|
+
(media). Site-wide SEO/OG defaults, rendered into `<head>`.
|
|
169
|
+
- **`Navbar`** (global) — `logo`, `links` (block `multiple`), `cta` (link).
|
|
170
|
+
- **`Footer`** (global) — `text`, `links` (block `multiple`).
|
|
171
|
+
|
|
172
|
+
A page can still *override* a Meta value with its own field; the global is the
|
|
173
|
+
default. Per-page SEO = a page field that falls back to `Meta`.
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## Rendering: the spread-to-edit pattern
|
|
178
|
+
|
|
179
|
+
Import content from the virtual modules, then **spread the field onto the element
|
|
180
|
+
that renders it**. The spread adds the `data-okno` boundary the editor needs; the
|
|
181
|
+
child renders the value. Same value, two jobs, zero duplication.
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
import { home } from "okno:pages"
|
|
185
|
+
import { articles } from "okno:collections"
|
|
186
|
+
import { navbar, meta } from "okno:globals"
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
By value kind:
|
|
190
|
+
|
|
191
|
+
```astro
|
|
192
|
+
<!-- string / number: spread + render -->
|
|
193
|
+
<h1 {...home.title}>{home.title}</h1>
|
|
194
|
+
|
|
195
|
+
<!-- richtext: spread + set:html (it's an HTML string) -->
|
|
196
|
+
<article {...home.body} set:html={home.body}></article>
|
|
197
|
+
|
|
198
|
+
<!-- media: spread maps src/alt/dimensions onto the element -->
|
|
199
|
+
<img {...home.cover} alt="" />
|
|
200
|
+
|
|
201
|
+
<!-- link: spread maps url→href (+target/rel if external); child is the text -->
|
|
202
|
+
<a {...home.cta}>{home.cta}</a>
|
|
203
|
+
|
|
204
|
+
<!-- block array: {...item.slot} marks array membership (add/remove/reorder),
|
|
205
|
+
field spreads stay bare -->
|
|
206
|
+
{home.cards.map((card) => (
|
|
207
|
+
<article {...card.slot}>
|
|
208
|
+
<h3 {...card.title}>{card.title}</h3>
|
|
209
|
+
<p {...card.subtitle}>{card.subtitle}</p>
|
|
210
|
+
</article>
|
|
211
|
+
))}
|
|
212
|
+
|
|
213
|
+
<!-- globals render the same way, anywhere -->
|
|
214
|
+
<nav>{navbar.links.map((l) => <a {...l.link}>{l.link}</a>)}</nav>
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
> **The `.slot` rule.** Any time you iterate an okno array — a `multiple` block
|
|
218
|
+
> field OR a `multiple` reference — spread `{...item.slot}` onto the element that
|
|
219
|
+
> wraps each item. It marks the array-membership boundary (`data-okno-item`) the
|
|
220
|
+
> editor needs to add, remove, and reorder items live. Field spreads inside stay
|
|
221
|
+
> bare; `.slot` goes on the wrapper. Forget it and the content still renders, but
|
|
222
|
+
> live structural edits silently break. (Scalar fields never take `.slot` — it
|
|
223
|
+
> only exists on array items.)
|
|
224
|
+
|
|
225
|
+
### References resolve to full items
|
|
226
|
+
|
|
227
|
+
The build plugin replaces a stored reference id with the **full referenced
|
|
228
|
+
item**, and its `data-okno` points at *that item*. So editing through a reference
|
|
229
|
+
edits the entity, not the page:
|
|
230
|
+
|
|
231
|
+
```astro
|
|
232
|
+
<!-- single reference: home.author is the whole author entry -->
|
|
233
|
+
<span {...home.author.name}>{home.author.name}</span>
|
|
234
|
+
|
|
235
|
+
<!-- multiple reference: each is a full article; .slot marks membership -->
|
|
236
|
+
{home.featured.map((a) => (
|
|
237
|
+
<a href={`/blog/${a.slug}`} {...a.slot}>
|
|
238
|
+
<img {...a.cover} alt="" />
|
|
239
|
+
<h3 {...a.title}>{a.title}</h3>
|
|
240
|
+
</a>
|
|
241
|
+
))}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Resolution is **one level deep** (cycle-safe): an article resolves its `author`,
|
|
245
|
+
but that author's *own* references are not re-resolved. Don't rely on
|
|
246
|
+
`article.author.someOtherReference` being an object.
|
|
247
|
+
|
|
248
|
+
### Live edits (optional)
|
|
249
|
+
|
|
250
|
+
Wrapped values expose `.on()` — subscribe to patch the DOM without a reload:
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
home.title.on((v) => { document.querySelector("h1")!.textContent = String(v) })
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
### Production editor
|
|
257
|
+
|
|
258
|
+
The editor auto-injects in dev. For the deployed build to ship it, the layout
|
|
259
|
+
must import the boot module once:
|
|
260
|
+
|
|
261
|
+
```astro
|
|
262
|
+
<script>import "okno/boot"</script>
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
### Deploying
|
|
266
|
+
|
|
267
|
+
okno produces static output — host it anywhere. On a git-connected host
|
|
268
|
+
(Cloudflare Pages, Netlify, Vercel) build command is `bun run build` (or
|
|
269
|
+
`npm run build`), output dir `dist`. Two rules specific to okno:
|
|
270
|
+
|
|
271
|
+
- **Build only the production branch** (`main`). okno publishes by merging into
|
|
272
|
+
it, so every publish auto-rebuilds your live site — that pairing is the point.
|
|
273
|
+
- **Disable builds/previews for the `okno` branch.** It's the drafts branch okno
|
|
274
|
+
commits to on *save* (the only branch okno writes to). Building it deploys
|
|
275
|
+
nothing useful, and because its infra can lag the production branch it will
|
|
276
|
+
often *fail* the build. On Cloudflare: Settings → Builds → Branch control →
|
|
277
|
+
build the production branch only (no preview deployments).
|
|
278
|
+
|
|
279
|
+
---
|
|
280
|
+
|
|
281
|
+
## Routing — the `url` field
|
|
282
|
+
|
|
283
|
+
Every page and collection declares where it lives on the live site, via a `url`
|
|
284
|
+
in the schema. This is the bridge that lets the editor map a browser URL back to
|
|
285
|
+
the entry behind it (so editing works on the deployed page), and it drives the
|
|
286
|
+
"open on site" links. **Set it to match your framework's actual route** — okno
|
|
287
|
+
stores the mapping; your framework still owns the file that renders it.
|
|
288
|
+
|
|
289
|
+
- **Pages — a literal path.** `home` → `url: "/"`, `about` → `url: "/about"`.
|
|
290
|
+
- **Collections — a pattern with `[token]` segments.** Each `[token]` pulls a
|
|
291
|
+
slug from the item. `url: "/blog/[slug]"` uses the item's own slug;
|
|
292
|
+
`url: "/[category]/[slug]"` pulls `[category]` from a referenced item's slug
|
|
293
|
+
and `[slug]` from the item — so a collection can route under its category.
|
|
294
|
+
|
|
295
|
+
```ts
|
|
296
|
+
// schema.ts
|
|
297
|
+
articles: {
|
|
298
|
+
name: "Articles",
|
|
299
|
+
fields: { /* …, */ slug: { type: "slug", from: "title" } },
|
|
300
|
+
url: "/blog/[slug]", // ← matches src/pages/blog/[slug].astro
|
|
301
|
+
}
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
The framework side stays ordinary. In Astro you still write the dynamic route
|
|
305
|
+
and enumerate items in `getStaticPaths`, keyed by the same slug the `url`
|
|
306
|
+
pattern uses:
|
|
307
|
+
|
|
308
|
+
```astro
|
|
309
|
+
---
|
|
310
|
+
// src/pages/blog/[slug].astro
|
|
311
|
+
import { articles } from "okno:collections"
|
|
312
|
+
export const getStaticPaths = () =>
|
|
313
|
+
articles.map((a) => ({ params: { slug: String(a.slug) }, props: { article: a } }))
|
|
314
|
+
const { article } = Astro.props
|
|
315
|
+
---
|
|
316
|
+
<h1 {...article.title}>{article.title}</h1>
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
Rule of thumb: **the schema `url` pattern and the framework route file must agree**
|
|
320
|
+
(`/blog/[slug]` ↔ `blog/[slug].astro`). The `slug` field is required on every
|
|
321
|
+
collection precisely because it's what fills the `[token]`.
|
|
322
|
+
|
|
323
|
+
---
|
|
324
|
+
|
|
325
|
+
## Authoring entries (MCP)
|
|
326
|
+
|
|
327
|
+
- One `write_entry` per entry. `content` is the fields object, matching the
|
|
328
|
+
schema. `created`/`updated` stamps are managed for you — don't set them.
|
|
329
|
+
- **Collections need a slug.** The entry's filename is the slug:
|
|
330
|
+
`collections/articles/why-git.ts` → slug `why-git`.
|
|
331
|
+
- **References store ids (slugs), not objects.** `category: "engineering"`,
|
|
332
|
+
`featured: ["why-git", "spread-to-edit"]`. The build resolves them.
|
|
333
|
+
- **Media is `{ src, alt, width?, height? }`.** Point `src` at a file under
|
|
334
|
+
`okno/files/...` or an absolute URL.
|
|
335
|
+
- **Localized fields** carry per-locale values; default locale first. Only fields
|
|
336
|
+
marked `localized` vary by locale.
|
|
337
|
+
|
|
338
|
+
## A worked model — a blog
|
|
339
|
+
|
|
340
|
+
> "Articles, with authors and categories, featured on the homepage."
|
|
341
|
+
|
|
342
|
+
- **Authors** → collection (entities, listed, reused) — `name`, `slug`, `role`,
|
|
343
|
+
`bio`, `avatar`.
|
|
344
|
+
- **Categories** → collection (entities, you filter/link by them) — `title`,
|
|
345
|
+
`slug`, `description`.
|
|
346
|
+
- **Articles** → collection — `cover`, `title`, `slug`, `excerpt`, `content`
|
|
347
|
+
(richtext), `category` (reference → category), `author` (reference → author).
|
|
348
|
+
- **Home** → page — `headline`, `description`, `image`, `featured` (reference →
|
|
349
|
+
articles, `multiple`).
|
|
350
|
+
- **Navbar / Footer / Meta** → globals (from the Starter).
|
|
351
|
+
|
|
352
|
+
Why no blocks here? Nothing is page-local composition — authors and categories
|
|
353
|
+
have identity and are reused, so they're collections joined by references. Add a
|
|
354
|
+
block only when you build a *page* out of arbitrary stacked sections.
|
|
355
|
+
|
|
356
|
+
---
|
|
357
|
+
|
|
358
|
+
## Checklist
|
|
359
|
+
|
|
360
|
+
- [ ] `get_schema` read before any `write_schema`
|
|
361
|
+
- [ ] Every collection has a `slug` field (`from` a title/name)
|
|
362
|
+
- [ ] Entities reused/linked → collections+references; page-local composition → blocks
|
|
363
|
+
- [ ] Site-wide values → globals (Meta/Navbar/Footer)
|
|
364
|
+
- [ ] Each page/collection has a `url`; collection patterns (`/blog/[slug]`) match the framework route file
|
|
365
|
+
- [ ] Templates spread every field; richtext via `set:html`; `.slot` on every iterated array item
|
|
366
|
+
- [ ] Layout imports `okno/boot` for the prod editor
|
|
367
|
+
- [ ] Told the user that publishing is their step, not yours
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { __oknoResolveRefs } from "./wrap"
|
|
3
|
+
|
|
4
|
+
describe("__oknoResolveRefs", () => {
|
|
5
|
+
test("single ref happy path", () => {
|
|
6
|
+
const raw = { author: "jane" }
|
|
7
|
+
const fields = { author: { type: "reference", collection: "authors" } }
|
|
8
|
+
const collections = { authors: [{ slug: "jane", name: "Jane" }] }
|
|
9
|
+
const result = __oknoResolveRefs(raw, fields, collections) as any
|
|
10
|
+
expect(result.author).toEqual({
|
|
11
|
+
__oknoRef: { path: "authors.jane", data: { slug: "jane", name: "Jane" } },
|
|
12
|
+
})
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test("array refs with a missing id filtered out", () => {
|
|
16
|
+
const raw = { authors: ["jane", "unknown"] }
|
|
17
|
+
const fields = { authors: { type: "reference", collection: "authors" } }
|
|
18
|
+
const collections = { authors: [{ slug: "jane", name: "Jane" }] }
|
|
19
|
+
const result = __oknoResolveRefs(raw, fields, collections) as any
|
|
20
|
+
expect(result.authors).toHaveLength(1)
|
|
21
|
+
expect(result.authors[0].__oknoRef.path).toBe("authors.jane")
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test("item with toJSON method — data is toJSON output, match by toJSON slug", () => {
|
|
25
|
+
const item = {
|
|
26
|
+
_raw: { slug: "bob", name: "Bob" },
|
|
27
|
+
toJSON() {
|
|
28
|
+
return this._raw
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
const raw = { author: "bob" }
|
|
32
|
+
const fields = { author: { type: "reference", collection: "people" } }
|
|
33
|
+
const collections = { people: [item] }
|
|
34
|
+
const result = __oknoResolveRefs(raw, fields, collections) as any
|
|
35
|
+
expect(result.author.__oknoRef.data).toEqual({ slug: "bob", name: "Bob" })
|
|
36
|
+
expect(result.author.__oknoRef.path).toBe("people.bob")
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test("dash/underscore fallback — def.collection dashed, collections key underscored", () => {
|
|
40
|
+
const raw = { post: "hello" }
|
|
41
|
+
const fields = { post: { type: "reference", collection: "blog-posts" } }
|
|
42
|
+
const collections = { blog_posts: [{ slug: "hello", title: "Hello World" }] }
|
|
43
|
+
const result = __oknoResolveRefs(raw, fields, collections) as any
|
|
44
|
+
expect(result.post.__oknoRef.path).toBe("blog-posts.hello")
|
|
45
|
+
expect(result.post.__oknoRef.data).toEqual({ slug: "hello", title: "Hello World" })
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test("non-reference field passes through untouched", () => {
|
|
49
|
+
const raw = { title: "Hello", author: "jane" }
|
|
50
|
+
const fields = {
|
|
51
|
+
title: { type: "text" },
|
|
52
|
+
author: { type: "reference", collection: "authors" },
|
|
53
|
+
}
|
|
54
|
+
const collections = { authors: [{ slug: "jane", name: "Jane" }] }
|
|
55
|
+
const result = __oknoResolveRefs(raw, fields, collections) as any
|
|
56
|
+
expect(result.title).toBe("Hello")
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test("null id returns null (single ref)", () => {
|
|
60
|
+
const raw = { author: null }
|
|
61
|
+
const fields = { author: { type: "reference", collection: "authors" } }
|
|
62
|
+
const collections = { authors: [{ slug: "jane", name: "Jane" }] }
|
|
63
|
+
const result = __oknoResolveRefs(raw, fields, collections) as any
|
|
64
|
+
expect(result.author).toBeNull()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test("empty string id returns null", () => {
|
|
68
|
+
const raw = { author: "" }
|
|
69
|
+
const fields = { author: { type: "reference", collection: "authors" } }
|
|
70
|
+
const collections = { authors: [{ slug: "jane", name: "Jane" }] }
|
|
71
|
+
const result = __oknoResolveRefs(raw, fields, collections) as any
|
|
72
|
+
expect(result.author).toBeNull()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test("unknown id returns null (single ref)", () => {
|
|
76
|
+
const raw = { author: "nobody" }
|
|
77
|
+
const fields = { author: { type: "reference", collection: "authors" } }
|
|
78
|
+
const collections = { authors: [{ slug: "jane", name: "Jane" }] }
|
|
79
|
+
const result = __oknoResolveRefs(raw, fields, collections) as any
|
|
80
|
+
expect(result.author).toBeNull()
|
|
81
|
+
})
|
|
82
|
+
})
|