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 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
+ }