mikser-io-schemas 1.1.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +183 -0
  3. package/index.js +211 -0
  4. package/package.json +42 -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,183 @@
1
+ # mikser-io-schemas
2
+
3
+ Zod schemas as entities for [mikser-io](https://github.com/almero-digital-marketing/mikser-io). Loads `.js` files from a `schemas/` folder, registers each as an entity in the catalog (`type: 'schema'`), and validates other entities against their declared schema during the lifecycle.
4
+
5
+ Mirrors the layouts pattern: each file in the folder becomes one entity, content references a schema by name in front-matter, and the plugin handles validation transparently — the same way layouts handles auto-matching and rendering.
6
+
7
+ ## Why schemas as entities
8
+
9
+ At agency-scale content sites, content shapes need to be:
10
+
11
+ - **Discoverable** — the frontend, an admin UI, or an AI agent can query "what schemas exist?" via the api plugin and get back the field metadata, perfect for form generation.
12
+ - **Versioned with the content** — schemas live as plain files in `schemas/`. Diffable, portable, takeable on day one and year ten (see ADR-0002 in mikser-io).
13
+ - **Validated in the build** — a doc that doesn't fit its declared schema fails at build time, not at runtime in front of users.
14
+ - **AI-friendly** — an agent reading `schemas/product.js` understands the shape of every product doc without ad-hoc inference.
15
+
16
+ This plugin treats schemas the same way mikser treats layouts: file in a folder, entity in the catalog, plugin handles the rest.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ npm install mikser-io-schemas zod
22
+ ```
23
+
24
+ `zod` is a peer dependency — bring your own version (`^3.0.0` or `^4.0.0`).
25
+
26
+ ## Configure
27
+
28
+ ```js
29
+ // mikser.config.js
30
+ export default {
31
+ plugins: ['documents', 'layouts', 'render-hbs', 'schemas'],
32
+
33
+ schemas: {
34
+ schemasFolder: 'schemas', // default
35
+ extensions: ['js', 'mjs', 'cjs'], // default
36
+ validateOn: 'meta', // 'meta' | 'entity' | 'content'; default 'meta'
37
+ strict: false, // throw on failure (default: warn)
38
+ match: entity => entity.meta?.schema ?? entity.meta?.type, // default resolver
39
+ },
40
+ }
41
+ ```
42
+
43
+ ## Author a schema
44
+
45
+ ```js
46
+ // schemas/article.js
47
+ import { z } from 'zod'
48
+
49
+ export default z.object({
50
+ title: z.string().describe('Article title'),
51
+ summary: z.string().describe('Short summary shown in cards and meta tags'),
52
+ date: z.string(),
53
+ author: z.string().optional(),
54
+ tags: z.array(z.string()).optional(),
55
+ published: z.boolean().default(false),
56
+ }).describe('A long-form article')
57
+ ```
58
+
59
+ ## Reference it from a document
60
+
61
+ ```yaml
62
+ ---
63
+ schema: article
64
+ title: Q2 product launch
65
+ summary: A short summary of what shipped this quarter.
66
+ date: 2026-04-15
67
+ author: Mikser Team
68
+ tags: [product, launch]
69
+ published: true
70
+ ---
71
+
72
+ Article body in markdown…
73
+ ```
74
+
75
+ By default the plugin resolves the schema name from `meta.schema`, falling back to `meta.type`. So a document with `type: article` (and no explicit `meta.schema`) will validate against `schemas/article.js` automatically — exactly the way `meta.layout` auto-matches.
76
+
77
+ ## What gets validated
78
+
79
+ The `validateOn` config decides what subject is passed to the schema:
80
+
81
+ | `validateOn` | What gets `.safeParse(subject)`-ed |
82
+ |---|---|
83
+ | `'meta'` *(default)* | `entity.meta` — front-matter / structured fields. The common case. |
84
+ | `'entity'` | the entire entity object (id, name, type, meta, content, …). Use when the schema describes the whole entity. |
85
+ | `'content'` | `entity.content` — useful when content itself is structured (e.g. YAML in `.yml` documents). |
86
+
87
+ Validation runs in `onProcess`, after the front-matter / yaml / aml parsers have populated `entity.meta`. Failures are reported with field paths:
88
+
89
+ ```
90
+ Schema "article" failed for /documents/en/q2.md: title: Required; date: Invalid date
91
+ ```
92
+
93
+ In `strict: true` mode the build halts on the first failure. Default (`strict: false`) logs a warning and continues — useful during a migration when not every document is conformant yet.
94
+
95
+ ## What the catalog stores
96
+
97
+ Each schema becomes an entity with a JSON-serialisable description of its shape:
98
+
99
+ ```json
100
+ {
101
+ "id": "/schemas/article",
102
+ "type": "schema",
103
+ "name": "article",
104
+ "format": "js",
105
+ "meta": {
106
+ "description": "A long-form article",
107
+ "shape": {
108
+ "type": "object",
109
+ "fields": {
110
+ "title": { "type": "string", "description": "Article title" },
111
+ "summary": { "type": "string", "description": "Short summary shown in cards and meta tags" },
112
+ "date": { "type": "string" },
113
+ "author": { "type": "string", "optional": true },
114
+ "tags": { "type": "array", "items": { "type": "string" }, "optional": true },
115
+ "published": { "type": "boolean", "default": false }
116
+ }
117
+ }
118
+ }
119
+ }
120
+ ```
121
+
122
+ The runtime Zod object stays in process memory (exposed at `runtime.schemas` as a `Map` keyed by name). The catalog representation is *enough for discovery* — frontends, form builders, and AI tools can read field names, types, descriptions, and optional/default markers without re-importing the JS.
123
+
124
+ For richer JSON Schema output (full constraints, refinements, conditional schemas) plug in [`zod-to-json-schema`](https://www.npmjs.com/package/zod-to-json-schema) at consume time — it's not built in to keep this plugin zero-extra-dep-at-runtime.
125
+
126
+ ## Querying schemas via the api plugin
127
+
128
+ With `mikser-io` running `--server` and the api plugin configured:
129
+
130
+ ```bash
131
+ curl http://localhost:3001/api/public/entities?type=schema
132
+ ```
133
+
134
+ ```json
135
+ {
136
+ "items": [
137
+ {
138
+ "id": "/schemas/article",
139
+ "name": "article",
140
+ "type": "schema",
141
+ "meta": { "description": "...", "shape": { ... } }
142
+ }
143
+ ]
144
+ }
145
+ ```
146
+
147
+ A form builder reads this and renders fields automatically. An AI assistant reads it to understand what valid content for the site looks like.
148
+
149
+ ## Programmatic access
150
+
151
+ Other plugins (or one-shot scripts) can pull a schema and use it:
152
+
153
+ ```js
154
+ const { runtime } = await import('mikser-io')
155
+ // after start():
156
+ const article = runtime.schemas.get('article')
157
+ article.parse(payload) // throws on failure
158
+ article.safeParse(payload).success // boolean
159
+ ```
160
+
161
+ ## Watch mode
162
+
163
+ Schema files hot-reload on save in `--watch` mode. The next process cycle re-validates every affected document against the new shape.
164
+
165
+ ## Hooks used
166
+
167
+ | Hook | What it does |
168
+ |---|---|
169
+ | `onLoaded` | Resolves the schemas folder path |
170
+ | `onLoad` | Scans the folder, imports each `.js` / `.mjs` / `.cjs`, registers entity + runtime Zod object |
171
+ | `onProcess` | Iterates the journal, validates each CREATE / UPDATE against its declared schema |
172
+ | `onSync` | Hot-reloads schema files on filesystem change in watch mode |
173
+
174
+ ## Limitations (worth knowing up front)
175
+
176
+ - **`.refine()` / `.transform()` bodies aren't serialisable.** The catalog representation notes that an effect exists; the validation itself still runs because the runtime Zod object is the source of truth.
177
+ - **Cross-schema references work via JS imports.** A `product` schema can import an `address` schema with normal ESM; load order doesn't matter because JS resolves references at runtime.
178
+ - **Validation runs at `onProcess`.** This is after parsers populate `meta`, so default values from the front-matter / yaml plugin are visible. It is *before* render — so a render-time field that doesn't yet exist won't be checked.
179
+ - **Strict mode halts the build.** Use it for production builds; use the default (warn) during migrations.
180
+
181
+ ## License
182
+
183
+ MIT
package/index.js ADDED
@@ -0,0 +1,211 @@
1
+ // mikser-io-schemas
2
+ //
3
+ // Loads Zod schemas from a configurable folder, registers each as an
4
+ // entity (type: 'schema') in the mikser catalog, and validates other
5
+ // entities against their declared schema during onProcess.
6
+ //
7
+ // The runtime Zod objects live in process memory (exposed at
8
+ // runtime.schemas — a Map keyed by name). The catalog holds a
9
+ // JSON-serialisable shape description so frontends, form generators,
10
+ // and AI tools can introspect without re-importing the JS.
11
+ //
12
+ // The file scanning, loading, hot-reload, and catalog registration
13
+ // machinery is delegated to mikser-io's useSource helper. This plugin
14
+ // only carries the schema-specific concerns: Zod introspection and
15
+ // onProcess-time validation.
16
+
17
+ import { pathToFileURL } from 'node:url'
18
+
19
+ const DEFAULT_FOLDER = 'schemas'
20
+ const DEFAULT_EXTENSIONS = ['js', 'mjs', 'cjs']
21
+
22
+ export default (core) => {
23
+ const { runtime, useSource, onProcess, useLogger, useJournal, constants: { OPERATION } } = core
24
+
25
+ // Per-name registry of live Zod objects. Exposed on `runtime.schemas`
26
+ // so other plugins (an editor UI, the api plugin's POST handlers)
27
+ // can pull a schema and use it for ad-hoc validation.
28
+ const schemas = new Map()
29
+ runtime.schemas = schemas
30
+
31
+ useSource(core, {
32
+ collection: 'schemas',
33
+ type: 'schema',
34
+ folder: runtime.config?.schemas?.schemasFolder ?? DEFAULT_FOLDER,
35
+ extensions: runtime.config?.schemas?.extensions ?? DEFAULT_EXTENSIONS,
36
+ stripExtensionFromId: true, // /schemas/article rather than /schemas/article.js
37
+ load: async ({ file, name }) => {
38
+ // Cache-bust on reload so a changed file actually re-imports.
39
+ // Node's ESM loader caches by URL; appending a query string
40
+ // forces a fresh module evaluation.
41
+ const url = `${pathToFileURL(file).href}?t=${Date.now()}`
42
+ const mod = await import(url)
43
+ const schema = mod.default ?? mod.schema
44
+
45
+ if (!isZodSchema(schema)) {
46
+ useLogger().warn('Schema %s does not export a Zod schema as default; skipping', file)
47
+ return null
48
+ }
49
+
50
+ schemas.set(name, schema)
51
+ return {
52
+ meta: {
53
+ description: schema.description ?? undefined,
54
+ shape: introspect(schema),
55
+ },
56
+ }
57
+ },
58
+ })
59
+
60
+ onProcess(async (signal) => {
61
+ const logger = useLogger()
62
+ const config = runtime.config?.schemas ?? {}
63
+ const resolveName = config.match ?? defaultMatch
64
+ const strict = config.strict === true
65
+ const validateOn = config.validateOn ?? 'meta'
66
+
67
+ for await (const { entity } of useJournal(
68
+ 'Schema validation',
69
+ [OPERATION.CREATE, OPERATION.UPDATE],
70
+ signal,
71
+ )) {
72
+ if (entity.type === 'schema') continue // schemas don't validate themselves
73
+
74
+ const schemaName = resolveName(entity)
75
+ if (!schemaName) continue
76
+
77
+ const schema = schemas.get(schemaName)
78
+ if (!schema) {
79
+ logger.trace('Schema %s not found for %s', schemaName, entity.id)
80
+ continue
81
+ }
82
+
83
+ const subject = validateOn === 'entity'
84
+ ? entity
85
+ : validateOn === 'content'
86
+ ? entity.content
87
+ : entity.meta ?? {}
88
+
89
+ const result = schema.safeParse(subject)
90
+ if (result.success) {
91
+ logger.trace('Schema %s OK: %s', schemaName, entity.id)
92
+ continue
93
+ }
94
+
95
+ const issues = result.error.issues ?? result.error.errors ?? []
96
+ const message = issues
97
+ .map(i => `${(i.path ?? []).join('.') || '(root)'}: ${i.message}`)
98
+ .join('; ')
99
+ const summary = `Schema "${schemaName}" failed for ${entity.id}: ${message}`
100
+
101
+ if (strict) {
102
+ throw new Error(summary)
103
+ } else {
104
+ logger.warn(summary)
105
+ }
106
+ }
107
+ })
108
+ }
109
+
110
+ // --------------------------------------------------------------------
111
+ // Helpers
112
+ // --------------------------------------------------------------------
113
+
114
+ function defaultMatch(entity) {
115
+ return entity.meta?.schema ?? entity.meta?.type
116
+ }
117
+
118
+ function isZodSchema(value) {
119
+ return value
120
+ && typeof value === 'object'
121
+ && typeof value.safeParse === 'function'
122
+ && typeof value.parse === 'function'
123
+ }
124
+
125
+ // Normalise the Zod-version-specific type tag.
126
+ //
127
+ // Zod 3: _def.typeName = 'ZodObject', 'ZodString', ...
128
+ // Zod 4: _def.type = 'object', 'string', ...
129
+ //
130
+ // We collapse both to a lowercase, un-prefixed string so the switch
131
+ // below works on either version.
132
+ function zodKind(def) {
133
+ if (!def) return 'unknown'
134
+ if (typeof def.typeName === 'string') return def.typeName.replace(/^Zod/, '').toLowerCase()
135
+ if (typeof def.type === 'string') return def.type.toLowerCase()
136
+ return 'unknown'
137
+ }
138
+
139
+ // Walk a Zod schema and produce a JSON-serialisable shape description.
140
+ // Covers the common Zod types in v3 and v4; falls back to a generic
141
+ // { type: '...' } for anything else. Frontends and AI tools can read
142
+ // this directly; for richer output use a real Zod → JSON-Schema
143
+ // converter at consume time.
144
+ function introspect(schema) {
145
+ if (!schema || typeof schema !== 'object') return { type: 'unknown' }
146
+ const def = schema._def
147
+ if (!def) return { type: 'unknown' }
148
+
149
+ const description = def.description ?? schema.description
150
+ const wrap = (out) => description ? { ...out, description } : out
151
+
152
+ const kind = zodKind(def)
153
+
154
+ switch (kind) {
155
+ case 'object': {
156
+ const fields = {}
157
+ const shape =
158
+ typeof schema.shape === 'function' ? schema.shape() :
159
+ schema.shape ??
160
+ (typeof def.shape === 'function' ? def.shape() : def.shape)
161
+ for (const [key, fieldSchema] of Object.entries(shape ?? {})) {
162
+ fields[key] = introspect(fieldSchema)
163
+ }
164
+ return wrap({ type: 'object', fields })
165
+ }
166
+ case 'string': return wrap({ type: 'string' })
167
+ case 'number': return wrap({ type: 'number' })
168
+ case 'boolean': return wrap({ type: 'boolean' })
169
+ case 'date': return wrap({ type: 'date' })
170
+ case 'bigint': return wrap({ type: 'bigint' })
171
+ case 'literal': return wrap({ type: 'literal', value: def.value })
172
+ case 'enum':
173
+ case 'nativeenum': {
174
+ const raw = def.values ?? def.entries ?? {}
175
+ const values = Array.isArray(raw) ? raw : Object.values(raw)
176
+ return wrap({ type: 'enum', values })
177
+ }
178
+ case 'array': {
179
+ const items = introspect(def.element ?? def.type)
180
+ return wrap({ type: 'array', items })
181
+ }
182
+ case 'tuple':
183
+ return wrap({ type: 'tuple', items: (def.items ?? []).map(introspect) })
184
+ case 'union':
185
+ case 'discriminatedunion':
186
+ return wrap({ type: 'union', options: (def.options ?? []).map(introspect) })
187
+ case 'record':
188
+ return wrap({ type: 'record', values: introspect(def.valueType ?? def.value) })
189
+ case 'optional':
190
+ return { ...introspect(def.innerType), optional: true }
191
+ case 'nullable':
192
+ return { ...introspect(def.innerType), nullable: true }
193
+ case 'default': {
194
+ // v3: defaultValue is a thunk returning the default.
195
+ // v4: defaultValue is the value itself.
196
+ const def_ = typeof def.defaultValue === 'function' ? def.defaultValue() : def.defaultValue
197
+ return { ...introspect(def.innerType), default: def_ }
198
+ }
199
+ case 'any': return wrap({ type: 'any' })
200
+ case 'unknown': return wrap({ type: 'unknown' })
201
+ case 'null': return wrap({ type: 'null' })
202
+ case 'effects':
203
+ case 'pipe':
204
+ case 'pipeline': {
205
+ const inner = introspect(def.schema ?? def.in ?? def.innerType)
206
+ return { ...inner, effect: def.effect?.type ?? kind }
207
+ }
208
+ default:
209
+ return wrap({ type: kind })
210
+ }
211
+ }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "mikser-io-schemas",
3
+ "version": "1.1.0",
4
+ "description": "Zod schemas as entities for mikser-io — load from a folder, register in the catalog, validate documents in the lifecycle",
5
+ "main": "index.js",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": {
9
+ "default": "./index.js"
10
+ }
11
+ },
12
+ "files": [
13
+ "index.js",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "scripts": {},
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/almero-digital-marketing/mikser-io-schemas.git"
21
+ },
22
+ "author": "",
23
+ "license": "MIT",
24
+ "bugs": {
25
+ "url": "https://github.com/almero-digital-marketing/mikser-io-schemas/issues"
26
+ },
27
+ "homepage": "https://github.com/almero-digital-marketing/mikser-io-schemas#readme",
28
+ "keywords": [
29
+ "mikser",
30
+ "mikser-io",
31
+ "schemas",
32
+ "zod",
33
+ "validation"
34
+ ],
35
+ "peerDependencies": {
36
+ "mikser-io": "^6.24.0",
37
+ "zod": "^3.0.0 || ^4.0.0"
38
+ },
39
+ "engines": {
40
+ "node": ">=18"
41
+ }
42
+ }