@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 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.50",
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;