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.
Files changed (58) hide show
  1. package/dist/acorn-CZBJD705.js +3143 -0
  2. package/dist/boot.d.ts +88 -0
  3. package/dist/boot.d.ts.map +1 -0
  4. package/dist/boot.js +98 -0
  5. package/dist/cli.d.ts +3 -0
  6. package/dist/cli.d.ts.map +1 -0
  7. package/dist/cli.js +22 -0
  8. package/dist/codegen/generate.d.ts +5 -1
  9. package/dist/codegen/generate.d.ts.map +1 -1
  10. package/dist/codegen/scan-imports.d.ts +9 -0
  11. package/dist/codegen/scan-imports.d.ts.map +1 -0
  12. package/dist/content-encode-DoojdsVU.js +80 -0
  13. package/dist/core/content-encode.d.ts +44 -0
  14. package/dist/core/content-encode.d.ts.map +1 -0
  15. package/dist/core/order.d.ts +18 -0
  16. package/dist/core/order.d.ts.map +1 -0
  17. package/dist/core/parse.d.ts +15 -0
  18. package/dist/core/parse.d.ts.map +1 -0
  19. package/dist/editor/index.js +29645 -15293
  20. package/dist/index-B1PShnsc.js +132 -0
  21. package/dist/index-B2WzdTzI.js +14263 -0
  22. package/dist/index-ButdD9Pf.js +1825 -0
  23. package/dist/index-DKF0siIz.js +5497 -0
  24. package/dist/index-q-7MEoHz.js +47 -0
  25. package/dist/index.d.ts +1 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +1 -1
  28. package/dist/init.d.ts +39 -0
  29. package/dist/init.d.ts.map +1 -0
  30. package/dist/init.js +221 -0
  31. package/dist/mcp.d.ts +4 -0
  32. package/dist/mcp.d.ts.map +1 -0
  33. package/dist/mcp.js +140 -0
  34. package/dist/okno.css +1 -0
  35. package/dist/order-bAkdncZC.js +21 -0
  36. package/dist/plugin-Cy62173B.js +1080 -0
  37. package/dist/runtime/wrap.d.ts +48 -1
  38. package/dist/runtime/wrap.d.ts.map +1 -1
  39. package/dist/runtime/wrap.js +274 -0
  40. package/dist/types/fields.d.ts +54 -6
  41. package/dist/types/fields.d.ts.map +1 -1
  42. package/dist/types/index.d.ts +3 -2
  43. package/dist/types/index.d.ts.map +1 -1
  44. package/dist/types/permissions.d.ts +22 -0
  45. package/dist/types/permissions.d.ts.map +1 -0
  46. package/dist/types/schema.d.ts +41 -6
  47. package/dist/types/schema.d.ts.map +1 -1
  48. package/dist/vite/dev-server.d.ts +8 -0
  49. package/dist/vite/dev-server.d.ts.map +1 -1
  50. package/dist/vite/index.js +1 -1
  51. package/dist/vite/local-engine.d.ts +34 -0
  52. package/dist/vite/local-engine.d.ts.map +1 -0
  53. package/dist/vite/plugin.d.ts.map +1 -1
  54. package/package.json +42 -11
  55. package/skills/connect-okno/SKILL.md +367 -0
  56. package/src/runtime/wrap.test.ts +82 -0
  57. package/src/runtime/wrap.ts +684 -0
  58. 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.2",
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
- "import": "./src/index.ts",
16
+ "types": "./dist/index.d.ts",
14
17
  "default": "./dist/index.js"
15
18
  },
16
19
  "./vite": {
17
- "import": "./src/vite/index.ts",
20
+ "types": "./dist/vite/index.d.ts",
18
21
  "default": "./dist/vite/index.js"
19
22
  },
20
23
  "./editor": {
21
- "import": "./editor/index.ts",
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": ["dist"],
32
+ "files": [
33
+ "dist",
34
+ "src/runtime",
35
+ "skills"
36
+ ],
26
37
  "scripts": {
27
38
  "dev": "vite build --watch",
28
- "build": "vite build && tsc || true"
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.28.0",
34
- "svelte": "^5.0.0",
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
- "@lucide/svelte": "*",
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.x"
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
+ })