@pramen/cms-astro 0.0.51 → 0.0.53

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,122 @@
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
+ ## Serving the editor: `admin`
62
+
63
+ Add `admin: true` and this site also serves the [visual editor](../cms-editor), at
64
+ `/_pramen/admin`:
65
+
66
+ ```ts
67
+ integrations: [pramenCms({ backend: { url: "https://cms.example.workers.dev" }, admin: true })],
68
+ ```
69
+
70
+ That injects **one catch-all Astro route**, so the editor is part of this site rather than
71
+ something deployed beside it. What that buys, in order of how much time each used to cost:
72
+
73
+ - **No `dist/` to deploy and no asset paths to get right.** The editor's `editor.js` /
74
+ `editor.css` are imported by the injected route and go through this site's bundler, which
75
+ emits and fingerprints them. A copied-in `index.html` could only ever reference them
76
+ root-absolute, so it worked at the origin root and nowhere else.
77
+ - **No SPA-fallback rewrite.** `/_pramen/admin/pages/:id` is a real server route: a deep
78
+ link or a refresh is served like any other page.
79
+ - **No second hostname for the editor** — it is a route on this site, not a separate
80
+ deploy pointed at a separate domain.
81
+ - **CORS only if the CMS is elsewhere.** Serving the editor here does not move the API: it
82
+ still calls `backend.url`, so a CMS on its own Worker is still cross-origin and still
83
+ needs `CORS_ORIGINS` to allow this site. Co-deploy the CMS into this site's Worker (the
84
+ D1 store needs no `export`, so it can live in an Astro Worker) and `backend.url` becomes
85
+ same-origin — then there is genuinely no CORS.
86
+ - **Nothing to point it at.** The shell tells the editor which Worker and tenant to call, so
87
+ the first screen asks for an editor/reviewer JWT and nothing else.
88
+
89
+ The mount path is a constant, not an option: the same value is the injected route pattern
90
+ *and* the prefix handed to the editor's router, so the two cannot drift into a router
91
+ mounted where the server does not serve. `_pramen` is a reserved namespace — every ordinary
92
+ path stays yours.
93
+
94
+ Pass an object instead of `true` to configure the editor itself (this replaces its old
95
+ `/config.js`, and is typed):
96
+
97
+ ```ts
98
+ pramenCms({
99
+ backend: { url: "https://cms.example.workers.dev", tenant: "acme" },
100
+ admin: {
101
+ brand: { name: "Acme", suffix: "cms" }, // the wordmark; `suffix: null` drops the second half
102
+ signInUrl: "/signin/", // must be a page that EXISTS
103
+ hidePages: true, // collections-only deployments
104
+ extraNav: [{ label: "Curation", href: "/curate" }],
105
+ },
106
+ })
107
+ ```
108
+
109
+ `@pramen/cms-editor` is an **optional** peer dependency: install it only if you use `admin`.
110
+ Omit the option and no route is injected and nothing is added to the site.
111
+
112
+ A working site is in [`example/site`](../../example/site) — content collections and the admin
113
+ route, wired in one `pramenCms()` call. It doubles as this package's end-to-end test
114
+ (`test/astro-site.test.ts`).
115
+
116
+ > `signInUrl` must be a page that already exists. An unauthenticated load calls it *after*
117
+ > clearing the stored session, so a path that lands back inside the editor is a loop with
118
+ > nothing to recover from. `?setup=1` always forces the built-in screen.
119
+
120
+ ## The kit of parts
121
+
6
122
  - **`createCmsClient({ baseUrl })`** — `getPage(slug, locale?)`, `listPublishedPages()`, and
7
123
  `getPreview(token)` to redeem a signed preview link (no session needed — the signature is
8
124
  the authorization; the result carries `isPreview: true`).
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pramen/cms-astro",
3
- "version": "0.0.51",
4
- "description": "Astro integration for @pramen/cms \u2014 a build-time content-collection loader + a BlockRenderer for rendering CMS blocks in .astro pages.",
3
+ "version": "0.0.53",
4
+ "description": "Astro integration for @pramen/cms a build-time content-collection loader + a BlockRenderer for rendering CMS blocks in .astro pages.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -14,7 +14,10 @@
14
14
  "sideEffects": false,
15
15
  "exports": {
16
16
  ".": "./src/index.ts",
17
+ "./integration": "./src/integration.ts",
18
+ "./admin": "./src/admin.ts",
17
19
  "./BlockRenderer.astro": "./src/BlockRenderer.astro",
20
+ "./PramenAdmin.astro": "./src/PramenAdmin.astro",
18
21
  "./RichText.astro": "./src/RichText.astro",
19
22
  "./RichTextMarks.astro": "./src/RichTextMarks.astro"
20
23
  },
@@ -22,7 +25,16 @@
22
25
  "src"
23
26
  ],
24
27
  "peerDependencies": {
25
- "astro": ">=4"
28
+ "astro": ">=4",
29
+ "@pramen/cms-editor": "^0.0.52"
30
+ },
31
+ "peerDependenciesMeta": {
32
+ "@pramen/cms-editor": {
33
+ "optional": true
34
+ }
35
+ },
36
+ "devDependencies": {
37
+ "@pramen/cms-editor": "0.0.53"
26
38
  },
27
39
  "publishConfig": {
28
40
  "access": "public"
@@ -0,0 +1,51 @@
1
+ ---
2
+ /**
3
+ * The CMS editor's shell — injected at `/_pramen/admin/[...path]` by
4
+ * `pramenCms({ admin: … })`. See `admin.ts` for why this is a route and not an index.html.
5
+ *
6
+ * Everything the SPA needs to boot, and nothing it could get wrong:
7
+ *
8
+ * - The two assets are imported with `?url`, so the SITE's bundler emits, fingerprints and
9
+ * serves them. There is no root-absolute path to break under a prefix and nothing for a
10
+ * host to copy into the right directory.
11
+ * - The mount node carries the prefix this route was injected at. Same constant, so the
12
+ * router can never be mounted somewhere the server does not serve.
13
+ * - The runtime config is an inline script written from typed integration options, ahead
14
+ * of the module script that reads it.
15
+ *
16
+ * A catch-all pattern, so `/_pramen/admin/pages/:id` is a real server route: a deep link or
17
+ * a refresh is served like any other page, with no SPA-fallback rewrite for the host to
18
+ * configure (and to get wrong for its own 404s).
19
+ */
20
+ import editorSrc from "@pramen/cms-editor/editor.js?url";
21
+ import editorCss from "@pramen/cms-editor/editor.css?url";
22
+ import { adminBasePath, adminConfigScript, adminTitle } from "pramen:cms/admin";
23
+
24
+ // The editor is a client-side app against a live API — there is nothing to prerender, and
25
+ // prerendering would bake one deployment's config into a static file.
26
+ export const prerender = false;
27
+
28
+ // Never store this in a shared cache. Without an explicit directive, caches fall back to
29
+ // heuristic freshness (Cloudflare's Workers Cache keeps a header-less 200 for hours), and
30
+ // this document carries the deployment's runtime config inline.
31
+ Astro.response.headers.set("cache-control", "private, no-store");
32
+ ---
33
+
34
+ <!doctype html>
35
+ <html lang="en">
36
+ <head>
37
+ <meta charset="utf-8" />
38
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
39
+ <meta name="robots" content="noindex, nofollow" />
40
+ {/* The pre-hydration fallback only. The bundle re-applies the configured wordmark on
41
+ boot, so this deliberately does not try to reproduce that string here — one place
42
+ builds it, and it is the one that owns the brand rules. */}
43
+ <title>{adminTitle}</title>
44
+ <link rel="stylesheet" href={editorCss} />
45
+ <script is:inline set:html={adminConfigScript} />
46
+ </head>
47
+ <body>
48
+ <div id="app" data-base-path={adminBasePath}></div>
49
+ <script is:inline type="module" src={editorSrc}></script>
50
+ </body>
51
+ </html>
@@ -22,6 +22,18 @@ interface Props {
22
22
 
23
23
  const { value, nodes } = Astro.props;
24
24
  const list = nodes ?? value?.content ?? [];
25
+
26
+ /** The element name for a heading node. Computed HERE, in the frontmatter, and not inline
27
+ * in the template below: Astro's compiler scans a template expression for markup, so a `<`
28
+ * comparison inside one is read as the start of a tag and fails the build outright
29
+ * ("Unable to assign attributes when using <> Fragment shorthand syntax"). Frontmatter is
30
+ * plain TypeScript, where `<=` means what it says.
31
+ *
32
+ * Integer-checked as well as ranged — `h2.5` is not an element name. */
33
+ function headingTag(level: unknown): string {
34
+ const ok = typeof level === "number" && Number.isInteger(level) && level >= 1 && level <= 6;
35
+ return ok ? `h${level}` : "h2";
36
+ }
25
37
  ---
26
38
 
27
39
  {
@@ -39,10 +51,7 @@ const list = nodes ?? value?.content ?? [];
39
51
  </p>
40
52
  );
41
53
  case "heading": {
42
- // Integer too — `h2.5` is an invalid element name.
43
- const level =
44
- typeof attrs.level === "number" && Number.isInteger(attrs.level) && attrs.level >= 1 && attrs.level <= 6 ? attrs.level : 2;
45
- const Tag = `h${level}`;
54
+ const Tag = headingTag(attrs.level);
46
55
  return (
47
56
  <Tag>
48
57
  <Self nodes={children} />
package/src/admin.ts ADDED
@@ -0,0 +1,83 @@
1
+ // The admin mount — where @pramen/cms-editor lives on the host site, and what the shell
2
+ // tells it when it boots.
3
+ //
4
+ // The editor is a routed SPA. Serving it used to mean deploying its `dist/` somewhere and
5
+ // asking the host for three things it had no way to verify: a catch-all rewrite to
6
+ // index.html, hashed assets at the ORIGIN ROOT (so any prefix mount broke), and a
7
+ // hand-edited `config.js` that failed silently when it 404'd. None of that survives here:
8
+ // the integration injects ONE Astro route, so the host's own routing table serves every
9
+ // deep link, its bundler emits and fingerprints the two assets, and the config is rendered
10
+ // into the page from typed options.
11
+ //
12
+ // `ADMIN_BASE` is a constant, not an option. It is the pattern passed to `injectRoute` AND
13
+ // the prefix stamped onto the mount node, so the route and the router cannot disagree —
14
+ // which is the entire failure mode a configurable prefix invites. The `_`-prefixed segment
15
+ // is a reserved namespace: the host keeps every ordinary path for its own pages.
16
+
17
+ /** Where the editor is mounted on the host site. */
18
+ export const ADMIN_BASE = "/_pramen/admin";
19
+
20
+ /** The route pattern injected for it — one catch-all, so every in-app URL is a real server
21
+ * route and a refresh or a deep link is served like any other page. */
22
+ export const ADMIN_ROUTE = `${ADMIN_BASE}/[...path]`;
23
+
24
+ /** What the editor is handed at boot. Rendered into the shell as one inline script, so it
25
+ * is set before the bundle runs — the contract the old `/config.js` had, minus the file. */
26
+ export interface AdminRuntimeConfig {
27
+ /** Which CMS Worker to call, and as which tenant. The editor asks for a token and
28
+ * nothing else once this is present. */
29
+ backend: { url: string; tenant: string };
30
+ /** The wordmark in the topbar, on the sign-in screen and in the browser tab. Set it when
31
+ * you deploy for a client — the default is the framework's name, not theirs. */
32
+ brand?: { name?: string; suffix?: string | null };
33
+ /** Send unauthenticated/expired sessions to your own sign-in page. Must be a page that
34
+ * EXISTS: the editor clears the session before redirecting, so a path that lands back
35
+ * inside the editor is a loop with nothing to recover from. */
36
+ signInUrl?: string;
37
+ /** Hide the Pages tab, for deployments that use collections only. */
38
+ hidePages?: boolean;
39
+ /** Extra top-nav links to companion tools the host serves. */
40
+ extraNav?: { label: string; href: string }[];
41
+ }
42
+
43
+ /** Options for the injected admin route. `true` is "mount it with the integration's own
44
+ * backend and no other configuration". */
45
+ export type AdminOptions = boolean | Omit<AdminRuntimeConfig, "backend">;
46
+
47
+ /** Characters that must not survive into an inline `<script>` verbatim. */
48
+ const UNSAFE_IN_SCRIPT = /[<\u2028\u2029]/g;
49
+
50
+ /** `<` as a JS unicode escape (so `</script>` cannot close the tag), and U+2028/U+2029 —
51
+ * legal in JSON strings, and historically line terminators in JS source — as their own. */
52
+ function escapeForScript(char: string): string {
53
+ return `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`;
54
+ }
55
+
56
+ /**
57
+ * Serialize the runtime config for an inline `<script>`.
58
+ *
59
+ * `</script>` inside any string value would close the tag early and drop the rest of the
60
+ * page into the browser's HTML parser — and every one of these fields (a brand name, a nav
61
+ * label) is content someone types. Escaping at the JSON level is the fix that does not
62
+ * depend on where in the object the value happens to sit.
63
+ */
64
+ export function serializeAdminConfig(cfg: AdminRuntimeConfig): string {
65
+ return `window.PRAMEN_CMS_EDITOR=${JSON.stringify(cfg).replace(UNSAFE_IN_SCRIPT, escapeForScript)};`;
66
+ }
67
+
68
+ /** The shell's `<title>`, which is only the pre-hydration fallback — the bundle re-applies
69
+ * the wordmark itself on boot. Mirrors `brand.ts`'s rule so the tab does not visibly change
70
+ * text a moment after load: a configured name replaces the whole string (nothing English is
71
+ * appended to a client's name), and `suffix: null` drops the second half. */
72
+ export function adminDocumentTitle(cfg: AdminRuntimeConfig): string {
73
+ const name = cfg.brand?.name?.trim();
74
+ if (!name) return "pramen · cms editor";
75
+ const suffix = cfg.brand?.suffix === undefined ? "cms" : cfg.brand.suffix;
76
+ return suffix ? `${name} · ${suffix}` : name;
77
+ }
78
+
79
+ /** Build the runtime config from the integration's options and its backend descriptor. */
80
+ export function adminRuntimeConfig(admin: AdminOptions, backend: { url: string; tenant?: string }): AdminRuntimeConfig {
81
+ const extra = admin === true ? {} : admin || {};
82
+ return { ...extra, backend: { url: backend.url.replace(/\/+$/, ""), tenant: backend.tenant ?? "main" } };
83
+ }
package/src/index.ts CHANGED
@@ -11,6 +11,16 @@
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
+
20
+ // The admin mount — the editor served as an injected route on this site (`admin: true`).
21
+ export { ADMIN_BASE, ADMIN_ROUTE } from "./admin.js";
22
+ export type { AdminOptions, AdminRuntimeConfig } from "./admin.js";
23
+
14
24
  /** Any JSON value — the wire form of everything the CMS stores. */
15
25
  export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
16
26
 
@@ -0,0 +1,257 @@
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 { fileURLToPath } from "node:url";
28
+ import { ADMIN_BASE, ADMIN_ROUTE, adminDocumentTitle, adminRuntimeConfig, serializeAdminConfig, type AdminOptions } from "./admin.js";
29
+
30
+ /** Where the CMS lives. A named descriptor rather than a bare `baseUrl` string, so a future
31
+ * local/in-process backend can be added without changing the call shape. */
32
+ export interface CmsBackend {
33
+ /** The Worker's origin, e.g. `https://cms.example.workers.dev`. */
34
+ url: string;
35
+ /** Tenant to read. Default `"main"`. */
36
+ tenant?: string;
37
+ /** Bearer token, for reading a private deployment at build time. The public content API
38
+ * needs none — pass one only if your ACL does not grant anonymous reads. */
39
+ token?: string;
40
+ }
41
+
42
+ /** One generated collection: the Astro collection name, and the CMS content type it loads. */
43
+ export type CollectionMap = Record<string, string>;
44
+
45
+ export interface PramenCmsOptions {
46
+ backend: CmsBackend;
47
+ /**
48
+ * Which collections to generate.
49
+ *
50
+ * - `"auto"` (default) — one collection per content type in the store, named after the
51
+ * type's slug. Discovered at config time, so adding a type in the editor takes effect
52
+ * on the next build with no code change.
53
+ * - an explicit map — `{ articles: "article", pages: "page" }` — when you want your own
54
+ * names, or a subset.
55
+ *
56
+ * `"auto"` costs one request during `astro:config:setup`. If it fails (the CMS is down,
57
+ * or unreachable from CI) the build FAILS rather than silently producing zero
58
+ * collections, which would otherwise surface as `getCollection("articles")` returning
59
+ * nothing and a site that builds green and empty.
60
+ */
61
+ collections?: "auto" | CollectionMap;
62
+ /** Restrict every generated collection to one locale. Omit to load all of them. */
63
+ locale?: string;
64
+ /**
65
+ * Serve the visual editor from this site, at `/_pramen/admin`.
66
+ *
67
+ * `true` mounts it against the same `backend` the collections load from; an object also
68
+ * carries the editor's own configuration (`brand`, `signInUrl`, `hidePages`, `extraNav`).
69
+ * Omit it and no admin route is injected at all — nothing is added to the site, and
70
+ * `@pramen/cms-editor` need not be installed.
71
+ *
72
+ * The route is a real Astro route, so deep links and refreshes are served by this site's
73
+ * router and the editor's two assets go through its bundler. There is nothing to deploy
74
+ * separately, no SPA-fallback rewrite to configure, and no second hostname for the editor.
75
+ *
76
+ * It does NOT move the API: the editor still calls `backend.url`, so a CMS on its own
77
+ * Worker stays cross-origin and still needs `CORS_ORIGINS`.
78
+ */
79
+ admin?: AdminOptions;
80
+ }
81
+
82
+ /** The virtual module the site imports from. */
83
+ const VIRTUAL_ID = "pramen:cms";
84
+ const RESOLVED_ID = "\0pramen:cms";
85
+
86
+ /** A second one for the admin shell. Separate from `pramen:cms` so the injected route does
87
+ * not drag `astro:content` and the generated collections into a runtime page. */
88
+ const ADMIN_VIRTUAL_ID = "pramen:cms/admin";
89
+ const ADMIN_RESOLVED_ID = "\0pramen:cms/admin";
90
+
91
+ /** Ask the CMS which content types exist. Public and un-gated (`listPublicContentTypes`),
92
+ * because this runs at BUILD time where there is no editor session — and a content type's
93
+ * slug is already public: `listPublishedPages` returns it for every published page. */
94
+ async function discoverTypes(backend: CmsBackend): Promise<string[]> {
95
+ const base = backend.url.replace(/\/+$/, "");
96
+ const headers: Record<string, string> = { "content-type": "application/json", "x-pramen-tenant": backend.tenant ?? "main" };
97
+ if (backend.token) headers.authorization = `Bearer ${backend.token}`;
98
+ const res = await fetch(`${base}/rpc/listPublicContentTypes`, { method: "POST", headers, body: "{}" });
99
+ const body = (await res.json().catch(() => ({}))) as { ok?: boolean; result?: { slug: string }[]; error?: string };
100
+ if (body.ok !== true || !Array.isArray(body.result)) {
101
+ throw new Error(
102
+ `@pramen/cms-astro: collections: "auto" could not read content types from ${base} (HTTP ${res.status}${body.error ? `: ${body.error}` : ""}). ` +
103
+ `Pass an explicit map instead — collections: { articles: "article" } — or make the CMS reachable from this build.`,
104
+ );
105
+ }
106
+ return body.result.map((t) => t.slug).filter((s) => typeof s === "string" && s !== "");
107
+ }
108
+
109
+ /** A valid JS identifier-ish collection name. A content-type slug is author-controlled, and
110
+ * it lands in generated source as an object key — quote it, and refuse the ones that cannot
111
+ * be a collection name at all rather than emitting code that fails to parse. */
112
+ function collectionKey(slug: string): string {
113
+ if (!/^[A-Za-z_][A-Za-z0-9_-]*$/.test(slug)) {
114
+ 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)} }`);
115
+ }
116
+ return slug;
117
+ }
118
+
119
+ /** Generate the virtual module's source. Everything the site needs from one import:
120
+ * the configured client, `resolve()` for media URLs, and the collections. */
121
+ function moduleSource(backend: CmsBackend, map: CollectionMap, locale?: string): string {
122
+ const clientOpts = JSON.stringify({ baseUrl: backend.url, tenant: backend.tenant, token: backend.token });
123
+ const entries = Object.entries(map)
124
+ .map(([name, type]) => ` ${collectionKey(name)}: defineCollection({ loader: cmsLoader({ client, type: ${JSON.stringify(type)}${locale ? `, locale: ${JSON.stringify(locale)}` : ""} }) }),`)
125
+ .join("\n");
126
+ return `// GENERATED by @pramen/cms-astro (pramenCms integration) — do not edit.
127
+ import { defineCollection } from "astro:content";
128
+ import { createCmsClient, cmsLoader } from "@pramen/cms-astro";
129
+
130
+ export const client = createCmsClient(${clientOpts});
131
+
132
+ /** Absolute URL for a media path the CMS returned. Same base as the client, so a component
133
+ * never has to be handed the client (or a duplicated base URL) just to show an image. */
134
+ export const resolve = (path) => client.resolve(path);
135
+
136
+ export const collections = {
137
+ ${entries}
138
+ };
139
+ `;
140
+ }
141
+
142
+ /** Generate the admin shell's module: the mount prefix, the runtime config already
143
+ * serialized for an inline script, and the fallback tab title. Values, not logic — the
144
+ * shell is a template. */
145
+ function adminModuleSource(admin: AdminOptions, backend: CmsBackend): string {
146
+ const cfg = adminRuntimeConfig(admin, backend);
147
+ return `// GENERATED by @pramen/cms-astro (pramenCms integration) — do not edit.
148
+ export const adminBasePath = ${JSON.stringify(ADMIN_BASE)};
149
+ export const adminConfigScript = ${JSON.stringify(serializeAdminConfig(cfg))};
150
+ export const adminTitle = ${JSON.stringify(adminDocumentTitle(cfg))};
151
+ `;
152
+ }
153
+
154
+ /** The `pramen:cms` module's types, injected so the site gets them with no manual d.ts. */
155
+ const TYPES = `declare module "pramen:cms" {
156
+ import type { CmsClient } from "@pramen/cms-astro";
157
+ /** The configured CMS client — same instance the collections load through. */
158
+ export const client: CmsClient;
159
+ /** Absolute URL for a media path the CMS returned. */
160
+ export function resolve(path: string): string;
161
+ /** Generated content collections. Re-export from src/content.config.ts:
162
+ * \`export { collections } from "pramen:cms";\` */
163
+ export const collections: Record<string, unknown>;
164
+ }
165
+ `;
166
+
167
+ /** The admin shell's module types. Injected only when the admin route is. */
168
+ const ADMIN_TYPES = `declare module "pramen:cms/admin" {
169
+ /** The prefix the admin route was injected at — stamped onto the editor's mount node. */
170
+ export const adminBasePath: string;
171
+ /** The editor's runtime config, serialized for an inline <script>. */
172
+ export const adminConfigScript: string;
173
+ /** Pre-hydration fallback for the shell's <title>. */
174
+ export const adminTitle: string;
175
+ }
176
+ `;
177
+
178
+ /**
179
+ * The Astro integration for a @pramen/cms backend.
180
+ *
181
+ * import pramenCms from "@pramen/cms-astro";
182
+ *
183
+ * export default defineConfig({
184
+ * integrations: [pramenCms({ backend: { url: "https://cms.example.workers.dev" } })],
185
+ * });
186
+ *
187
+ * Then, once, in `src/content.config.ts`:
188
+ *
189
+ * export { collections } from "pramen:cms";
190
+ *
191
+ * `createCmsClient` / `cmsLoader` stay exported — this is the front door, not a
192
+ * replacement. A site that wants to define its own collections by hand still can.
193
+ */
194
+ export function pramenCms(opts: PramenCmsOptions): AstroIntegration {
195
+ if (!opts?.backend?.url) throw new Error("@pramen/cms-astro: pramenCms() needs a backend url — pramenCms({ backend: { url: \"https://cms.example.workers.dev\" } })");
196
+ return {
197
+ name: "@pramen/cms-astro",
198
+ hooks: {
199
+ "astro:config:setup": async ({ updateConfig, injectRoute, logger }) => {
200
+ const wanted = opts.collections ?? "auto";
201
+ const map: CollectionMap = wanted === "auto" ? Object.fromEntries((await discoverTypes(opts.backend)).map((s) => [collectionKey(s), s])) : wanted;
202
+ const names = Object.keys(map);
203
+ if (names.length === 0) {
204
+ // Not an error: a store with no content types yet is a legitimate early state.
205
+ // It IS worth saying out loud, because the symptom otherwise is `getCollection`
206
+ // throwing about a collection the site is sure it configured.
207
+ logger.warn(`no content types found at ${opts.backend.url} — no collections generated`);
208
+ } else {
209
+ logger.info(`${names.length} collection(s) from ${opts.backend.url}: ${names.join(", ")}`);
210
+ }
211
+ const code = moduleSource(opts.backend, map, opts.locale);
212
+ // Only when asked. A site that just reads content never installs @pramen/cms-editor,
213
+ // and an injected route would be a build error rather than an unused page.
214
+ const adminCode = opts.admin ? adminModuleSource(opts.admin, opts.backend) : undefined;
215
+ updateConfig({
216
+ vite: {
217
+ // The editor's bundle is a finished artifact, not source for this build to walk:
218
+ // it is emitted verbatim and referenced by url. `?url` alone asks for that, and
219
+ // this says so for the file itself, since a `.js` extension is otherwise the one
220
+ // thing a bundler assumes it should follow.
221
+ assetsInclude: ["**/@pramen/cms-editor/dist/editor.js"],
222
+ plugins: [
223
+ {
224
+ name: "pramen:cms",
225
+ // `enforce: "pre"` so this resolves before Astro's own alias handling sees
226
+ // an unknown bare specifier and reports it as a missing package.
227
+ enforce: "pre" as const,
228
+ resolveId(id: string) {
229
+ if (id === VIRTUAL_ID) return RESOLVED_ID;
230
+ if (id === ADMIN_VIRTUAL_ID && adminCode) return ADMIN_RESOLVED_ID;
231
+ return null;
232
+ },
233
+ load(id: string) {
234
+ if (id === RESOLVED_ID) return code;
235
+ if (id === ADMIN_RESOLVED_ID) return adminCode;
236
+ return null;
237
+ },
238
+ },
239
+ ],
240
+ },
241
+ });
242
+ if (adminCode) {
243
+ // One catch-all: every in-app URL is a real server route, so a deep link and a
244
+ // refresh are served like any other page. The pattern and the prefix the shell
245
+ // stamps on the mount node are the same constant — see admin.ts.
246
+ injectRoute({ pattern: ADMIN_ROUTE, entrypoint: fileURLToPath(new URL("./PramenAdmin.astro", import.meta.url)) });
247
+ logger.info(`editor mounted at ${ADMIN_BASE}`);
248
+ }
249
+ },
250
+ "astro:config:done": ({ injectTypes }) => {
251
+ injectTypes({ filename: "pramen-cms.d.ts", content: opts.admin ? `${TYPES}\n${ADMIN_TYPES}` : TYPES });
252
+ },
253
+ },
254
+ };
255
+ }
256
+
257
+ export default pramenCms;