@pramen/cms-astro 0.0.50 → 0.0.52
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 +57 -0
- package/package.json +2 -1
- package/src/index.ts +6 -0
- package/src/integration.ts +193 -0
package/README.md
CHANGED
|
@@ -3,6 +3,63 @@
|
|
|
3
3
|
Consume a [`@pramen/cms`](../cms) backend from an **Astro** site. Self-contained (no
|
|
4
4
|
`@pramen/server` dependency — it speaks the CMS's public HTTP content API).
|
|
5
5
|
|
|
6
|
+
## The front door: `pramenCms()`
|
|
7
|
+
|
|
8
|
+
One integration, and the collections come from the store:
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
// astro.config.mjs
|
|
12
|
+
import { defineConfig } from "astro/config";
|
|
13
|
+
import pramenCms from "@pramen/cms-astro";
|
|
14
|
+
|
|
15
|
+
export default defineConfig({
|
|
16
|
+
integrations: [pramenCms({ backend: { url: "https://cms.example.workers.dev" } })],
|
|
17
|
+
});
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
// src/content.config.ts — once, and never again
|
|
22
|
+
export { collections } from "pramen:cms";
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
That is the whole wiring. `collections: "auto"` (the default) asks the CMS which content
|
|
26
|
+
types exist and generates one collection per type, named after its slug — so adding a
|
|
27
|
+
content type in the editor takes effect on the next build with no code change. Pass a map
|
|
28
|
+
when you want your own names or a subset: `collections: { clanky: "article" }`.
|
|
29
|
+
|
|
30
|
+
The `pramen:cms` virtual module also exports the **configured client** and a bound
|
|
31
|
+
**`resolve()`**, so a component that needs a media URL imports it instead of
|
|
32
|
+
re-instantiating the client with a duplicated base URL:
|
|
33
|
+
|
|
34
|
+
```astro
|
|
35
|
+
---
|
|
36
|
+
import { client, resolve } from "pramen:cms";
|
|
37
|
+
const page = await client.getPage("o-nas");
|
|
38
|
+
---
|
|
39
|
+
<img src={resolve(page.page.fields.hero.url)} alt="" />
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Types for that module are injected automatically (`pramen-cms.d.ts`), so there is no
|
|
43
|
+
hand-written `d.ts` to keep in step.
|
|
44
|
+
|
|
45
|
+
**Why the one-line re-export?** Astro has no API for an integration to define content
|
|
46
|
+
collections — `astro:config:setup` offers routes, scripts, middleware, renderers and Vite
|
|
47
|
+
config, and nothing for the content layer. Collections must be exported from
|
|
48
|
+
`src/content.config.ts`. So the integration generates them and you re-export once, instead
|
|
49
|
+
of hand-writing a `defineCollection` per type that has to track rows in the store.
|
|
50
|
+
|
|
51
|
+
**`"auto"` fails the build if it cannot reach the CMS**, rather than generating nothing.
|
|
52
|
+
Zero collections would otherwise build green and deploy an empty site. Discovery reads
|
|
53
|
+
`listPublicContentTypes`, which is un-gated — no build-time token needed, and nothing new is
|
|
54
|
+
exposed (a content type's slug already reaches the public through `listPublishedPages`). If
|
|
55
|
+
your deployment does need auth for reads, pass `backend: { token }`.
|
|
56
|
+
|
|
57
|
+
`createCmsClient` / `cmsLoader` stay exported and are documented below — the integration is
|
|
58
|
+
the front door, not a replacement. A site that wants to define its own collections by hand
|
|
59
|
+
still can.
|
|
60
|
+
|
|
61
|
+
## The kit of parts
|
|
62
|
+
|
|
6
63
|
- **`createCmsClient({ baseUrl })`** — `getPage(slug, locale?)`, `listPublishedPages()`, and
|
|
7
64
|
`getPreview(token)` to redeem a signed preview link (no session needed — the signature is
|
|
8
65
|
the authorization; the result carries `isPreview: true`).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pramen/cms-astro",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.52",
|
|
4
4
|
"description": "Astro integration for @pramen/cms \u2014 a build-time content-collection loader + a BlockRenderer for rendering CMS blocks in .astro pages.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"sideEffects": false,
|
|
15
15
|
"exports": {
|
|
16
16
|
".": "./src/index.ts",
|
|
17
|
+
"./integration": "./src/integration.ts",
|
|
17
18
|
"./BlockRenderer.astro": "./src/BlockRenderer.astro",
|
|
18
19
|
"./RichText.astro": "./src/RichText.astro",
|
|
19
20
|
"./RichTextMarks.astro": "./src/RichTextMarks.astro"
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
|
|
12
12
|
import type { Loader, LoaderContext } from "astro/loaders";
|
|
13
13
|
|
|
14
|
+
// The integration — the front door (`pramenCms()`), re-exported so
|
|
15
|
+
// `import pramenCms from "@pramen/cms-astro"` works. The kit of parts below stays exported:
|
|
16
|
+
// a site that defines its own collections by hand still can.
|
|
17
|
+
export { pramenCms, default } from "./integration.js";
|
|
18
|
+
export type { CmsBackend, CollectionMap, PramenCmsOptions } from "./integration.js";
|
|
19
|
+
|
|
14
20
|
/** Any JSON value — the wire form of everything the CMS stores. */
|
|
15
21
|
export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
|
16
22
|
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// The Astro integration — one front door for a @pramen/cms backend (issue #35).
|
|
2
|
+
//
|
|
3
|
+
// Before this, a site hand-wired the kit of parts: instantiate `createCmsClient` in
|
|
4
|
+
// `content.config.ts`, write one `defineCollection({ loader: cmsLoader({ client, type }) })`
|
|
5
|
+
// per content type, then re-instantiate or re-import the client anywhere else that needs
|
|
6
|
+
// `resolve()` for a media URL. Two things drifted: the base URL was repeated at every call
|
|
7
|
+
// site, and the hand-written collection list had to be kept in step with content types that
|
|
8
|
+
// are runtime ROWS — so adding a type in the editor silently did nothing until someone
|
|
9
|
+
// remembered to edit the config.
|
|
10
|
+
//
|
|
11
|
+
// `pramenCms()` owns both. It builds the client once from a typed `backend` descriptor and
|
|
12
|
+
// exposes it (plus the collections) through the `pramen:cms` virtual module.
|
|
13
|
+
//
|
|
14
|
+
// ON REGISTERING COLLECTIONS. Astro has no API for an integration to define content
|
|
15
|
+
// collections — `astro:config:setup` offers routes, scripts, middleware, renderers and Vite
|
|
16
|
+
// config, and nothing for the content layer (checked against Astro 7). Collections must be
|
|
17
|
+
// exported from `src/content.config.ts`. So the integration generates them and the site
|
|
18
|
+
// re-exports in one line:
|
|
19
|
+
//
|
|
20
|
+
// // src/content.config.ts
|
|
21
|
+
// export { collections } from "pramen:cms";
|
|
22
|
+
//
|
|
23
|
+
// That is the honest version of "the integration registers them": one line that never
|
|
24
|
+
// changes, instead of one `defineCollection` per type that has to track the store.
|
|
25
|
+
|
|
26
|
+
import type { AstroIntegration } from "astro";
|
|
27
|
+
import { createCmsClient, type CmsClient } from "./index.js";
|
|
28
|
+
|
|
29
|
+
/** Where the CMS lives. A named descriptor rather than a bare `baseUrl` string, so a future
|
|
30
|
+
* local/in-process backend can be added without changing the call shape. */
|
|
31
|
+
export interface CmsBackend {
|
|
32
|
+
/** The Worker's origin, e.g. `https://cms.example.workers.dev`. */
|
|
33
|
+
url: string;
|
|
34
|
+
/** Tenant to read. Default `"main"`. */
|
|
35
|
+
tenant?: string;
|
|
36
|
+
/** Bearer token, for reading a private deployment at build time. The public content API
|
|
37
|
+
* needs none — pass one only if your ACL does not grant anonymous reads. */
|
|
38
|
+
token?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One generated collection: the Astro collection name, and the CMS content type it loads. */
|
|
42
|
+
export type CollectionMap = Record<string, string>;
|
|
43
|
+
|
|
44
|
+
export interface PramenCmsOptions {
|
|
45
|
+
backend: CmsBackend;
|
|
46
|
+
/**
|
|
47
|
+
* Which collections to generate.
|
|
48
|
+
*
|
|
49
|
+
* - `"auto"` (default) — one collection per content type in the store, named after the
|
|
50
|
+
* type's slug. Discovered at config time, so adding a type in the editor takes effect
|
|
51
|
+
* on the next build with no code change.
|
|
52
|
+
* - an explicit map — `{ articles: "article", pages: "page" }` — when you want your own
|
|
53
|
+
* names, or a subset.
|
|
54
|
+
*
|
|
55
|
+
* `"auto"` costs one request during `astro:config:setup`. If it fails (the CMS is down,
|
|
56
|
+
* or unreachable from CI) the build FAILS rather than silently producing zero
|
|
57
|
+
* collections, which would otherwise surface as `getCollection("articles")` returning
|
|
58
|
+
* nothing and a site that builds green and empty.
|
|
59
|
+
*/
|
|
60
|
+
collections?: "auto" | CollectionMap;
|
|
61
|
+
/** Restrict every generated collection to one locale. Omit to load all of them. */
|
|
62
|
+
locale?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The virtual module the site imports from. */
|
|
66
|
+
const VIRTUAL_ID = "pramen:cms";
|
|
67
|
+
const RESOLVED_ID = "\0pramen:cms";
|
|
68
|
+
|
|
69
|
+
/** Ask the CMS which content types exist. Public and un-gated (`listPublicContentTypes`),
|
|
70
|
+
* because this runs at BUILD time where there is no editor session — and a content type's
|
|
71
|
+
* slug is already public: `listPublishedPages` returns it for every published page. */
|
|
72
|
+
async function discoverTypes(backend: CmsBackend): Promise<string[]> {
|
|
73
|
+
const base = backend.url.replace(/\/+$/, "");
|
|
74
|
+
const headers: Record<string, string> = { "content-type": "application/json", "x-pramen-tenant": backend.tenant ?? "main" };
|
|
75
|
+
if (backend.token) headers.authorization = `Bearer ${backend.token}`;
|
|
76
|
+
const res = await fetch(`${base}/rpc/listPublicContentTypes`, { method: "POST", headers, body: "{}" });
|
|
77
|
+
const body = (await res.json().catch(() => ({}))) as { ok?: boolean; result?: { slug: string }[]; error?: string };
|
|
78
|
+
if (body.ok !== true || !Array.isArray(body.result)) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`@pramen/cms-astro: collections: "auto" could not read content types from ${base} (HTTP ${res.status}${body.error ? `: ${body.error}` : ""}). ` +
|
|
81
|
+
`Pass an explicit map instead — collections: { articles: "article" } — or make the CMS reachable from this build.`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
return body.result.map((t) => t.slug).filter((s) => typeof s === "string" && s !== "");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A valid JS identifier-ish collection name. A content-type slug is author-controlled, and
|
|
88
|
+
* it lands in generated source as an object key — quote it, and refuse the ones that cannot
|
|
89
|
+
* be a collection name at all rather than emitting code that fails to parse. */
|
|
90
|
+
function collectionKey(slug: string): string {
|
|
91
|
+
if (!/^[A-Za-z_][A-Za-z0-9_-]*$/.test(slug)) {
|
|
92
|
+
throw new Error(`@pramen/cms-astro: content-type slug ${JSON.stringify(slug)} cannot be a collection name — map it explicitly, e.g. collections: { myName: ${JSON.stringify(slug)} }`);
|
|
93
|
+
}
|
|
94
|
+
return slug;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Generate the virtual module's source. Everything the site needs from one import:
|
|
98
|
+
* the configured client, `resolve()` for media URLs, and the collections. */
|
|
99
|
+
function moduleSource(backend: CmsBackend, map: CollectionMap, locale?: string): string {
|
|
100
|
+
const clientOpts = JSON.stringify({ baseUrl: backend.url, tenant: backend.tenant, token: backend.token });
|
|
101
|
+
const entries = Object.entries(map)
|
|
102
|
+
.map(([name, type]) => ` ${collectionKey(name)}: defineCollection({ loader: cmsLoader({ client, type: ${JSON.stringify(type)}${locale ? `, locale: ${JSON.stringify(locale)}` : ""} }) }),`)
|
|
103
|
+
.join("\n");
|
|
104
|
+
return `// GENERATED by @pramen/cms-astro (pramenCms integration) — do not edit.
|
|
105
|
+
import { defineCollection } from "astro:content";
|
|
106
|
+
import { createCmsClient, cmsLoader } from "@pramen/cms-astro";
|
|
107
|
+
|
|
108
|
+
export const client = createCmsClient(${clientOpts});
|
|
109
|
+
|
|
110
|
+
/** Absolute URL for a media path the CMS returned. Same base as the client, so a component
|
|
111
|
+
* never has to be handed the client (or a duplicated base URL) just to show an image. */
|
|
112
|
+
export const resolve = (path) => client.resolve(path);
|
|
113
|
+
|
|
114
|
+
export const collections = {
|
|
115
|
+
${entries}
|
|
116
|
+
};
|
|
117
|
+
`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The `pramen:cms` module's types, injected so the site gets them with no manual d.ts. */
|
|
121
|
+
const TYPES = `declare module "pramen:cms" {
|
|
122
|
+
import type { CmsClient } from "@pramen/cms-astro";
|
|
123
|
+
/** The configured CMS client — same instance the collections load through. */
|
|
124
|
+
export const client: CmsClient;
|
|
125
|
+
/** Absolute URL for a media path the CMS returned. */
|
|
126
|
+
export function resolve(path: string): string;
|
|
127
|
+
/** Generated content collections. Re-export from src/content.config.ts:
|
|
128
|
+
* \`export { collections } from "pramen:cms";\` */
|
|
129
|
+
export const collections: Record<string, unknown>;
|
|
130
|
+
}
|
|
131
|
+
`;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The Astro integration for a @pramen/cms backend.
|
|
135
|
+
*
|
|
136
|
+
* import pramenCms from "@pramen/cms-astro";
|
|
137
|
+
*
|
|
138
|
+
* export default defineConfig({
|
|
139
|
+
* integrations: [pramenCms({ backend: { url: "https://cms.example.workers.dev" } })],
|
|
140
|
+
* });
|
|
141
|
+
*
|
|
142
|
+
* Then, once, in `src/content.config.ts`:
|
|
143
|
+
*
|
|
144
|
+
* export { collections } from "pramen:cms";
|
|
145
|
+
*
|
|
146
|
+
* `createCmsClient` / `cmsLoader` stay exported — this is the front door, not a
|
|
147
|
+
* replacement. A site that wants to define its own collections by hand still can.
|
|
148
|
+
*/
|
|
149
|
+
export function pramenCms(opts: PramenCmsOptions): AstroIntegration {
|
|
150
|
+
if (!opts?.backend?.url) throw new Error("@pramen/cms-astro: pramenCms() needs a backend url — pramenCms({ backend: { url: \"https://cms.example.workers.dev\" } })");
|
|
151
|
+
return {
|
|
152
|
+
name: "@pramen/cms-astro",
|
|
153
|
+
hooks: {
|
|
154
|
+
"astro:config:setup": async ({ updateConfig, logger }) => {
|
|
155
|
+
const wanted = opts.collections ?? "auto";
|
|
156
|
+
const map: CollectionMap = wanted === "auto" ? Object.fromEntries((await discoverTypes(opts.backend)).map((s) => [collectionKey(s), s])) : wanted;
|
|
157
|
+
const names = Object.keys(map);
|
|
158
|
+
if (names.length === 0) {
|
|
159
|
+
// Not an error: a store with no content types yet is a legitimate early state.
|
|
160
|
+
// It IS worth saying out loud, because the symptom otherwise is `getCollection`
|
|
161
|
+
// throwing about a collection the site is sure it configured.
|
|
162
|
+
logger.warn(`no content types found at ${opts.backend.url} — no collections generated`);
|
|
163
|
+
} else {
|
|
164
|
+
logger.info(`${names.length} collection(s) from ${opts.backend.url}: ${names.join(", ")}`);
|
|
165
|
+
}
|
|
166
|
+
const code = moduleSource(opts.backend, map, opts.locale);
|
|
167
|
+
updateConfig({
|
|
168
|
+
vite: {
|
|
169
|
+
plugins: [
|
|
170
|
+
{
|
|
171
|
+
name: "pramen:cms",
|
|
172
|
+
// `enforce: "pre"` so this resolves before Astro's own alias handling sees
|
|
173
|
+
// an unknown bare specifier and reports it as a missing package.
|
|
174
|
+
enforce: "pre" as const,
|
|
175
|
+
resolveId(id: string) {
|
|
176
|
+
return id === VIRTUAL_ID ? RESOLVED_ID : null;
|
|
177
|
+
},
|
|
178
|
+
load(id: string) {
|
|
179
|
+
return id === RESOLVED_ID ? code : null;
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
},
|
|
186
|
+
"astro:config:done": ({ injectTypes }) => {
|
|
187
|
+
injectTypes({ filename: "pramen-cms.d.ts", content: TYPES });
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export default pramenCms;
|