@pramen/cms-astro 0.0.52 → 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
@@ -58,6 +58,65 @@ your deployment does need auth for reads, pass `backend: { token }`.
58
58
  the front door, not a replacement. A site that wants to define its own collections by hand
59
59
  still can.
60
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
+
61
120
  ## The kit of parts
62
121
 
63
122
  - **`createCmsClient({ baseUrl })`** — `getPage(slug, locale?)`, `listPublishedPages()`, and
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pramen/cms-astro",
3
- "version": "0.0.52",
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",
@@ -15,7 +15,9 @@
15
15
  "exports": {
16
16
  ".": "./src/index.ts",
17
17
  "./integration": "./src/integration.ts",
18
+ "./admin": "./src/admin.ts",
18
19
  "./BlockRenderer.astro": "./src/BlockRenderer.astro",
20
+ "./PramenAdmin.astro": "./src/PramenAdmin.astro",
19
21
  "./RichText.astro": "./src/RichText.astro",
20
22
  "./RichTextMarks.astro": "./src/RichTextMarks.astro"
21
23
  },
@@ -23,7 +25,16 @@
23
25
  "src"
24
26
  ],
25
27
  "peerDependencies": {
26
- "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"
27
38
  },
28
39
  "publishConfig": {
29
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
@@ -17,6 +17,10 @@ import type { Loader, LoaderContext } from "astro/loaders";
17
17
  export { pramenCms, default } from "./integration.js";
18
18
  export type { CmsBackend, CollectionMap, PramenCmsOptions } from "./integration.js";
19
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
+
20
24
  /** Any JSON value — the wire form of everything the CMS stores. */
21
25
  export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
22
26
 
@@ -24,7 +24,8 @@
24
24
  // changes, instead of one `defineCollection` per type that has to track the store.
25
25
 
26
26
  import type { AstroIntegration } from "astro";
27
- import { createCmsClient, type CmsClient } from "./index.js";
27
+ import { fileURLToPath } from "node:url";
28
+ import { ADMIN_BASE, ADMIN_ROUTE, adminDocumentTitle, adminRuntimeConfig, serializeAdminConfig, type AdminOptions } from "./admin.js";
28
29
 
29
30
  /** Where the CMS lives. A named descriptor rather than a bare `baseUrl` string, so a future
30
31
  * local/in-process backend can be added without changing the call shape. */
@@ -60,12 +61,33 @@ export interface PramenCmsOptions {
60
61
  collections?: "auto" | CollectionMap;
61
62
  /** Restrict every generated collection to one locale. Omit to load all of them. */
62
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;
63
80
  }
64
81
 
65
82
  /** The virtual module the site imports from. */
66
83
  const VIRTUAL_ID = "pramen:cms";
67
84
  const RESOLVED_ID = "\0pramen:cms";
68
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
+
69
91
  /** Ask the CMS which content types exist. Public and un-gated (`listPublicContentTypes`),
70
92
  * because this runs at BUILD time where there is no editor session — and a content type's
71
93
  * slug is already public: `listPublishedPages` returns it for every published page. */
@@ -117,6 +139,18 @@ ${entries}
117
139
  `;
118
140
  }
119
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
+
120
154
  /** The `pramen:cms` module's types, injected so the site gets them with no manual d.ts. */
121
155
  const TYPES = `declare module "pramen:cms" {
122
156
  import type { CmsClient } from "@pramen/cms-astro";
@@ -130,6 +164,17 @@ const TYPES = `declare module "pramen:cms" {
130
164
  }
131
165
  `;
132
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
+
133
178
  /**
134
179
  * The Astro integration for a @pramen/cms backend.
135
180
  *
@@ -151,7 +196,7 @@ export function pramenCms(opts: PramenCmsOptions): AstroIntegration {
151
196
  return {
152
197
  name: "@pramen/cms-astro",
153
198
  hooks: {
154
- "astro:config:setup": async ({ updateConfig, logger }) => {
199
+ "astro:config:setup": async ({ updateConfig, injectRoute, logger }) => {
155
200
  const wanted = opts.collections ?? "auto";
156
201
  const map: CollectionMap = wanted === "auto" ? Object.fromEntries((await discoverTypes(opts.backend)).map((s) => [collectionKey(s), s])) : wanted;
157
202
  const names = Object.keys(map);
@@ -164,8 +209,16 @@ export function pramenCms(opts: PramenCmsOptions): AstroIntegration {
164
209
  logger.info(`${names.length} collection(s) from ${opts.backend.url}: ${names.join(", ")}`);
165
210
  }
166
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;
167
215
  updateConfig({
168
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"],
169
222
  plugins: [
170
223
  {
171
224
  name: "pramen:cms",
@@ -173,18 +226,29 @@ export function pramenCms(opts: PramenCmsOptions): AstroIntegration {
173
226
  // an unknown bare specifier and reports it as a missing package.
174
227
  enforce: "pre" as const,
175
228
  resolveId(id: string) {
176
- return id === VIRTUAL_ID ? RESOLVED_ID : null;
229
+ if (id === VIRTUAL_ID) return RESOLVED_ID;
230
+ if (id === ADMIN_VIRTUAL_ID && adminCode) return ADMIN_RESOLVED_ID;
231
+ return null;
177
232
  },
178
233
  load(id: string) {
179
- return id === RESOLVED_ID ? code : null;
234
+ if (id === RESOLVED_ID) return code;
235
+ if (id === ADMIN_RESOLVED_ID) return adminCode;
236
+ return null;
180
237
  },
181
238
  },
182
239
  ],
183
240
  },
184
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
+ }
185
249
  },
186
250
  "astro:config:done": ({ injectTypes }) => {
187
- injectTypes({ filename: "pramen-cms.d.ts", content: TYPES });
251
+ injectTypes({ filename: "pramen-cms.d.ts", content: opts.admin ? `${TYPES}\n${ADMIN_TYPES}` : TYPES });
188
252
  },
189
253
  },
190
254
  };