@natilon/astro-cms 0.7.0 → 0.9.1
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 +172 -47
- package/package.json +1 -1
- package/src/index.mjs +71 -0
- package/src/loader.mjs +10 -4
package/README.md
CHANGED
|
@@ -1,70 +1,195 @@
|
|
|
1
1
|
# @natilon/astro-cms
|
|
2
2
|
|
|
3
|
-
Astro integration
|
|
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.
|
|
3
|
+
Astro integration for [Natilon CMS](../../README.md). Mounts the CMS admin panel into `astro dev`, auto-generates content collection config, and exports the JSON loader + schema builder your site needs.
|
|
10
4
|
|
|
11
5
|
## Install
|
|
12
6
|
|
|
13
7
|
```sh
|
|
14
|
-
npm i @natilon/astro-cms @natilon/cms-server
|
|
8
|
+
npm i @natilon/astro-cms @natilon/cms-server
|
|
15
9
|
```
|
|
16
10
|
|
|
17
|
-
##
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
### 1. Create `cms.config.mjs`
|
|
18
14
|
|
|
19
15
|
```js
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
16
|
+
export default {
|
|
17
|
+
mountPath: "/admin",
|
|
18
|
+
locales: ["en"],
|
|
19
|
+
defaultLocale: "en",
|
|
20
|
+
|
|
21
|
+
collections: {
|
|
22
|
+
blog: {
|
|
23
|
+
label: "Blog Posts",
|
|
24
|
+
listFields: ["title", "pubDate"],
|
|
25
|
+
metaFields: [
|
|
26
|
+
{ key: "title", type: "text", label: "Title", required: true },
|
|
27
|
+
{ key: "slug", type: "text", label: "Slug", required: true },
|
|
28
|
+
{ key: "pubDate", type: "datetime", label: "Published" },
|
|
29
|
+
{ key: "blocks", type: "blocks", label: "Content" },
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
content: {
|
|
35
|
+
pagesDir: "src/pages-data",
|
|
36
|
+
publishBranch: "main",
|
|
37
|
+
commitMessage: (ts) => `Content updated ${ts}`,
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
media: { provider: "local" },
|
|
41
|
+
|
|
42
|
+
auth: {
|
|
43
|
+
provider: "basic",
|
|
44
|
+
userEnv: "ADMIN_USER",
|
|
45
|
+
passEnv: "ADMIN_PASS",
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### 2. Mount in `astro.config.mjs`
|
|
26
51
|
|
|
27
|
-
|
|
52
|
+
```js
|
|
53
|
+
import { defineConfig } from "astro/config";
|
|
54
|
+
import natilon from "@natilon/astro-cms";
|
|
55
|
+
import config from "./cms.config.mjs";
|
|
28
56
|
|
|
29
57
|
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
|
-
],
|
|
58
|
+
integrations: [natilon({ config })],
|
|
39
59
|
});
|
|
40
60
|
```
|
|
41
61
|
|
|
42
|
-
|
|
43
|
-
`/admin`. No second process needed for local development.
|
|
62
|
+
### 3. Run `astro dev` — `src/content.config.ts` is generated automatically
|
|
44
63
|
|
|
45
|
-
|
|
64
|
+
```
|
|
65
|
+
✔ Generated src/content.config.ts — covers blog. Edit freely.
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The generated file covers every collection in `cms.config.mjs`. You can commit it, and adding a new collection to `cms.config.mjs` requires no changes to it. Delete it to regenerate.
|
|
69
|
+
|
|
70
|
+
### 4. Add env vars (`.env`)
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
ADMIN_USER=admin
|
|
74
|
+
ADMIN_PASS=secret
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### 5. Render blocks in your pages
|
|
78
|
+
|
|
79
|
+
```astro
|
|
80
|
+
---
|
|
81
|
+
import BlockRenderer from "@natilon/astro-blocks";
|
|
82
|
+
const { entry } = Astro.props;
|
|
83
|
+
---
|
|
84
|
+
<BlockRenderer blocks={entry.data.blocks} />
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
See [`@natilon/astro-blocks`](../astro-blocks/README.md) for the full block reference.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## `cms.config.mjs` reference
|
|
92
|
+
|
|
93
|
+
### Collection options
|
|
46
94
|
|
|
47
95
|
```js
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
});
|
|
96
|
+
collections: {
|
|
97
|
+
blog: {
|
|
98
|
+
label: "Blog Posts", // displayed in sidebar
|
|
99
|
+
listFields: ["title", "slug"], // columns shown in entry list
|
|
100
|
+
metaFields: [ ... ], // editable fields
|
|
101
|
+
defaultValues: { draft: true },
|
|
102
|
+
},
|
|
103
|
+
}
|
|
57
104
|
```
|
|
58
105
|
|
|
59
|
-
|
|
60
|
-
|
|
106
|
+
### `metaFields` field types
|
|
107
|
+
|
|
108
|
+
| `type` | Editor control | Zod type (auto) |
|
|
109
|
+
|------------------|---------------------------------------|------------------------------|
|
|
110
|
+
| `text` | Single-line input | `z.string().optional()` |
|
|
111
|
+
| `textarea` | Multi-line input | `z.string().optional()` |
|
|
112
|
+
| `richtext` | Rich-text editor (HTML output) | `z.string().optional()` |
|
|
113
|
+
| `number` | Numeric input | `z.number().optional()` |
|
|
114
|
+
| `boolean` | Toggle | `z.boolean().optional()` |
|
|
115
|
+
| `date` | Date picker | `z.coerce.date().optional()` |
|
|
116
|
+
| `datetime` | Date + time picker | `z.coerce.date().optional()` |
|
|
117
|
+
| `select` | Dropdown (`options: [...]` required) | `z.enum([...]).optional()` |
|
|
118
|
+
| `image` | Media picker (CDN or local) | `z.string().optional()` |
|
|
119
|
+
| `meta-image` | OG/social image picker | `z.string().nullable().optional()` |
|
|
120
|
+
| `json` | Raw JSON textarea | `z.unknown().optional()` |
|
|
121
|
+
| `collection-ref` | Entry picker from another collection | `z.string().optional()` |
|
|
122
|
+
| `blocks` | Block content editor (see astro-blocks)| auto-included always |
|
|
123
|
+
| `code` | Code editor | `z.string().optional()` |
|
|
124
|
+
|
|
125
|
+
**Options:**
|
|
126
|
+
|
|
127
|
+
```js
|
|
128
|
+
{ key: "status", type: "select", label: "Status", options: ["draft", "published"], required: true }
|
|
129
|
+
{ key: "cover", type: "image", label: "Cover image" }
|
|
130
|
+
{ key: "source", type: "collection-ref", label: "Author", collection: "authors" }
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`required: true` shows a red `*` in the editor and blocks saving if the field is empty.
|
|
134
|
+
|
|
135
|
+
### Custom block types
|
|
136
|
+
|
|
137
|
+
```js
|
|
138
|
+
// cms.config.mjs — optional, extends the built-in block palette
|
|
139
|
+
blocks: {
|
|
140
|
+
hero: {
|
|
141
|
+
label: "Hero",
|
|
142
|
+
icon: "fa-star",
|
|
143
|
+
properties: {
|
|
144
|
+
heading: { type: "text", label: "Heading", required: true },
|
|
145
|
+
image: { type: "image", label: "Background image" },
|
|
146
|
+
cta: { type: "text", label: "Button text" },
|
|
147
|
+
ctaHref: { type: "text", label: "Button URL" },
|
|
148
|
+
},
|
|
149
|
+
defaults: { heading: "Welcome" },
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## Content helpers
|
|
157
|
+
|
|
158
|
+
`jsonContentLoader` and `buildCollectionSchema` are re-exported from the main entry:
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
import { jsonContentLoader, buildCollectionSchema } from "@natilon/astro-cms";
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### `jsonContentLoader(collection, opts?)`
|
|
165
|
+
|
|
166
|
+
Astro Content Layer loader. Reads `src/pages-data/{collection}/*.json` (or `opts.pagesDir`).
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
loader: jsonContentLoader("blog")
|
|
170
|
+
loader: jsonContentLoader("blog", { pagesDir: "content/pages" })
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### `buildCollectionSchema(collectionConfig, { z })`
|
|
174
|
+
|
|
175
|
+
Generates a Zod schema from a collection's `metaFields`. Always includes `slug`, `lang`, `draft`, `publishAt`, `blocks`, and standard taxonomy arrays.
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
// Extend to tighten types:
|
|
179
|
+
schema: buildCollectionSchema(config.collections.blog, { z }).extend({
|
|
180
|
+
pubDate: z.coerce.date(), // make date required (not optional)
|
|
181
|
+
}),
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## Integration options
|
|
61
187
|
|
|
62
|
-
|
|
188
|
+
| Option | Type | Default |
|
|
189
|
+
|--------------------|------------|---------------------------------|
|
|
190
|
+
| `config` | `Object` | **required** — your cms.config |
|
|
191
|
+
| `publicConfig` | `Function` | auto-derived (strips secrets) |
|
|
192
|
+
| `rootDir` | `string` | Astro's `config.root` |
|
|
193
|
+
| `adminUiSourceDir` | `string` | path to admin-ui for HMR dev |
|
|
194
|
+
| `realm` | `string` | HTTP Basic auth realm |
|
|
63
195
|
|
|
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
CHANGED
package/src/index.mjs
CHANGED
|
@@ -1,6 +1,35 @@
|
|
|
1
1
|
import { fileURLToPath } from "url";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
2
4
|
import { createCmsServer, mountAdminUi } from "@natilon/cms-server";
|
|
3
5
|
|
|
6
|
+
export { jsonContentLoader } from "./loader.mjs";
|
|
7
|
+
export { buildCollectionSchema } from "./schema.mjs";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Generate the content of src/content.config.ts for a new project.
|
|
11
|
+
* The generated file is safe to commit and customise.
|
|
12
|
+
*/
|
|
13
|
+
function generateContentConfig() {
|
|
14
|
+
return `\
|
|
15
|
+
// Auto-generated by @natilon/astro-cms — safe to commit and customize.
|
|
16
|
+
// Delete this file and restart dev to regenerate from cms.config.mjs.
|
|
17
|
+
import { defineCollection, z } from "astro:content";
|
|
18
|
+
import { jsonContentLoader, buildCollectionSchema } from "@natilon/astro-cms";
|
|
19
|
+
import config from "../cms.config.mjs";
|
|
20
|
+
|
|
21
|
+
export const collections = Object.fromEntries(
|
|
22
|
+
Object.entries(config.collections).map(([name, col]) => [
|
|
23
|
+
name,
|
|
24
|
+
defineCollection({
|
|
25
|
+
loader: jsonContentLoader(name),
|
|
26
|
+
schema: buildCollectionSchema(col, { z }),
|
|
27
|
+
}),
|
|
28
|
+
])
|
|
29
|
+
);
|
|
30
|
+
`;
|
|
31
|
+
}
|
|
32
|
+
|
|
4
33
|
/**
|
|
5
34
|
* Astro integration for the Natilon CMS.
|
|
6
35
|
*
|
|
@@ -8,6 +37,9 @@ import { createCmsServer, mountAdminUi } from "@natilon/cms-server";
|
|
|
8
37
|
* Astro's Vite server, so the site and `/admin` are served from one origin.
|
|
9
38
|
* In `astro build`, does nothing — the CMS is a separate process in prod.
|
|
10
39
|
*
|
|
40
|
+
* Auto-generates `src/content.config.ts` from `cms.config.mjs` if the file
|
|
41
|
+
* does not already exist, so new projects skip the manual wiring step.
|
|
42
|
+
*
|
|
11
43
|
* @param {Object} opts
|
|
12
44
|
* @param {Object} opts.config cms.config object (imported by the consumer)
|
|
13
45
|
* @param {() => Object} [opts.publicConfig] returns sanitized config for the browser
|
|
@@ -20,6 +52,44 @@ export default function natilonCms(opts = {}) {
|
|
|
20
52
|
return {
|
|
21
53
|
name: "@natilon/astro-cms",
|
|
22
54
|
hooks: {
|
|
55
|
+
"astro:config:setup": ({ config: astroConfig, logger }) => {
|
|
56
|
+
// Resolve project root — Astro 5 gives a URL object, earlier versions a string.
|
|
57
|
+
let rootDir = opts.rootDir;
|
|
58
|
+
if (!rootDir) {
|
|
59
|
+
const r = astroConfig.root;
|
|
60
|
+
if (!r) {
|
|
61
|
+
rootDir = process.cwd();
|
|
62
|
+
} else if (typeof r === "object" && typeof r.pathname === "string") {
|
|
63
|
+
// URL object (Astro 5+)
|
|
64
|
+
rootDir = fileURLToPath(r);
|
|
65
|
+
} else if (typeof r === "string" && r.startsWith("file://")) {
|
|
66
|
+
rootDir = fileURLToPath(new URL(r));
|
|
67
|
+
} else {
|
|
68
|
+
rootDir = String(r);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Auto-generate content.config.ts only when all of these hold:
|
|
73
|
+
// 1. No existing content config file is found
|
|
74
|
+
// 2. The cms.config has at least one collection defined
|
|
75
|
+
const candidates = [
|
|
76
|
+
path.join(rootDir, "src", "content.config.ts"),
|
|
77
|
+
path.join(rootDir, "src", "content.config.js"),
|
|
78
|
+
path.join(rootDir, "src", "content.config.mjs"),
|
|
79
|
+
path.join(rootDir, "src", "content", "config.ts"),
|
|
80
|
+
path.join(rootDir, "src", "content", "config.js"),
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
if (candidates.some((f) => fs.existsSync(f))) return;
|
|
84
|
+
if (!opts.config?.collections || Object.keys(opts.config.collections).length === 0) return;
|
|
85
|
+
|
|
86
|
+
const target = path.join(rootDir, "src", "content.config.ts");
|
|
87
|
+
fs.writeFileSync(target, generateContentConfig(), "utf8");
|
|
88
|
+
logger.info(
|
|
89
|
+
`Generated src/content.config.ts — covers ${Object.keys(opts.config.collections).join(", ")}. Edit freely.`
|
|
90
|
+
);
|
|
91
|
+
},
|
|
92
|
+
|
|
23
93
|
"astro:server:setup": async ({ server, logger }) => {
|
|
24
94
|
// server is Astro's underlying Vite dev server.
|
|
25
95
|
const rootDir = (() => {
|
|
@@ -68,3 +138,4 @@ export default function natilonCms(opts = {}) {
|
|
|
68
138
|
}
|
|
69
139
|
|
|
70
140
|
export { createCmsServer, mountAdminUi };
|
|
141
|
+
|
package/src/loader.mjs
CHANGED
|
@@ -35,7 +35,7 @@ export function jsonContentLoader(collection, { pagesDir = "src/pages-data" } =
|
|
|
35
35
|
return;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
const files = fs.readdirSync(dir).filter(
|
|
38
|
+
const files = fs.readdirSync(dir).filter(isEntryFile);
|
|
39
39
|
store.clear();
|
|
40
40
|
|
|
41
41
|
for (const file of files) {
|
|
@@ -46,15 +46,15 @@ export function jsonContentLoader(collection, { pagesDir = "src/pages-data" } =
|
|
|
46
46
|
|
|
47
47
|
watcher.add(dir);
|
|
48
48
|
watcher.on("add", async (fp) => {
|
|
49
|
-
if (fp.startsWith(dir) &&
|
|
49
|
+
if (fp.startsWith(dir) && isEntryFile(path.basename(fp)))
|
|
50
50
|
await loadFile(fp, store, parseData, logger, collection);
|
|
51
51
|
});
|
|
52
52
|
watcher.on("change", async (fp) => {
|
|
53
|
-
if (fp.startsWith(dir) &&
|
|
53
|
+
if (fp.startsWith(dir) && isEntryFile(path.basename(fp)))
|
|
54
54
|
await loadFile(fp, store, parseData, logger, collection);
|
|
55
55
|
});
|
|
56
56
|
watcher.on("unlink", (fp) => {
|
|
57
|
-
if (fp.startsWith(dir) &&
|
|
57
|
+
if (fp.startsWith(dir) && isEntryFile(path.basename(fp))) {
|
|
58
58
|
try {
|
|
59
59
|
const raw = JSON.parse(fs.readFileSync(fp, "utf-8"));
|
|
60
60
|
store.delete(raw.id || raw.slug);
|
|
@@ -65,6 +65,12 @@ export function jsonContentLoader(collection, { pagesDir = "src/pages-data" } =
|
|
|
65
65
|
};
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
// Manifest/index files (e.g. _index.json) are generated list projections,
|
|
69
|
+
// not content entries, so they must be excluded from the entry loader.
|
|
70
|
+
function isEntryFile(file) {
|
|
71
|
+
return file.endsWith(".json") && !file.startsWith("_");
|
|
72
|
+
}
|
|
73
|
+
|
|
68
74
|
async function loadFile(filePath, store, parseData, logger, collection) {
|
|
69
75
|
const file = path.basename(filePath);
|
|
70
76
|
try {
|