@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 +116 -0
- package/package.json +15 -3
- package/src/PramenAdmin.astro +51 -0
- package/src/RichText.astro +13 -4
- package/src/admin.ts +83 -0
- package/src/index.ts +10 -0
- package/src/integration.ts +257 -0
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.
|
|
4
|
-
"description": "Astro integration for @pramen/cms
|
|
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>
|
package/src/RichText.astro
CHANGED
|
@@ -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
|
-
|
|
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;
|