mikser-io-schemas 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +271 -0
- package/index.js +579 -0
- package/package.json +36 -0
- package/src/typegen.js +102 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Almero Digital Marketing
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# mikser-io-schemas
|
|
2
|
+
|
|
3
|
+
Zod-backed entity validation and TypeScript type generation for [mikser-io](https://github.com/almero-digital-marketing/mikser-io).
|
|
4
|
+
|
|
5
|
+
File-based content is mikser's superpower — but file-based content has no built-in schema. You discover the shape of an entity by reading its `meta` block, and drift between layouts ("did this layout want `publishedAt` or `published`?") accumulates silently until something breaks at render time. This plugin closes that gap *without giving up the file-based model*: schemas are plain `.js` modules next to your config, validation runs as part of the build, and the SDK's `.d.ts` gets regenerated so the frontend gets typed entity access.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install --save-dev mikser-io-schemas zod
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
`mikser-io ^8.2.0` is a peer dependency.
|
|
14
|
+
|
|
15
|
+
## Quick start
|
|
16
|
+
|
|
17
|
+
Add the plugin to your config and pick a folder for schemas (default `schemas/`):
|
|
18
|
+
|
|
19
|
+
```js
|
|
20
|
+
// mikser.config.js
|
|
21
|
+
export default {
|
|
22
|
+
plugins: ['documents', 'layouts', 'plugin-schemas'],
|
|
23
|
+
|
|
24
|
+
schemas: {
|
|
25
|
+
// schemasFolder: 'schemas', // default
|
|
26
|
+
// typesFile: 'entities.d.ts', // default — emitted at the project root
|
|
27
|
+
// onError: 'warn', // 'warn' (default) | 'fail' | 'off' (schema-shape only)
|
|
28
|
+
schemaKey: 'meta.layout', // REQUIRED for schema-shape validation. No
|
|
29
|
+
// default. Dotted front-matter path that
|
|
30
|
+
// names the schema to match.
|
|
31
|
+
// SSG projects typically pass 'meta.layout';
|
|
32
|
+
// SPA projects (no rendered HTML) pass
|
|
33
|
+
// 'meta.component' since their docs have no
|
|
34
|
+
// layout. When unset, schema-shape validation
|
|
35
|
+
// is off and every loaded schema triggers a
|
|
36
|
+
// finalize warning so the off state is loud.
|
|
37
|
+
//
|
|
38
|
+
// Ref validation (per ADR-0007 A6) runs
|
|
39
|
+
// regardless of schemaKey — every $-keyed
|
|
40
|
+
// field is auto-validated for existence,
|
|
41
|
+
// shape, and collision, as warnings.
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Drop a schema file in `schemas/`:
|
|
47
|
+
|
|
48
|
+
```js
|
|
49
|
+
// schemas/article.js
|
|
50
|
+
import { z } from 'zod'
|
|
51
|
+
|
|
52
|
+
export default z.object({
|
|
53
|
+
title: z.string().min(1),
|
|
54
|
+
layout: z.literal('article'),
|
|
55
|
+
publishedAt: z.string().datetime(),
|
|
56
|
+
author: z.string(),
|
|
57
|
+
tags: z.array(z.string()).default([]),
|
|
58
|
+
hero: z.string().url().optional(),
|
|
59
|
+
summary: z.string().max(280).optional(),
|
|
60
|
+
})
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Each entity whose `meta[schemaKey] === 'article'` is now validated. The match is by filename stem — `schemas/article.js` matches docs declaring `article`, `schemas/landing-page.js` matches docs declaring `landing-page`, etc. `schemas.schemaKey` is **required** and has no default — pick the field your project actually uses for dispatch:
|
|
64
|
+
|
|
65
|
+
- **SSG projects:** `schemaKey: 'meta.layout'` (same field mikser uses for template dispatch)
|
|
66
|
+
- **SPA projects:** `schemaKey: 'meta.component'` (no rendered HTML, no layout)
|
|
67
|
+
|
|
68
|
+
If a schema file loads but never matches any document during a build, the plugin warns at finalize:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
WARN Schema "article" loaded but never matched any entity — check `schemaKey` (currently 'meta.layout') or verify front-matter declares { layout: 'article' }
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
If `schemaKey` is unset, every loaded schema generates a louder warning pointing at the missing config:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
WARN Schema "article" loaded but `schemas.schemaKey` is not set — validation is off. Set it to the front-matter path that names the schema, e.g. 'meta.layout' (SSG) or 'meta.component' (SPA).
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
That makes both failure modes — misconfigured key or no key at all — loud at finalize.
|
|
81
|
+
|
|
82
|
+
## What validation surfaces
|
|
83
|
+
|
|
84
|
+
The plugin runs on `onValidate` — mikser's per-entity validation hook. Behaviour depends on `onError`:
|
|
85
|
+
|
|
86
|
+
| Mode | Behaviour |
|
|
87
|
+
|---|---|
|
|
88
|
+
| `'warn'` *(default)* | Log the validation issue, keep the entity in the catalog, continue the build. Right for adopting schemas on an existing project — broken entries stay visible without breaking deploys. |
|
|
89
|
+
| `'fail'` | Throw on the first invalid entity. The lifecycle marks it as a validation error and the build exits non-zero. Right for greenfield projects or CI. |
|
|
90
|
+
| `'off'` | Validate nothing. Schemas still load and the `.d.ts` is still emitted — useful when you want types without the runtime check. |
|
|
91
|
+
|
|
92
|
+
Example warning output on a malformed article:
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
WARN Validation problem: [CREATE] /content/blog/missing-author.md schema(article) author: Required; publishedAt: Invalid datetime
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The error string lists every issue Zod reported. If you want stricter messages or want to fail only on certain fields, use `.refine(...)` inside the schema — every Zod feature works because the schema *is* a Zod object.
|
|
99
|
+
|
|
100
|
+
## Type generation
|
|
101
|
+
|
|
102
|
+
After every build, `mikser-io-schemas` regenerates a `.d.ts` describing the union of all schemas. The default location is `entities.d.ts` at the project root; override with `schemas.typesFile`.
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
// entities.d.ts — Generated by mikser-io-schemas. Do not edit by hand.
|
|
106
|
+
|
|
107
|
+
import type { z } from 'zod'
|
|
108
|
+
import ArticleSchema from './schemas/article.js'
|
|
109
|
+
import ProductSchema from './schemas/product.js'
|
|
110
|
+
|
|
111
|
+
export type ArticleMeta = z.infer<typeof ArticleSchema>
|
|
112
|
+
export type ProductMeta = z.infer<typeof ProductSchema>
|
|
113
|
+
|
|
114
|
+
export interface LayoutMap {
|
|
115
|
+
'article': ArticleMeta
|
|
116
|
+
'product': ProductMeta
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export type LayoutName = keyof LayoutMap
|
|
120
|
+
export type MetaByLayout<L extends LayoutName> = LayoutMap[L]
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Consumers — typically a Vue or React app using `mikser-io-sdk-api` — can now type the entities they fetch:
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
import type { MetaByLayout } from '../mikser-content/entities'
|
|
127
|
+
import { useDocument } from 'mikser-io-sdk-vue'
|
|
128
|
+
|
|
129
|
+
type Article = { meta: MetaByLayout<'article'> }
|
|
130
|
+
|
|
131
|
+
const { document } = useDocument<Article>('/content/blog/launch')
|
|
132
|
+
// document.value.meta.title ← string
|
|
133
|
+
// document.value.meta.author ← string
|
|
134
|
+
// document.value.meta.tags ← string[]
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
For a discriminated union over layouts:
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
import type { LayoutMap, LayoutName } from '../mikser-content/entities'
|
|
141
|
+
|
|
142
|
+
type Entity = {
|
|
143
|
+
[L in LayoutName]: { layout: L; meta: LayoutMap[L] }
|
|
144
|
+
}[LayoutName]
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Now `if (entity.layout === 'article')` narrows `entity.meta` to `ArticleMeta` automatically.
|
|
148
|
+
|
|
149
|
+
## References between entities
|
|
150
|
+
|
|
151
|
+
[ADR-0007](https://github.com/almero-digital-marketing/mikser-io/blob/main/documentation/decisions/0007-references-declaration-and-expansion.md) makes references first-class via a tiny naming convention: any meta key starting with `$` is a reference. The value stays a plain string (a leading-slash href, no extension), so `sed`-replace, grep, and YAML/JSON portability are unchanged. What changes is that the engine now *knows* which fields are refs and treats them accordingly — render context normalization strips the `$` so templates see `meta.author` instead of `meta.$author`, the api can `expand` them in a single round-trip, and **this plugin auto-validates that every `$`-keyed value resolves to an actual entity in the catalog**.
|
|
152
|
+
|
|
153
|
+
```yaml
|
|
154
|
+
---
|
|
155
|
+
layout: article
|
|
156
|
+
title: Launch
|
|
157
|
+
$author: /authors/dick # single ref
|
|
158
|
+
$hero: /images/launch-hero
|
|
159
|
+
$related: # array of refs
|
|
160
|
+
- /blog/old-post-1
|
|
161
|
+
- /blog/old-post-2
|
|
162
|
+
seo:
|
|
163
|
+
$ogImage: /images/og-launch # nested $-keys also detected
|
|
164
|
+
---
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Schema shape for the same article — no special "reference" type, just `z.string()` because that's what's on disk:
|
|
168
|
+
|
|
169
|
+
```js
|
|
170
|
+
import { z } from 'zod'
|
|
171
|
+
|
|
172
|
+
export default z.object({
|
|
173
|
+
layout: z.literal('article'),
|
|
174
|
+
title: z.string(),
|
|
175
|
+
$author: z.string(),
|
|
176
|
+
$hero: z.string().optional(),
|
|
177
|
+
$related: z.array(z.string()).default([]),
|
|
178
|
+
})
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Deferred, warning-only ref validation
|
|
182
|
+
|
|
183
|
+
Per ADR-0007 A6, file-based editing is multi-step: an article can be saved before its author file exists, an entity can be renamed leaving N referencing entities temporarily broken, a batch import can land in any order. Any model that errors on broken-ref state at parse time fights normal editing workflows.
|
|
184
|
+
|
|
185
|
+
This plugin runs ref validation in `onFinalized` (after `onPersist` has populated the catalog) and **emits warnings, never errors**. Four checks run regardless of whether `schemaKey` is set:
|
|
186
|
+
|
|
187
|
+
- **Shape** — value under a `$`-key isn't a string or string array
|
|
188
|
+
- **Collision** — both `author:` and `$author:` declared in the same entity
|
|
189
|
+
- **Existence** — `$author: /authors/dick` and no entity resolves at that href
|
|
190
|
+
- **Target type** — when typed via `entityRef('author')` (planned), the target's layout doesn't match
|
|
191
|
+
|
|
192
|
+
Warnings are transition-based: a warning fires the first time an entity's issue set appears or changes; an info fires when an entity that previously had issues comes back clean. Stable repeats are silent so a single broken ref doesn't flood the log every cycle.
|
|
193
|
+
|
|
194
|
+
```
|
|
195
|
+
WARN Refs problem: /documents/en/posts/refs-broken.md
|
|
196
|
+
$author: reference /authors/does-not-exist does not resolve
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
A pending-validation map keyed by entity id is re-evaluated every cycle — entries whose targets finally appear clear themselves; new failures get added. The current state is exposed via the `mikser://schemas/pending` MCP resource so editors, dashboards, and AI agents can ask "what's currently broken?" without scraping logs.
|
|
200
|
+
|
|
201
|
+
`onError: 'fail'` in the config applies only to schema-shape mismatches against the Zod schema — never to ref failures, which are always warnings because they are always recoverable through subsequent edits.
|
|
202
|
+
|
|
203
|
+
### Working with refs from the frontend
|
|
204
|
+
|
|
205
|
+
References behave differently in three layers per ADR-0007 A3:
|
|
206
|
+
|
|
207
|
+
| Layer | Shape |
|
|
208
|
+
|---|---|
|
|
209
|
+
| Source files on disk | `$author: /authors/dick` |
|
|
210
|
+
| Catalog (in-memory) | preserved canonical `$`-keys |
|
|
211
|
+
| Templates / render context | normalized — `$` stripped, you do `{{ meta.author }}` |
|
|
212
|
+
| SDK responses | normalized — you read `entity.meta.author` |
|
|
213
|
+
|
|
214
|
+
So a Vue/React/Svelte component reading `useDocument` always sees `meta.author` as a plain string, regardless of whether the source used `author:` or `$author:`. Existing templates and SDK code keep working unchanged.
|
|
215
|
+
|
|
216
|
+
To get the resolved entity inline (instead of just the href string) ask the api to `expand` it — see [`mikser-io-sdk-api`](https://github.com/almero-digital-marketing/mikser-io-sdk-api)'s `expand` parameter. One round-trip, full graph context, type-safe via the generated `.d.ts`.
|
|
217
|
+
|
|
218
|
+
### What about graph-shaped queries?
|
|
219
|
+
|
|
220
|
+
For "which entities reference this one?", "what does this entity link to?", and atomic rename cascade, the engine maintains an inverse-reference index at `runtime.refs.*`. The same data is exposed via MCP tools (`mikser_refs_inbound`, `_outbound`, `_broken`, `_rename`) and the `mikser://refs/index` resource. The index is engine-level (not a plugin) — see ADR-0006's four-test analysis in ADR-0007 §B9 — so it's always available with no plugin-coordination required. This schemas plugin currently does its own catalog walk for re-evaluation; a future optimisation will use `runtime.refs.inboundFor(ref)` to look up exactly which pending entries are affected when a target appears mid-cycle.
|
|
221
|
+
|
|
222
|
+
## Conventions
|
|
223
|
+
|
|
224
|
+
- **One schema file per layout**, named after the layout: `schemas/article.js`, `schemas/product.js`, etc.
|
|
225
|
+
- **Default export is the Zod schema.** No registration boilerplate.
|
|
226
|
+
- **Optional `revision`** export — bump to invalidate the type cache deliberately. (mikser's journal handles incremental builds normally, but this gives you a manual override if needed.)
|
|
227
|
+
- **HMR**: edit a schema file while `mikser --watch` is running and the plugin re-loads it, re-validates affected entities, and re-emits the `.d.ts`.
|
|
228
|
+
|
|
229
|
+
## Configuration reference
|
|
230
|
+
|
|
231
|
+
```js
|
|
232
|
+
schemas: {
|
|
233
|
+
// Folder containing the schema modules. Default: 'schemas'.
|
|
234
|
+
schemasFolder: 'schemas',
|
|
235
|
+
|
|
236
|
+
// Where to write the generated TypeScript declaration file.
|
|
237
|
+
// Path is relative to the working folder; default 'entities.d.ts'.
|
|
238
|
+
typesFile: 'entities.d.ts',
|
|
239
|
+
|
|
240
|
+
// How to behave when an entity fails its schema.
|
|
241
|
+
// 'warn' — log, continue. (default)
|
|
242
|
+
// 'fail' — throw, exit non-zero.
|
|
243
|
+
// 'off' — skip validation entirely.
|
|
244
|
+
onError: 'warn',
|
|
245
|
+
|
|
246
|
+
// Dotted path on the entity that holds the layout name. The
|
|
247
|
+
// filename stem of each schema file is matched against this value.
|
|
248
|
+
// REQUIRED. No default. Pick 'meta.layout' for SSG projects, or
|
|
249
|
+
// 'meta.component' for SPA projects (no rendered HTML, no layout).
|
|
250
|
+
schemaKey: 'meta.layout',
|
|
251
|
+
}
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
## Lifecycle hooks used
|
|
255
|
+
|
|
256
|
+
The plugin hooks into:
|
|
257
|
+
|
|
258
|
+
| Hook | What it does |
|
|
259
|
+
|---|---|
|
|
260
|
+
| `onLoaded` | Resolves config paths, ensures `schemasFolder` exists, registers the schema-folder watcher. Also registers the `mikser://schemas/pending` MCP resource when an MCP substrate is available. |
|
|
261
|
+
| `onSync('schemas', ...)` | Loads / reloads / unloads schema modules as files appear in `schemasFolder`. Cache-busted dynamic import so HMR works. |
|
|
262
|
+
| `onValidate([CREATE, UPDATE], ...)` | Looks up the schema by `entity.meta[schemaKey]` and runs `.safeParse(entity.meta)`. Returns the error message string in `'warn'` mode, throws in `'fail'` mode. Skips silently when `schemaKey` is unset (the finalize warning surfaces the off state). Used for **schema-shape validation only**; ref validation is deferred (see below). |
|
|
263
|
+
| `onFinalized` | Two passes: (1) walks every entity in the catalog and runs ref validation (shape, collision, existence) — see [ADR-0007 A6](https://github.com/almero-digital-marketing/mikser-io/blob/main/documentation/decisions/0007-references-declaration-and-expansion.md). All transitions are logged; stable repeats are silent. (2) If anything changed in the schemas map this run, re-emits the `.d.ts`. |
|
|
264
|
+
|
|
265
|
+
The split between schema validation (`onValidate`, per-entity at create/update) and ref validation (`onFinalized`, full-catalog re-eval per cycle) matches the two failure modes' timing characteristics: schema shape is a property of one entity in isolation; ref resolution depends on the whole catalog's current state, which is only fully visible after `onPersist`.
|
|
266
|
+
|
|
267
|
+
No engine changes, no new lifecycle phases — the plugin lives entirely on top of the existing engine API.
|
|
268
|
+
|
|
269
|
+
## License
|
|
270
|
+
|
|
271
|
+
MIT
|
package/index.js
ADDED
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
// mikser-io-schemas
|
|
2
|
+
//
|
|
3
|
+
// Zod-backed entity validation for mikser-io. Schemas live as plain .js
|
|
4
|
+
// modules in `schemas/` — one per layout — and validate the `meta`
|
|
5
|
+
// front-matter of every loaded entity that uses that layout. A
|
|
6
|
+
// TypeScript declaration file is regenerated on every build so SDK
|
|
7
|
+
// consumers get typed access to entity meta.
|
|
8
|
+
//
|
|
9
|
+
// Layout matching:
|
|
10
|
+
// schemas/article.js → entities where meta.layout === 'article'
|
|
11
|
+
//
|
|
12
|
+
// Configuration:
|
|
13
|
+
// schemas: {
|
|
14
|
+
// schemasFolder: 'schemas', // default
|
|
15
|
+
// typesFile: 'entities.d.ts', // emitted at workingFolder root
|
|
16
|
+
// onError: 'warn', // 'warn' | 'fail' | 'off'
|
|
17
|
+
// schemaKey: 'meta.layout', // REQUIRED. Dotted front-matter
|
|
18
|
+
// // path that names the schema to
|
|
19
|
+
// // validate against. No default —
|
|
20
|
+
// // pick the field your project
|
|
21
|
+
// // actually uses for dispatch:
|
|
22
|
+
// // SSG projects typically pass
|
|
23
|
+
// // 'meta.layout'; SPA projects
|
|
24
|
+
// // (no rendered HTML, no layout)
|
|
25
|
+
// // typically pass 'meta.component'.
|
|
26
|
+
// // When unset, validation is off
|
|
27
|
+
// // and every loaded schema
|
|
28
|
+
// // triggers a finalize warning.
|
|
29
|
+
// }
|
|
30
|
+
//
|
|
31
|
+
// Behavior:
|
|
32
|
+
// - 'warn' (default): log validation errors, leave the entity in the
|
|
33
|
+
// catalog. Right for migrating an existing project; loud enough to
|
|
34
|
+
// notice, soft enough to not block deploys.
|
|
35
|
+
// - 'fail': any validation error throws — the entity is marked invalid
|
|
36
|
+
// by the lifecycle and the build surfaces a non-zero exit.
|
|
37
|
+
// - 'off': validate nothing. Schemas still load (so the .d.ts emit
|
|
38
|
+
// still runs) but entity contents are not checked. Useful when types
|
|
39
|
+
// are the only thing you care about.
|
|
40
|
+
//
|
|
41
|
+
// Reference handling:
|
|
42
|
+
// References between entities are plain href strings (see
|
|
43
|
+
// mikser-io-sdk-vue's useDocument / useHref). A schema field for a
|
|
44
|
+
// reference is just z.string() — no special reference type. This keeps
|
|
45
|
+
// schemas portable and avoids coupling the validation layer to the
|
|
46
|
+
// resolution layer.
|
|
47
|
+
|
|
48
|
+
import path from 'node:path'
|
|
49
|
+
import { mkdir, readdir } from 'node:fs/promises'
|
|
50
|
+
import _ from 'lodash'
|
|
51
|
+
import { extractRefs, isRefKey, findEntities } from 'mikser-io'
|
|
52
|
+
import { writeTypes } from './src/typegen.js'
|
|
53
|
+
|
|
54
|
+
// Friendly per-issue messages — overrides Zod's defaults for the cases
|
|
55
|
+
// where Zod's wording is technically correct but reads awkwardly for
|
|
56
|
+
// people editing markdown. Returning `{ message }` overrides for that
|
|
57
|
+
// issue; falling through to ctx.defaultError keeps Zod's text.
|
|
58
|
+
//
|
|
59
|
+
// String codes (not zod's ZodIssueCode imports) so the plugin keeps
|
|
60
|
+
// zod as a peer dependency with no direct require — every schema
|
|
61
|
+
// already brings its own zod.
|
|
62
|
+
function friendlyErrorMap(issue, ctx) {
|
|
63
|
+
switch (issue.code) {
|
|
64
|
+
case 'invalid_type':
|
|
65
|
+
if (issue.received === 'undefined') return { message: 'is missing' }
|
|
66
|
+
return { message: `expected ${issue.expected}, got ${issue.received}` }
|
|
67
|
+
case 'too_small':
|
|
68
|
+
if (issue.type === 'string') return { message: `is too short (min ${issue.minimum} chars)` }
|
|
69
|
+
if (issue.type === 'number') return { message: `is too small (min ${issue.minimum})` }
|
|
70
|
+
if (issue.type === 'array') return { message: `needs at least ${issue.minimum} item${issue.minimum === 1 ? '' : 's'}` }
|
|
71
|
+
break
|
|
72
|
+
case 'too_big':
|
|
73
|
+
if (issue.type === 'string') return { message: `is too long (max ${issue.maximum} chars)` }
|
|
74
|
+
if (issue.type === 'number') return { message: `is too large (max ${issue.maximum})` }
|
|
75
|
+
if (issue.type === 'array') return { message: `has too many items (max ${issue.maximum})` }
|
|
76
|
+
break
|
|
77
|
+
case 'invalid_string':
|
|
78
|
+
if (issue.validation === 'email') return { message: 'is not a valid email' }
|
|
79
|
+
if (issue.validation === 'url') return { message: 'is not a valid URL' }
|
|
80
|
+
if (issue.validation === 'uuid') return { message: 'is not a valid UUID' }
|
|
81
|
+
if (issue.validation === 'regex') return { message: 'does not match the required pattern' }
|
|
82
|
+
break
|
|
83
|
+
case 'invalid_enum_value':
|
|
84
|
+
return { message: `must be one of: ${(issue.options ?? []).join(', ')}` }
|
|
85
|
+
case 'unrecognized_keys': {
|
|
86
|
+
const keys = issue.keys ?? []
|
|
87
|
+
return { message: `unknown field${keys.length === 1 ? '' : 's'}: ${keys.join(', ')}` }
|
|
88
|
+
}
|
|
89
|
+
case 'invalid_union':
|
|
90
|
+
case 'invalid_union_discriminator':
|
|
91
|
+
return { message: 'does not match any expected shape' }
|
|
92
|
+
}
|
|
93
|
+
return { message: ctx.defaultError }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export default ({
|
|
97
|
+
runtime,
|
|
98
|
+
onLoaded,
|
|
99
|
+
onValidate,
|
|
100
|
+
onFinalized,
|
|
101
|
+
onSync,
|
|
102
|
+
watch,
|
|
103
|
+
useLogger,
|
|
104
|
+
matchEntity,
|
|
105
|
+
constants: { OPERATION, ACTION },
|
|
106
|
+
}) => {
|
|
107
|
+
const collection = 'schemas'
|
|
108
|
+
const type = 'schema'
|
|
109
|
+
|
|
110
|
+
// schema name → { name, schema, source, revision }
|
|
111
|
+
const schemas = {}
|
|
112
|
+
|
|
113
|
+
// Names of schemas that actually matched at least one entity during
|
|
114
|
+
// the run. Anything in `schemas` but missing from `usedSchemas` at
|
|
115
|
+
// finalize triggers a warning — catches the silent-skip failure
|
|
116
|
+
// mode where validation is configured but never runs (wrong
|
|
117
|
+
// schemaKey, typo, missing front-matter, no docs with that
|
|
118
|
+
// dispatch token).
|
|
119
|
+
const usedSchemas = new Set()
|
|
120
|
+
|
|
121
|
+
// Generated .d.ts gets stamped from a stable journal-of-edits, not
|
|
122
|
+
// wall-clock time — wrote() flips true on any schema-folder sync so
|
|
123
|
+
// onFinalized knows it needs to re-emit.
|
|
124
|
+
let dirty = true
|
|
125
|
+
|
|
126
|
+
// Reference-validation state. Per ADR-0007 A6 we never error on ref
|
|
127
|
+
// problems — they're routine mid-edit state (article saved before
|
|
128
|
+
// its author, entity renamed leaving N referencing entities
|
|
129
|
+
// temporarily broken). Instead we keep the open issues by entity id
|
|
130
|
+
// and re-evaluate every cycle, so newly-resolved refs auto-clear and
|
|
131
|
+
// newly-broken refs surface promptly.
|
|
132
|
+
//
|
|
133
|
+
// pending = Map<entityId, RefIssue[]>
|
|
134
|
+
// RefIssue = { kind: 'shape'|'collision'|'missing', path, ...detail }
|
|
135
|
+
//
|
|
136
|
+
// Logging is transition-based: a warning fires the first time an
|
|
137
|
+
// entity's ref-issue set differs from what's already recorded, and
|
|
138
|
+
// a tidy "cleared" info fires when an entity that previously had
|
|
139
|
+
// issues comes back clean. Subsequent cycles with the same set are
|
|
140
|
+
// silent to keep log noise down.
|
|
141
|
+
const pending = new Map()
|
|
142
|
+
|
|
143
|
+
function getSchemaName(entity, schemaKey) {
|
|
144
|
+
return _.get(entity, schemaKey)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Walk meta looking for $-keys whose values are neither strings nor
|
|
148
|
+
// string arrays. Skips array elements that aren't strings — those
|
|
149
|
+
// produce per-element issues. Only walks plain objects; arrays of
|
|
150
|
+
// objects are walked too.
|
|
151
|
+
function findShapeIssues(meta) {
|
|
152
|
+
const issues = []
|
|
153
|
+
walk(meta, '')
|
|
154
|
+
return issues
|
|
155
|
+
|
|
156
|
+
function walk(node, prefix) {
|
|
157
|
+
if (node === null || typeof node !== 'object') return
|
|
158
|
+
if (Array.isArray(node)) {
|
|
159
|
+
for (let i = 0; i < node.length; i++) {
|
|
160
|
+
walk(node[i], prefix ? `${prefix}.${i}` : String(i))
|
|
161
|
+
}
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
for (const [k, v] of Object.entries(node)) {
|
|
165
|
+
const here = prefix ? `${prefix}.${k}` : k
|
|
166
|
+
if (isRefKey(k)) {
|
|
167
|
+
if (typeof v === 'string') {
|
|
168
|
+
// valid
|
|
169
|
+
} else if (Array.isArray(v)) {
|
|
170
|
+
const badIdx = v.findIndex(x => typeof x !== 'string')
|
|
171
|
+
if (badIdx >= 0) {
|
|
172
|
+
issues.push({
|
|
173
|
+
kind: 'shape',
|
|
174
|
+
path: here,
|
|
175
|
+
detail: `array element at index ${badIdx} is not a string`,
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
} else {
|
|
179
|
+
issues.push({
|
|
180
|
+
kind: 'shape',
|
|
181
|
+
path: here,
|
|
182
|
+
detail: `value must be string or string array, got ${v === null ? 'null' : typeof v}`,
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
} else {
|
|
186
|
+
walk(v, here)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Walk meta looking for collisions — sibling keys where both `key`
|
|
193
|
+
// and `$key` are declared in the same object. Per ADR-0007 A4 the
|
|
194
|
+
// $-version wins in the projection deterministically; we surface a
|
|
195
|
+
// warning so editors know they have orphaned non-ref state.
|
|
196
|
+
function findCollisionIssues(meta) {
|
|
197
|
+
const issues = []
|
|
198
|
+
walk(meta, '')
|
|
199
|
+
return issues
|
|
200
|
+
|
|
201
|
+
function walk(node, prefix) {
|
|
202
|
+
if (node === null || typeof node !== 'object') return
|
|
203
|
+
if (Array.isArray(node)) {
|
|
204
|
+
for (let i = 0; i < node.length; i++) {
|
|
205
|
+
walk(node[i], prefix ? `${prefix}.${i}` : String(i))
|
|
206
|
+
}
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
const dollarStems = new Set()
|
|
210
|
+
const plainKeys = new Set()
|
|
211
|
+
for (const k of Object.keys(node)) {
|
|
212
|
+
if (isRefKey(k)) dollarStems.add(k.slice(1))
|
|
213
|
+
else plainKeys.add(k)
|
|
214
|
+
}
|
|
215
|
+
for (const stem of dollarStems) {
|
|
216
|
+
if (plainKeys.has(stem)) {
|
|
217
|
+
const here = prefix ? `${prefix}.${stem}` : stem
|
|
218
|
+
issues.push({
|
|
219
|
+
kind: 'collision',
|
|
220
|
+
path: here,
|
|
221
|
+
detail: `both \`${stem}\` and \`$${stem}\` declared; $-version wins in render`,
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
for (const [k, v] of Object.entries(node)) {
|
|
226
|
+
walk(v, prefix ? `${prefix}.${k}` : k)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Check whether a ref string resolves to an entity in the catalog.
|
|
232
|
+
// The convention (ADR-0007 A2) is hrefs — leading slash, no
|
|
233
|
+
// extension — but the catalog keys entities by id (which for source-
|
|
234
|
+
// file entities includes the extension). We tolerate both forms so a
|
|
235
|
+
// ref like `/authors/dick` matches an entity at `/authors/dick.md`.
|
|
236
|
+
async function refExists(ref) {
|
|
237
|
+
const matches = await findEntities(e =>
|
|
238
|
+
!!e && (
|
|
239
|
+
e.id === ref ||
|
|
240
|
+
e.meta?.href === ref ||
|
|
241
|
+
(typeof e.id === 'string' && e.id.replace(/\.[^./]+$/, '') === ref)
|
|
242
|
+
),
|
|
243
|
+
)
|
|
244
|
+
return matches.length > 0
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function validateEntityRefs(entity) {
|
|
248
|
+
const issues = []
|
|
249
|
+
if (!entity?.meta || typeof entity.meta !== 'object') return issues
|
|
250
|
+
|
|
251
|
+
issues.push(...findShapeIssues(entity.meta))
|
|
252
|
+
issues.push(...findCollisionIssues(entity.meta))
|
|
253
|
+
|
|
254
|
+
// Existence check runs over the valid string refs only — shape
|
|
255
|
+
// issues already flag the malformed ones, no double-warning.
|
|
256
|
+
for (const { path: refPath, ref } of extractRefs(entity.meta)) {
|
|
257
|
+
if (!(await refExists(ref))) {
|
|
258
|
+
issues.push({ kind: 'missing', path: refPath, ref })
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return issues
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function formatIssueLine(issue) {
|
|
265
|
+
switch (issue.kind) {
|
|
266
|
+
case 'shape': return ` ${issue.path}: ${issue.detail}`
|
|
267
|
+
case 'collision': return ` ${issue.path}: ${issue.detail}`
|
|
268
|
+
case 'missing': return ` ${issue.path}: reference ${issue.ref} does not resolve`
|
|
269
|
+
default: return ` ${issue.path}: ${JSON.stringify(issue)}`
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function issueSet(issues) {
|
|
274
|
+
return new Set(issues.map(i => `${i.kind}:${i.path}:${i.ref ?? ''}:${i.detail ?? ''}`))
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function issuesEqual(a, b) {
|
|
278
|
+
if (a.length !== b.length) return false
|
|
279
|
+
const sa = issueSet(a)
|
|
280
|
+
for (const k of issueSet(b)) {
|
|
281
|
+
if (!sa.has(k)) return false
|
|
282
|
+
}
|
|
283
|
+
return true
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Load (or reload) a single schema file. Used both for the initial
|
|
287
|
+
// folder scan in onLoaded and for live onSync CREATE/UPDATE events.
|
|
288
|
+
// Returns true on successful load (caller can flip `dirty`).
|
|
289
|
+
async function loadSchemaFile(name, source) {
|
|
290
|
+
const logger = useLogger()
|
|
291
|
+
try {
|
|
292
|
+
const mod = await import(`${source}?stamp=${Date.now()}`)
|
|
293
|
+
const schema = mod.default
|
|
294
|
+
if (!schema || typeof schema.safeParse !== 'function') {
|
|
295
|
+
logger.error(
|
|
296
|
+
'Schema %s: default export is not a Zod schema (no safeParse method)',
|
|
297
|
+
name,
|
|
298
|
+
)
|
|
299
|
+
return false
|
|
300
|
+
}
|
|
301
|
+
schemas[name] = { name, schema, source, revision: mod.revision ?? 1 }
|
|
302
|
+
dirty = true
|
|
303
|
+
logger.info('Schema loaded: %s', name)
|
|
304
|
+
return true
|
|
305
|
+
} catch (err) {
|
|
306
|
+
logger.error('Schema %s: load failed: %s', name, err.message)
|
|
307
|
+
return false
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
onLoaded(async () => {
|
|
312
|
+
const logger = useLogger()
|
|
313
|
+
const config = runtime.config.schemas ?? {}
|
|
314
|
+
|
|
315
|
+
runtime.options.schemas = config.schemasFolder || collection
|
|
316
|
+
runtime.options.schemasFolder = path.join(
|
|
317
|
+
runtime.options.workingFolder,
|
|
318
|
+
runtime.options.schemas,
|
|
319
|
+
)
|
|
320
|
+
runtime.options.schemasTypesFile = path.join(
|
|
321
|
+
runtime.options.workingFolder,
|
|
322
|
+
config.typesFile || 'entities.d.ts',
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
await mkdir(runtime.options.schemasFolder, { recursive: true })
|
|
326
|
+
logger.info('Schemas folder: %s', runtime.options.schemasFolder)
|
|
327
|
+
logger.info('Schemas types: %s', runtime.options.schemasTypesFile)
|
|
328
|
+
|
|
329
|
+
// Initial scan — load every existing schema file. Chokidar (started
|
|
330
|
+
// by watch() below) defaults to ignoreInitial: true and only runs
|
|
331
|
+
// in --watch mode at all, so without this loop a cold start would
|
|
332
|
+
// silently ignore every schema already on disk.
|
|
333
|
+
const entries = await readdir(runtime.options.schemasFolder, { withFileTypes: true })
|
|
334
|
+
const schemaFiles = entries
|
|
335
|
+
.filter(entry => entry.isFile())
|
|
336
|
+
.filter(entry => entry.name.endsWith('.js') || entry.name.endsWith('.mjs'))
|
|
337
|
+
for (const entry of schemaFiles) {
|
|
338
|
+
const name = entry.name.replace(path.extname(entry.name), '')
|
|
339
|
+
const source = path.join(runtime.options.schemasFolder, entry.name)
|
|
340
|
+
await loadSchemaFile(name, source)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
watch(collection, runtime.options.schemasFolder)
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
// Discover schema modules. Each file in schemasFolder becomes one
|
|
347
|
+
// schema keyed by its filename stem ('article.js' → 'article').
|
|
348
|
+
// The default export must be a Zod schema (anything with a
|
|
349
|
+
// `.safeParse()` method is treated as one — we don't `instanceof`
|
|
350
|
+
// ZodType so users can pass a `.refine(...)`/`.transform(...)` chain
|
|
351
|
+
// or even a custom validator with a safeParse-shaped surface).
|
|
352
|
+
//
|
|
353
|
+
// onSync fires from chokidar events after the initial scan. Files
|
|
354
|
+
// already on disk at startup are picked up by the readdir loop in
|
|
355
|
+
// onLoaded.
|
|
356
|
+
onSync(collection, async ({ action, context }) => {
|
|
357
|
+
if (!context.relativePath) return false
|
|
358
|
+
const { relativePath } = context
|
|
359
|
+
if (!relativePath.endsWith('.js') && !relativePath.endsWith('.mjs')) return false
|
|
360
|
+
|
|
361
|
+
const logger = useLogger()
|
|
362
|
+
const name = relativePath.replace(path.extname(relativePath), '')
|
|
363
|
+
const source = path.join(runtime.options.schemasFolder, relativePath)
|
|
364
|
+
|
|
365
|
+
switch (action) {
|
|
366
|
+
case ACTION.CREATE:
|
|
367
|
+
case ACTION.UPDATE: {
|
|
368
|
+
await loadSchemaFile(name, source)
|
|
369
|
+
return true
|
|
370
|
+
}
|
|
371
|
+
case ACTION.DELETE: {
|
|
372
|
+
if (schemas[name]) {
|
|
373
|
+
delete schemas[name]
|
|
374
|
+
dirty = true
|
|
375
|
+
logger.info('Schema removed: %s', name)
|
|
376
|
+
}
|
|
377
|
+
return true
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return false
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
// Reference validation runs in `onFinalized` because that's the
|
|
384
|
+
// earliest phase where the catalog is fully populated:
|
|
385
|
+
//
|
|
386
|
+
// process — yaml / front-matter plugins parse `meta`
|
|
387
|
+
// processed — layouts plugin annotates entities
|
|
388
|
+
// persist — entries flow into the catalog (findEntities works
|
|
389
|
+
// from here onwards)
|
|
390
|
+
// render / postprocess
|
|
391
|
+
// finalize ← here. We can walk findEntities() and resolve refs.
|
|
392
|
+
//
|
|
393
|
+
// The earlier `onValidate` hook doesn't work for source documents
|
|
394
|
+
// because it fires at createEntity time, before front-matter has
|
|
395
|
+
// populated `meta`. Earlier process-phase hooks see only the current
|
|
396
|
+
// cycle's mutations through the journal, but ref resolution needs
|
|
397
|
+
// the *catalog* — which entities currently exist — and that's the
|
|
398
|
+
// post-persist view.
|
|
399
|
+
//
|
|
400
|
+
// Per ADR-0007 A6 ref validation is always WARNINGS, never errors.
|
|
401
|
+
// `config.onError: 'fail'` does not apply — broken refs are routine
|
|
402
|
+
// mid-edit state, not unrecoverable failures.
|
|
403
|
+
//
|
|
404
|
+
// Logging is transition-based: a warning fires the first time an
|
|
405
|
+
// entity's issue set appears or changes, and a tidy "cleared" info
|
|
406
|
+
// fires when an entity that previously had issues comes back clean.
|
|
407
|
+
// Stable repeats are silent so a single broken ref doesn't flood the
|
|
408
|
+
// log on every cycle.
|
|
409
|
+
|
|
410
|
+
// Validate per-entity on CREATE/UPDATE. Returning a string surfaces
|
|
411
|
+
// it as a validator warning via mikser's onValidate semantics;
|
|
412
|
+
// throwing fails the entry. Mode picked from runtime.config.schemas.onError.
|
|
413
|
+
onValidate([OPERATION.CREATE, OPERATION.UPDATE], async entry => {
|
|
414
|
+
const config = runtime.config.schemas ?? {}
|
|
415
|
+
const mode = config.onError ?? 'warn'
|
|
416
|
+
if (mode === 'off') return
|
|
417
|
+
|
|
418
|
+
// `schemaKey` is the dotted front-matter path that names the
|
|
419
|
+
// schema to validate against. Required — no default. SSG
|
|
420
|
+
// projects typically pass 'meta.layout' (same field mikser uses
|
|
421
|
+
// for template dispatch); SPA projects pass 'meta.component'
|
|
422
|
+
// since their docs have no layout. Anything else works too —
|
|
423
|
+
// e.g. 'meta.type' if your schemas key off a separate
|
|
424
|
+
// content-type field. When unset, validation is off; the
|
|
425
|
+
// finalize hook below warns about every loaded schema so the
|
|
426
|
+
// off state is loud, not silent.
|
|
427
|
+
const schemaKey = config.schemaKey
|
|
428
|
+
if (!schemaKey) return
|
|
429
|
+
const entity = entry.entity
|
|
430
|
+
if (!entity || !entity.meta) return
|
|
431
|
+
|
|
432
|
+
const schemaName = getSchemaName(entity, schemaKey)
|
|
433
|
+
if (!schemaName) return
|
|
434
|
+
|
|
435
|
+
const definition = schemas[schemaName]
|
|
436
|
+
if (!definition) return // no schema for this name — silently skip
|
|
437
|
+
|
|
438
|
+
usedSchemas.add(schemaName)
|
|
439
|
+
|
|
440
|
+
const result = definition.schema.safeParse(entity.meta, { errorMap: friendlyErrorMap })
|
|
441
|
+
if (result.success) return
|
|
442
|
+
|
|
443
|
+
// Multi-line message: one issue per line, source identified up
|
|
444
|
+
// front. Logs and thrown errors both render this readably; an
|
|
445
|
+
// editor scanning the log can spot exactly which file + which
|
|
446
|
+
// field needs attention. Single-line variant felt cramped on
|
|
447
|
+
// docs with several violations.
|
|
448
|
+
const sourceId = entity.id || '<unknown source>'
|
|
449
|
+
const lines = result.error.issues
|
|
450
|
+
.map(i => ` - ${i.path.join('.') || '<root>'}: ${i.message}`)
|
|
451
|
+
const message = `schema(${schemaName}) ${sourceId}:\n${lines.join('\n')}`
|
|
452
|
+
|
|
453
|
+
if (mode === 'fail') {
|
|
454
|
+
throw new Error(message)
|
|
455
|
+
}
|
|
456
|
+
return message // 'warn' — surfaces via mikser's logger
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
onFinalized(async () => {
|
|
460
|
+
const logger = useLogger()
|
|
461
|
+
const entities = await findEntities()
|
|
462
|
+
const stillPresent = new Set()
|
|
463
|
+
for (const entity of entities) {
|
|
464
|
+
if (!entity?.id) continue
|
|
465
|
+
stillPresent.add(entity.id)
|
|
466
|
+
|
|
467
|
+
const newIssues = await validateEntityRefs(entity)
|
|
468
|
+
const oldIssues = pending.get(entity.id) ?? []
|
|
469
|
+
|
|
470
|
+
if (!issuesEqual(newIssues, oldIssues)) {
|
|
471
|
+
if (newIssues.length > 0) {
|
|
472
|
+
logger.warn(
|
|
473
|
+
'Refs problem: %s\n%s',
|
|
474
|
+
entity.id,
|
|
475
|
+
newIssues.map(formatIssueLine).join('\n'),
|
|
476
|
+
)
|
|
477
|
+
} else if (oldIssues.length > 0) {
|
|
478
|
+
logger.info('Refs cleared: %s', entity.id)
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (newIssues.length > 0) pending.set(entity.id, newIssues)
|
|
483
|
+
else pending.delete(entity.id)
|
|
484
|
+
}
|
|
485
|
+
// Drop pending entries for entities that have been deleted from
|
|
486
|
+
// the catalog — they can't be re-validated, and keeping them in
|
|
487
|
+
// pending would leak forever.
|
|
488
|
+
for (const id of [...pending.keys()]) {
|
|
489
|
+
if (!stillPresent.has(id)) pending.delete(id)
|
|
490
|
+
}
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
// Expose the current pending-validation list via the MCP substrate so
|
|
494
|
+
// editors, dashboards, and AI agents can ask "what's currently
|
|
495
|
+
// broken?" without scraping logs. Read-only snapshot of the in-memory
|
|
496
|
+
// pending Map.
|
|
497
|
+
onLoaded(() => {
|
|
498
|
+
const mcp = runtime.options.mcp
|
|
499
|
+
if (!mcp) return
|
|
500
|
+
try {
|
|
501
|
+
mcp.registerResource(
|
|
502
|
+
'mikser-schemas-pending',
|
|
503
|
+
'mikser://schemas/pending',
|
|
504
|
+
{
|
|
505
|
+
title: 'Pending schema-validation issues',
|
|
506
|
+
description: 'Per-entity reference issues currently flagged by mikser-io-schemas — shape problems, collisions, missing targets. Re-evaluated each cycle, so entries clear when their targets appear and new entries surface as references break.',
|
|
507
|
+
mimeType: 'application/json',
|
|
508
|
+
},
|
|
509
|
+
async (uri) => ({
|
|
510
|
+
contents: [{
|
|
511
|
+
uri: uri.href,
|
|
512
|
+
mimeType: 'application/json',
|
|
513
|
+
text: JSON.stringify({
|
|
514
|
+
count: pending.size,
|
|
515
|
+
entries: Array.from(pending.entries()).map(([id, issues]) => ({
|
|
516
|
+
id,
|
|
517
|
+
issues: issues.map(i => ({ kind: i.kind, path: i.path, ref: i.ref, detail: i.detail })),
|
|
518
|
+
})),
|
|
519
|
+
}, null, 2),
|
|
520
|
+
}],
|
|
521
|
+
}),
|
|
522
|
+
)
|
|
523
|
+
} catch (err) {
|
|
524
|
+
useLogger().debug('mikser://schemas/pending registration skipped: %s', err.message)
|
|
525
|
+
}
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
// Regenerate the .d.ts at the end of every build. Idempotent — only
|
|
529
|
+
// rewrites if something actually changed in the schemas map.
|
|
530
|
+
onFinalized(async () => {
|
|
531
|
+
const logger = useLogger()
|
|
532
|
+
const config = runtime.config.schemas ?? {}
|
|
533
|
+
const mode = config.onError ?? 'warn'
|
|
534
|
+
|
|
535
|
+
// Unused-schema warning: every loaded schema that never matched
|
|
536
|
+
// an entity during the run is almost certainly a config mistake
|
|
537
|
+
// — schemaKey not set at all, or pointing at a field your
|
|
538
|
+
// front-matter doesn't declare, or a typo in either the schema
|
|
539
|
+
// filename or the dispatch value, or simply no docs of that
|
|
540
|
+
// kind. Silent skip would let a project ship with validation
|
|
541
|
+
// effectively off, so we surface it at finalize. Suppressed
|
|
542
|
+
// when mode is 'off' (the user opted out explicitly).
|
|
543
|
+
if (mode !== 'off' && Object.keys(schemas).length > 0) {
|
|
544
|
+
const schemaKey = config.schemaKey
|
|
545
|
+
const unused = Object.keys(schemas).filter(n => !usedSchemas.has(n))
|
|
546
|
+
for (const name of unused) {
|
|
547
|
+
if (!schemaKey) {
|
|
548
|
+
logger.warn(
|
|
549
|
+
'Schema "%s" loaded but `schemas.schemaKey` is not set — validation is off. Set it to the front-matter path that names the schema, e.g. \'meta.layout\' (SSG) or \'meta.component\' (SPA).',
|
|
550
|
+
name,
|
|
551
|
+
)
|
|
552
|
+
} else {
|
|
553
|
+
logger.warn(
|
|
554
|
+
'Schema "%s" loaded but never matched any entity — check `schemaKey` (currently \'%s\') or verify front-matter declares { %s: \'%s\' }',
|
|
555
|
+
name, schemaKey, schemaKey.replace(/^meta\./, ''), name,
|
|
556
|
+
)
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (!dirty) return
|
|
562
|
+
try {
|
|
563
|
+
await writeTypes({
|
|
564
|
+
schemas,
|
|
565
|
+
outputPath: runtime.options.schemasTypesFile,
|
|
566
|
+
})
|
|
567
|
+
dirty = false
|
|
568
|
+
logger.info(
|
|
569
|
+
'Schemas types emitted: %d schemas → %s',
|
|
570
|
+
Object.keys(schemas).length,
|
|
571
|
+
runtime.options.schemasTypesFile,
|
|
572
|
+
)
|
|
573
|
+
} catch (err) {
|
|
574
|
+
logger.error('Schemas types emit failed: %s', err.message)
|
|
575
|
+
}
|
|
576
|
+
})
|
|
577
|
+
|
|
578
|
+
return { collection, type }
|
|
579
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mikser-io-schemas",
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "Zod-backed entity validation and TypeScript type generation for mikser-io",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"files": [
|
|
8
|
+
"index.js",
|
|
9
|
+
"src",
|
|
10
|
+
"README.md",
|
|
11
|
+
"LICENSE"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {},
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/almero-digital-marketing/mikser-io-schemas.git"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"mikser",
|
|
20
|
+
"mikser-io",
|
|
21
|
+
"zod",
|
|
22
|
+
"schema",
|
|
23
|
+
"validation",
|
|
24
|
+
"typescript"
|
|
25
|
+
],
|
|
26
|
+
"author": "",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"bugs": {
|
|
29
|
+
"url": "https://github.com/almero-digital-marketing/mikser-io-schemas/issues"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://github.com/almero-digital-marketing/mikser-io-schemas#readme",
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"mikser-io": "^8.2.0",
|
|
34
|
+
"zod": "^4.0.0"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/typegen.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Emit a TypeScript declaration file describing the entity meta shape for
|
|
2
|
+
// each registered schema. The generated file is plain text — we don't
|
|
3
|
+
// invoke the TypeScript compiler — so consumers don't need tsc installed.
|
|
4
|
+
//
|
|
5
|
+
// The shape is:
|
|
6
|
+
//
|
|
7
|
+
// import type { z } from 'zod'
|
|
8
|
+
// import articleSchema from './schemas/article.js'
|
|
9
|
+
// import productSchema from './schemas/product.js'
|
|
10
|
+
//
|
|
11
|
+
// export type ArticleMeta = z.infer<typeof articleSchema>
|
|
12
|
+
// export type ProductMeta = z.infer<typeof productSchema>
|
|
13
|
+
//
|
|
14
|
+
// export interface LayoutMap {
|
|
15
|
+
// article: ArticleMeta
|
|
16
|
+
// product: ProductMeta
|
|
17
|
+
// }
|
|
18
|
+
//
|
|
19
|
+
// export type LayoutName = keyof LayoutMap
|
|
20
|
+
// export type MetaByLayout<L extends LayoutName> = LayoutMap[L]
|
|
21
|
+
//
|
|
22
|
+
// SDK consumers can then write:
|
|
23
|
+
//
|
|
24
|
+
// import type { MetaByLayout } from './entities'
|
|
25
|
+
// const { document } = useDocument<{ meta: MetaByLayout<'article'> }>(id)
|
|
26
|
+
//
|
|
27
|
+
// or build a discriminated union over layouts for narrow typing.
|
|
28
|
+
|
|
29
|
+
import path from 'node:path'
|
|
30
|
+
import { writeFile, mkdir } from 'node:fs/promises'
|
|
31
|
+
|
|
32
|
+
function pascalCase(name) {
|
|
33
|
+
return name
|
|
34
|
+
.split(/[-_/.]+/)
|
|
35
|
+
.filter(Boolean)
|
|
36
|
+
.map(part => part[0].toUpperCase() + part.slice(1))
|
|
37
|
+
.join('')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Write the .d.ts to `outputPath` based on the loaded schemas.
|
|
42
|
+
*
|
|
43
|
+
* @param {object} opts
|
|
44
|
+
* @param {Record<string, { name: string, source: string }>} opts.schemas
|
|
45
|
+
* Map of layout name → { name, source } (source is the absolute path to
|
|
46
|
+
* the schema module, used to compute a relative import).
|
|
47
|
+
* @param {string} opts.outputPath Absolute path to the .d.ts target.
|
|
48
|
+
*/
|
|
49
|
+
export async function writeTypes({ schemas, outputPath }) {
|
|
50
|
+
await mkdir(path.dirname(outputPath), { recursive: true })
|
|
51
|
+
|
|
52
|
+
const entries = Object.entries(schemas).sort(([a], [b]) => a.localeCompare(b))
|
|
53
|
+
if (entries.length === 0) {
|
|
54
|
+
// Still emit something so downstream type-checking doesn't break
|
|
55
|
+
// when the project is mid-migration with zero schemas yet.
|
|
56
|
+
const empty = [
|
|
57
|
+
'// Generated by mikser-io-schemas. Do not edit by hand.',
|
|
58
|
+
'',
|
|
59
|
+
'export interface LayoutMap {}',
|
|
60
|
+
'export type LayoutName = keyof LayoutMap',
|
|
61
|
+
'export type MetaByLayout<L extends LayoutName> = LayoutMap[L]',
|
|
62
|
+
'',
|
|
63
|
+
].join('\n')
|
|
64
|
+
await writeFile(outputPath, empty, 'utf8')
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const outDir = path.dirname(outputPath)
|
|
69
|
+
const imports = []
|
|
70
|
+
const typeAliases = []
|
|
71
|
+
const mapLines = []
|
|
72
|
+
|
|
73
|
+
for (const [layout, { source }] of entries) {
|
|
74
|
+
const ident = `${pascalCase(layout)}Schema`
|
|
75
|
+
const metaAlias = `${pascalCase(layout)}Meta`
|
|
76
|
+
let importPath = path.relative(outDir, source).replace(/\\/g, '/')
|
|
77
|
+
if (!importPath.startsWith('.')) importPath = `./${importPath}`
|
|
78
|
+
|
|
79
|
+
imports.push(`import ${ident} from '${importPath}'`)
|
|
80
|
+
typeAliases.push(`export type ${metaAlias} = z.infer<typeof ${ident}>`)
|
|
81
|
+
mapLines.push(` '${layout}': ${metaAlias}`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const body = [
|
|
85
|
+
'// Generated by mikser-io-schemas. Do not edit by hand.',
|
|
86
|
+
'',
|
|
87
|
+
"import type { z } from 'zod'",
|
|
88
|
+
...imports,
|
|
89
|
+
'',
|
|
90
|
+
...typeAliases,
|
|
91
|
+
'',
|
|
92
|
+
'export interface LayoutMap {',
|
|
93
|
+
...mapLines,
|
|
94
|
+
'}',
|
|
95
|
+
'',
|
|
96
|
+
'export type LayoutName = keyof LayoutMap',
|
|
97
|
+
'export type MetaByLayout<L extends LayoutName> = LayoutMap[L]',
|
|
98
|
+
'',
|
|
99
|
+
].join('\n')
|
|
100
|
+
|
|
101
|
+
await writeFile(outputPath, body, 'utf8')
|
|
102
|
+
}
|