@natilon/astro-cms 0.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.
- package/README.md +70 -0
- package/package.json +32 -0
- package/src/content.mjs +60 -0
- package/src/index.mjs +70 -0
- package/src/loader.mjs +140 -0
- package/src/schema.mjs +82 -0
package/README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# @natilon/astro-cms
|
|
2
|
+
|
|
3
|
+
Astro integration that mounts [`@natilon/cms-server`](../cms-server) +
|
|
4
|
+
[`@natilon/admin-ui`](../admin-ui) into `astro dev`, so the site and
|
|
5
|
+
its CMS run from a single command on a single origin.
|
|
6
|
+
|
|
7
|
+
In production (`astro build`), this integration does nothing — the CMS
|
|
8
|
+
is a separate Node process you deploy alongside (or behind) the static
|
|
9
|
+
site. Use `@natilon/cms-server`'s `startCmsServer` for that.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npm i @natilon/astro-cms @natilon/cms-server @natilon/admin-ui
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```js
|
|
20
|
+
// astro.config.mjs
|
|
21
|
+
import { defineConfig } from "astro/config";
|
|
22
|
+
import natilonCms from "@natilon/astro-cms";
|
|
23
|
+
import cmsConfig, { publicConfig } from "./cms.config.mjs";
|
|
24
|
+
import path from "path";
|
|
25
|
+
import { fileURLToPath } from "url";
|
|
26
|
+
|
|
27
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
|
|
29
|
+
export default defineConfig({
|
|
30
|
+
integrations: [
|
|
31
|
+
natilonCms({
|
|
32
|
+
config: cmsConfig,
|
|
33
|
+
publicConfig,
|
|
34
|
+
rootDir: __dirname,
|
|
35
|
+
adminUiSourceDir: path.join(__dirname, "node_modules/@natilon/admin-ui"),
|
|
36
|
+
realm: "My Site Admin",
|
|
37
|
+
}),
|
|
38
|
+
],
|
|
39
|
+
});
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Now `npm run dev` boots Astro's Vite server with the admin mounted at
|
|
43
|
+
`/admin`. No second process needed for local development.
|
|
44
|
+
|
|
45
|
+
## Content helpers
|
|
46
|
+
|
|
47
|
+
```js
|
|
48
|
+
import { listEntries, getEntry } from "@natilon/astro-cms/content";
|
|
49
|
+
|
|
50
|
+
// In a .astro file:
|
|
51
|
+
const posts = await listEntries({
|
|
52
|
+
rootDir: process.cwd(),
|
|
53
|
+
pagesDir: "src/pages-data",
|
|
54
|
+
collection: "blog",
|
|
55
|
+
locales: ["en", "tr"],
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
These read the same JSON files the CMS writes. Use them from
|
|
60
|
+
`getStaticPaths()` to drive Astro routes.
|
|
61
|
+
|
|
62
|
+
## Options
|
|
63
|
+
|
|
64
|
+
| Option | Type | Default |
|
|
65
|
+
| ------------------- | -------------------------- | -------------------------------------- |
|
|
66
|
+
| `config` | `Object` (required) | your `cms.config` object |
|
|
67
|
+
| `publicConfig` | `() => Object` | sanitizer for `/api/config` |
|
|
68
|
+
| `rootDir` | `string` | Astro's `config.root` |
|
|
69
|
+
| `adminUiSourceDir` | `string` | path to `@natilon/admin-ui` package |
|
|
70
|
+
| `realm` | `string` | HTTP basic-auth realm |
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@natilon/astro-cms",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Astro integration that mounts the Natilon CMS admin under /admin during `astro dev` and exposes content helpers.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/natilon/cms.git",
|
|
9
|
+
"directory": "packages/astro-cms"
|
|
10
|
+
},
|
|
11
|
+
"keywords": ["astro", "astro-integration", "cms", "natilon"],
|
|
12
|
+
"type": "module",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./src/index.mjs",
|
|
15
|
+
"./content": "./src/content.mjs",
|
|
16
|
+
"./loader": "./src/loader.mjs",
|
|
17
|
+
"./schema": "./src/schema.mjs"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"src"
|
|
21
|
+
],
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"@natilon/cms-server": "*",
|
|
24
|
+
"astro": "^4.0.0 || ^5.0.0",
|
|
25
|
+
"zod": "^3.0.0"
|
|
26
|
+
},
|
|
27
|
+
"peerDependenciesMeta": {
|
|
28
|
+
"zod": {
|
|
29
|
+
"optional": true
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/content.mjs
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Read all JSON pages of a collection from disk.
|
|
6
|
+
* Filenames are decoded into entry objects: { slug, locale, data }.
|
|
7
|
+
*
|
|
8
|
+
* Files are expected to follow the pattern:
|
|
9
|
+
* {locale}-{slug}.json e.g. en-about.json
|
|
10
|
+
* Files without a recognized locale prefix are returned with locale=null.
|
|
11
|
+
*
|
|
12
|
+
* @param {Object} opts
|
|
13
|
+
* @param {string} opts.rootDir absolute project root
|
|
14
|
+
* @param {string} opts.pagesDir relative path to pages dir (e.g. "src/pages-data")
|
|
15
|
+
* @param {string} opts.collection collection name (subdirectory)
|
|
16
|
+
* @param {string[]} [opts.locales] recognized locale prefixes (default ["en"])
|
|
17
|
+
*/
|
|
18
|
+
export async function listEntries({ rootDir, pagesDir, collection, locales = ["en"] }) {
|
|
19
|
+
const dir = path.join(rootDir, pagesDir, collection);
|
|
20
|
+
let files = [];
|
|
21
|
+
try {
|
|
22
|
+
files = (await fs.readdir(dir)).filter((f) => f.endsWith(".json"));
|
|
23
|
+
} catch {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
const localeRe = new RegExp(`^(${locales.join("|")})-(.+)\\.json$`);
|
|
27
|
+
const entries = await Promise.all(
|
|
28
|
+
files.map(async (file) => {
|
|
29
|
+
const raw = await fs.readFile(path.join(dir, file), "utf8");
|
|
30
|
+
const data = JSON.parse(raw);
|
|
31
|
+
const m = file.match(localeRe);
|
|
32
|
+
return {
|
|
33
|
+
file,
|
|
34
|
+
slug: m ? m[2] : file.replace(/\.json$/, ""),
|
|
35
|
+
locale: m ? m[1] : null,
|
|
36
|
+
data,
|
|
37
|
+
};
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
return entries;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Read a single JSON entry. Returns null if not found.
|
|
45
|
+
*
|
|
46
|
+
* @param {Object} opts
|
|
47
|
+
* @param {string} opts.rootDir
|
|
48
|
+
* @param {string} opts.pagesDir
|
|
49
|
+
* @param {string} opts.collection
|
|
50
|
+
* @param {string} opts.file bare filename, e.g. "en-about.json"
|
|
51
|
+
*/
|
|
52
|
+
export async function getEntry({ rootDir, pagesDir, collection, file }) {
|
|
53
|
+
try {
|
|
54
|
+
const full = path.join(rootDir, pagesDir, collection, file);
|
|
55
|
+
const raw = await fs.readFile(full, "utf8");
|
|
56
|
+
return JSON.parse(raw);
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { fileURLToPath } from "url";
|
|
2
|
+
import { createCmsServer, mountAdminUi } from "@natilon/cms-server";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Astro integration for the Natilon CMS.
|
|
6
|
+
*
|
|
7
|
+
* In `astro dev`, mounts the CMS Express app + admin UI as middleware on
|
|
8
|
+
* Astro's Vite server, so the site and `/admin` are served from one origin.
|
|
9
|
+
* In `astro build`, does nothing — the CMS is a separate process in prod.
|
|
10
|
+
*
|
|
11
|
+
* @param {Object} opts
|
|
12
|
+
* @param {Object} opts.config cms.config object (imported by the consumer)
|
|
13
|
+
* @param {() => Object} [opts.publicConfig] returns sanitized config for the browser
|
|
14
|
+
* @param {string} [opts.rootDir] absolute project root (defaults to Astro's `config.root`)
|
|
15
|
+
* @param {string} [opts.adminUiSourceDir] absolute path to the admin-ui package (for vite-dev)
|
|
16
|
+
* @param {string} [opts.realm] HTTP basic-auth realm
|
|
17
|
+
* @returns {import("astro").AstroIntegration}
|
|
18
|
+
*/
|
|
19
|
+
export default function natilonCms(opts = {}) {
|
|
20
|
+
return {
|
|
21
|
+
name: "@natilon/astro-cms",
|
|
22
|
+
hooks: {
|
|
23
|
+
"astro:server:setup": async ({ server, logger }) => {
|
|
24
|
+
// server is Astro's underlying Vite dev server.
|
|
25
|
+
const rootDir = (() => {
|
|
26
|
+
if (opts.rootDir) return opts.rootDir;
|
|
27
|
+
const r = server.config?.root;
|
|
28
|
+
if (!r) return process.cwd();
|
|
29
|
+
// Vite 5+ gives an absolute path; Vite 4 gave a file:// URL.
|
|
30
|
+
if (r.startsWith("file://")) return fileURLToPath(new URL(".", r));
|
|
31
|
+
return r;
|
|
32
|
+
})();
|
|
33
|
+
|
|
34
|
+
const { app } = createCmsServer({
|
|
35
|
+
config: opts.config,
|
|
36
|
+
publicConfig: opts.publicConfig,
|
|
37
|
+
rootDir,
|
|
38
|
+
realm: opts.realm,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
if (opts.adminUiSourceDir) {
|
|
42
|
+
await mountAdminUi(app, {
|
|
43
|
+
mode: "vite-dev",
|
|
44
|
+
root: opts.adminUiSourceDir,
|
|
45
|
+
base: "/admin/",
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Scope the CMS Express app to /admin and /api/* — without this,
|
|
50
|
+
// Astro's own paths (`/src/assets/*`, etc.) would be caught by the
|
|
51
|
+
// basic-auth middleware or the local-media route.
|
|
52
|
+
server.middlewares.use((req, res, next) => {
|
|
53
|
+
const url = req.url || "";
|
|
54
|
+
if (
|
|
55
|
+
url === "/admin" ||
|
|
56
|
+
url.startsWith("/admin/") ||
|
|
57
|
+
url.startsWith("/admin?") ||
|
|
58
|
+
url.startsWith("/api/")
|
|
59
|
+
) {
|
|
60
|
+
return app(req, res, next);
|
|
61
|
+
}
|
|
62
|
+
return next();
|
|
63
|
+
});
|
|
64
|
+
logger.info("Natilon CMS mounted at /admin");
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export { createCmsServer, mountAdminUi };
|
package/src/loader.mjs
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Astro Content Layer loader for Natilon CMS JSON files.
|
|
6
|
+
*
|
|
7
|
+
* Usage in src/content.config.ts:
|
|
8
|
+
*
|
|
9
|
+
* import { jsonContentLoader } from '@natilon/astro-cms/loader';
|
|
10
|
+
*
|
|
11
|
+
* export const collections = {
|
|
12
|
+
* blog: defineCollection({
|
|
13
|
+
* loader: jsonContentLoader('blog'),
|
|
14
|
+
* schema: z.object({ title: z.string(), ... }),
|
|
15
|
+
* }),
|
|
16
|
+
* };
|
|
17
|
+
*
|
|
18
|
+
* Files are expected at: src/pages-data/{collection}/{locale}-{slug}.json
|
|
19
|
+
* Override the base directory via the second argument:
|
|
20
|
+
* jsonContentLoader('blog', { pagesDir: 'content/pages' })
|
|
21
|
+
*
|
|
22
|
+
* @param {string} collection Collection name (subdirectory name)
|
|
23
|
+
* @param {Object} [opts]
|
|
24
|
+
* @param {string} [opts.pagesDir="src/pages-data"] Relative to project root
|
|
25
|
+
*/
|
|
26
|
+
export function jsonContentLoader(collection, { pagesDir = "src/pages-data" } = {}) {
|
|
27
|
+
return {
|
|
28
|
+
name: `natilon-cms-${collection}`,
|
|
29
|
+
|
|
30
|
+
async load({ store, parseData, logger, watcher }) {
|
|
31
|
+
const dir = path.join(process.cwd(), pagesDir, collection);
|
|
32
|
+
|
|
33
|
+
if (!fs.existsSync(dir)) {
|
|
34
|
+
logger.warn(`[natilon-cms] ${dir} does not exist — skipping collection "${collection}"`);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
|
|
39
|
+
store.clear();
|
|
40
|
+
|
|
41
|
+
for (const file of files) {
|
|
42
|
+
await loadFile(path.join(dir, file), store, parseData, logger, collection);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!watcher) return;
|
|
46
|
+
|
|
47
|
+
watcher.add(dir);
|
|
48
|
+
watcher.on("add", async (fp) => {
|
|
49
|
+
if (fp.startsWith(dir) && fp.endsWith(".json"))
|
|
50
|
+
await loadFile(fp, store, parseData, logger, collection);
|
|
51
|
+
});
|
|
52
|
+
watcher.on("change", async (fp) => {
|
|
53
|
+
if (fp.startsWith(dir) && fp.endsWith(".json"))
|
|
54
|
+
await loadFile(fp, store, parseData, logger, collection);
|
|
55
|
+
});
|
|
56
|
+
watcher.on("unlink", (fp) => {
|
|
57
|
+
if (fp.startsWith(dir) && fp.endsWith(".json")) {
|
|
58
|
+
try {
|
|
59
|
+
const raw = JSON.parse(fs.readFileSync(fp, "utf-8"));
|
|
60
|
+
store.delete(raw.id || raw.slug);
|
|
61
|
+
} catch {}
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function loadFile(filePath, store, parseData, logger, collection) {
|
|
69
|
+
const file = path.basename(filePath);
|
|
70
|
+
try {
|
|
71
|
+
const raw = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
72
|
+
const data = {
|
|
73
|
+
...raw.meta,
|
|
74
|
+
// Core fields always present at the top level
|
|
75
|
+
id: raw.id,
|
|
76
|
+
slug: raw.slug,
|
|
77
|
+
lang: raw.lang,
|
|
78
|
+
collection: raw.collection,
|
|
79
|
+
draft: raw.meta?.draft ?? false,
|
|
80
|
+
publishAt: raw.meta?.publishAt,
|
|
81
|
+
// Normalise JSON-stored arrays (stored as strings in some older entries)
|
|
82
|
+
categorySlugs: normaliseArray(raw.meta?.categorySlugs),
|
|
83
|
+
categoryNames: normaliseArray(raw.meta?.categoryNames),
|
|
84
|
+
tagSlugs: normaliseArray(raw.meta?.tagSlugs),
|
|
85
|
+
tagNames: normaliseArray(raw.meta?.tagNames),
|
|
86
|
+
aliases: normaliseArray(raw.meta?.aliases),
|
|
87
|
+
// Blocks
|
|
88
|
+
blocks: raw.blocks || [],
|
|
89
|
+
};
|
|
90
|
+
const parsed = await parseData({ id: raw.id || raw.slug, data });
|
|
91
|
+
store.set({
|
|
92
|
+
id: raw.id || raw.slug,
|
|
93
|
+
data: parsed,
|
|
94
|
+
body: extractHtml(raw.blocks || []),
|
|
95
|
+
rendered: { html: extractHtml(raw.blocks || []) },
|
|
96
|
+
});
|
|
97
|
+
} catch (err) {
|
|
98
|
+
logger.error(`[natilon-cms] ${collection}/${file}: ${err.message}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function normaliseArray(val) {
|
|
103
|
+
if (Array.isArray(val)) return val;
|
|
104
|
+
if (typeof val === "string") {
|
|
105
|
+
try {
|
|
106
|
+
const p = JSON.parse(val);
|
|
107
|
+
return Array.isArray(p) ? p : [];
|
|
108
|
+
} catch {
|
|
109
|
+
return val ? [val] : [];
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function extractHtml(blocks) {
|
|
116
|
+
return blocks
|
|
117
|
+
.flatMap((b) => {
|
|
118
|
+
const p = b.properties || {};
|
|
119
|
+
switch (b.type) {
|
|
120
|
+
case "html":
|
|
121
|
+
case "text": return [String(p.content ?? "")];
|
|
122
|
+
case "heading": {
|
|
123
|
+
const t = `h${Math.min(Math.max(Number(p.level ?? 2), 1), 6)}`;
|
|
124
|
+
return [`<${t}>${p.text ?? ""}</${t}>`];
|
|
125
|
+
}
|
|
126
|
+
case "image":
|
|
127
|
+
return p.src ? [`<figure><img src="${p.src}" alt="${p.alt ?? ""}" /></figure>`] : [];
|
|
128
|
+
case "blockquote": return [`<blockquote>${p.content ?? ""}</blockquote>`];
|
|
129
|
+
case "divider": return ["<hr />"];
|
|
130
|
+
case "list": {
|
|
131
|
+
let items = [];
|
|
132
|
+
try { items = JSON.parse(p.items || "[]"); } catch {}
|
|
133
|
+
const tag = p.ordered ? "ol" : "ul";
|
|
134
|
+
return [`<${tag}>${items.map((i) => `<li>${i}</li>`).join("")}</${tag}>`];
|
|
135
|
+
}
|
|
136
|
+
default: return [];
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
.join("\n");
|
|
140
|
+
}
|
package/src/schema.mjs
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a Zod schema from a CMS collection config.
|
|
3
|
+
*
|
|
4
|
+
* IMPORTANT: Pass Astro's own `z` instance so schemas are compatible with
|
|
5
|
+
* Astro's content layer. Do NOT import z from "zod" directly in this file.
|
|
6
|
+
*
|
|
7
|
+
* Usage in src/content.config.ts:
|
|
8
|
+
*
|
|
9
|
+
* import { z } from 'astro:content';
|
|
10
|
+
* import { buildCollectionSchema } from '@natilon/astro-cms/schema';
|
|
11
|
+
* import config from '../cms.config.mjs';
|
|
12
|
+
*
|
|
13
|
+
* export const collections = {
|
|
14
|
+
* blog: defineCollection({
|
|
15
|
+
* loader: jsonContentLoader('blog'),
|
|
16
|
+
* schema: buildCollectionSchema(config.collections.blog, { z }),
|
|
17
|
+
* }),
|
|
18
|
+
* };
|
|
19
|
+
*
|
|
20
|
+
* Override individual fields with .extend():
|
|
21
|
+
*
|
|
22
|
+
* schema: buildCollectionSchema(config.collections.blog, { z }).extend({
|
|
23
|
+
* pubDate: z.coerce.date(), // make required instead of optional
|
|
24
|
+
* }),
|
|
25
|
+
*
|
|
26
|
+
* @param {Object} collectionConfig A single entry from config.collections
|
|
27
|
+
* @param {{ z: import('zod').ZodStatic }} opts Zod instance from 'astro:content'
|
|
28
|
+
* @returns {import('zod').ZodObject<any>}
|
|
29
|
+
*/
|
|
30
|
+
export function buildCollectionSchema(collectionConfig, { z }) {
|
|
31
|
+
const typeMap = {
|
|
32
|
+
text: () => z.string().optional(),
|
|
33
|
+
textarea: () => z.string().optional(),
|
|
34
|
+
richtext: () => z.string().optional(),
|
|
35
|
+
code: () => z.string().optional(),
|
|
36
|
+
number: () => z.number().optional(),
|
|
37
|
+
boolean: () => z.boolean().optional().default(false),
|
|
38
|
+
date: () => z.coerce.date().optional(),
|
|
39
|
+
select: (f) => f.options?.length ? z.enum(f.options).optional() : z.string().optional(),
|
|
40
|
+
image: () => z.string().optional(),
|
|
41
|
+
"meta-image": () => z.string().nullable().optional(),
|
|
42
|
+
json: () => z.unknown().optional(),
|
|
43
|
+
"collection-ref": () => z.string().optional(),
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const fields = {
|
|
47
|
+
// Core fields
|
|
48
|
+
title: z.string(),
|
|
49
|
+
slug: z.string(),
|
|
50
|
+
lang: z.string().optional(),
|
|
51
|
+
collection: z.string().optional(),
|
|
52
|
+
// Status
|
|
53
|
+
draft: z.boolean().optional().default(false),
|
|
54
|
+
publishAt: z.string().optional(),
|
|
55
|
+
// Common meta
|
|
56
|
+
description: z.string().optional(),
|
|
57
|
+
seoTitle: z.string().optional(),
|
|
58
|
+
seoDescription: z.string().optional(),
|
|
59
|
+
// Standard array fields (stored as JSON strings in older entries — loader normalises)
|
|
60
|
+
categorySlugs: z.array(z.string()).optional().default([]),
|
|
61
|
+
categoryNames: z.array(z.string()).optional().default([]),
|
|
62
|
+
tagSlugs: z.array(z.string()).optional().default([]),
|
|
63
|
+
tagNames: z.array(z.string()).optional().default([]),
|
|
64
|
+
aliases: z.array(z.string()).optional().default([]),
|
|
65
|
+
// Blocks
|
|
66
|
+
blocks: z.array(
|
|
67
|
+
z.object({
|
|
68
|
+
id: z.string(),
|
|
69
|
+
type: z.string(),
|
|
70
|
+
properties: z.record(z.unknown()),
|
|
71
|
+
children: z.array(z.array(z.unknown())).optional(),
|
|
72
|
+
}),
|
|
73
|
+
).optional().default([]),
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
for (const field of collectionConfig?.metaFields ?? []) {
|
|
77
|
+
const builder = typeMap[field.type] ?? (() => z.string().optional());
|
|
78
|
+
fields[field.key] = builder(field);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return z.object(fields);
|
|
82
|
+
}
|