@pramen/cms-astro 0.0.48 → 0.0.50

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,12 +3,16 @@
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
- - **`createCmsClient({ baseUrl })`** — `getPage(slug, locale?)` and `listPublishedPages()`.
6
+ - **`createCmsClient({ baseUrl })`** — `getPage(slug, locale?)`, `listPublishedPages()`, and
7
+ `getPreview(token)` to redeem a signed preview link (no session needed — the signature is
8
+ the authorization; the result carries `isPreview: true`).
7
9
  - **`cmsLoader({ client })`** — an Astro **content-collection loader**. Wire it into a
8
10
  collection and the CMS's published pages become available via `getCollection()` /
9
11
  `getEntry()`, rendered to static HTML at build time (re-run the build — a publish webhook —
10
12
  to refresh). Works with `output: 'static'`; no SSR required.
11
13
  - **`BlockRenderer.astro`** — render a page's blocks with your own `.astro` components.
14
+ - **`RichText.astro`** — render a `richtext` field. The value is a document tree, not an
15
+ HTML string, so it walks into real elements — nothing on this path uses `set:html`.
12
16
 
13
17
  ```ts
14
18
  // src/content.config.ts
@@ -40,6 +44,17 @@ const components = { rich_text: RichText, image: ImageBlock };
40
44
  <BlockRenderer blocks={page.blocks} {components} />
41
45
  ```
42
46
 
47
+ A block component renders its own fields; a `richtext` one hands the tree to `RichText`:
48
+
49
+ ```astro
50
+ ---
51
+ // src/components/blocks/RichText.astro
52
+ import RichText from "@pramen/cms-astro/RichText.astro";
53
+ const { fields } = Astro.props;
54
+ ---
55
+ <RichText value={fields.body} />
56
+ ```
57
+
43
58
  The `cmsLoader`'s default entry `data` flattens the page's own `fields` to the top level and
44
59
  adds `title / slug / locale / seo / regions / blocks` (blocks in document order). Pass
45
60
  `transform` to shape it differently, or a Zod `schema` on the collection to validate it.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pramen/cms-astro",
3
- "version": "0.0.48",
4
- "description": "Astro integration for @pramen/cms a build-time content-collection loader + a BlockRenderer for rendering CMS blocks in .astro pages.",
3
+ "version": "0.0.50",
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": {
7
7
  "type": "git",
@@ -14,9 +14,13 @@
14
14
  "sideEffects": false,
15
15
  "exports": {
16
16
  ".": "./src/index.ts",
17
- "./BlockRenderer.astro": "./src/BlockRenderer.astro"
17
+ "./BlockRenderer.astro": "./src/BlockRenderer.astro",
18
+ "./RichText.astro": "./src/RichText.astro",
19
+ "./RichTextMarks.astro": "./src/RichTextMarks.astro"
18
20
  },
19
- "files": ["src"],
21
+ "files": [
22
+ "src"
23
+ ],
20
24
  "peerDependencies": {
21
25
  "astro": ">=4"
22
26
  },
@@ -22,7 +22,9 @@ const { blocks, components, region } = Astro.props;
22
22
 
23
23
  {
24
24
  blocks.map((block) => {
25
- const Component = components[block.block_type];
25
+ // hasOwn: a block type slugged `constructor`/`valueOf` would otherwise resolve off
26
+ // the prototype and crash the render.
27
+ const Component = Object.hasOwn(components, block.block_type) ? components[block.block_type] : undefined;
26
28
  return Component ? (
27
29
  <Component fields={block.fields} block={block} region={region} />
28
30
  ) : (
@@ -0,0 +1,106 @@
1
+ ---
2
+ // Render a @pramen/cms `richtext` field. The value is a document TREE, not an HTML
3
+ // string, so this walks it into real elements — there is no `set:html` anywhere on the
4
+ // path, and nothing to sanitize at render time (the write path already dropped every node
5
+ // and mark outside the allow-list).
6
+ //
7
+ // import RichText from "@pramen/cms-astro/RichText.astro";
8
+ // <RichText value={block.fields.body} />
9
+ //
10
+ // Recurses into itself for child nodes; text leaves go through RichTextMarks.astro.
11
+
12
+ import type { RichTextDoc, RichTextNode } from "./index";
13
+ import Self from "./RichText.astro";
14
+ import Marks from "./RichTextMarks.astro";
15
+
16
+ interface Props {
17
+ /** A whole document — what a `richtext` field holds. */
18
+ value?: RichTextDoc | null;
19
+ /** Child nodes, used when this component recurses into itself. */
20
+ nodes?: RichTextNode[];
21
+ }
22
+
23
+ const { value, nodes } = Astro.props;
24
+ const list = nodes ?? value?.content ?? [];
25
+ ---
26
+
27
+ {
28
+ list.map((node) => {
29
+ const attrs = node.attrs ?? {};
30
+ const children = node.content ?? [];
31
+
32
+ switch (node.type) {
33
+ case "text":
34
+ return <Marks text={node.text ?? ""} marks={node.marks} />;
35
+ case "paragraph":
36
+ return (
37
+ <p>
38
+ <Self nodes={children} />
39
+ </p>
40
+ );
41
+ 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}`;
46
+ return (
47
+ <Tag>
48
+ <Self nodes={children} />
49
+ </Tag>
50
+ );
51
+ }
52
+ case "blockquote":
53
+ return (
54
+ <blockquote>
55
+ <Self nodes={children} />
56
+ </blockquote>
57
+ );
58
+ case "codeBlock":
59
+ return (
60
+ <pre>
61
+ <code class={typeof attrs.language === "string" ? `language-${attrs.language}` : undefined}>
62
+ <Self nodes={children} />
63
+ </code>
64
+ </pre>
65
+ );
66
+ case "bulletList":
67
+ return (
68
+ <ul>
69
+ <Self nodes={children} />
70
+ </ul>
71
+ );
72
+ case "orderedList":
73
+ return (
74
+ <ol start={typeof attrs.start === "number" ? attrs.start : undefined}>
75
+ <Self nodes={children} />
76
+ </ol>
77
+ );
78
+ case "listItem":
79
+ return (
80
+ <li>
81
+ <Self nodes={children} />
82
+ </li>
83
+ );
84
+ case "taskList":
85
+ return (
86
+ <ul data-type="taskList">
87
+ <Self nodes={children} />
88
+ </ul>
89
+ );
90
+ case "taskItem":
91
+ return (
92
+ <li data-type="taskItem" data-checked={attrs.checked === true ? "true" : "false"}>
93
+ <Self nodes={children} />
94
+ </li>
95
+ );
96
+ case "hardBreak":
97
+ return <br />;
98
+ case "horizontalRule":
99
+ return <hr />;
100
+ default:
101
+ // Unreachable through the write path (normalizeRichText drops unknown types), so
102
+ // rendering the subtree is a rescue for hand-written content, not a policy.
103
+ return <Self nodes={children} />;
104
+ }
105
+ })
106
+ }
@@ -0,0 +1,55 @@
1
+ ---
2
+ // Wrap a text leaf in its inline marks, peeling one mark per recursion. Split out of
3
+ // RichText.astro because marks nest arbitrarily (bold inside a link inside a highlight)
4
+ // and a template can't build that chain in one pass.
5
+ //
6
+ // A `link` is the only mark carrying attributes through. `target="_blank"` gets `rel`
7
+ // forced: this component owns the markup, and a bare `_blank` hands the opened page a
8
+ // live `window.opener` handle back to yours.
9
+
10
+ import { isSafeHref, normalizeHref, type RichTextMark } from "./index";
11
+ import Self from "./RichTextMarks.astro";
12
+
13
+ interface Props {
14
+ text: string;
15
+ marks?: RichTextMark[];
16
+ }
17
+
18
+ const { text, marks = [] } = Astro.props;
19
+ const [mark, ...rest] = marks;
20
+
21
+ const MARK_TAGS: Record<string, string> = {
22
+ bold: "strong",
23
+ italic: "em",
24
+ underline: "u",
25
+ strike: "s",
26
+ code: "code",
27
+ highlight: "mark",
28
+ };
29
+
30
+ const attrs = mark?.attrs ?? {};
31
+ // Astro does not sanitize attribute values, so an unchecked href here would render a
32
+ // working `javascript:` anchor for any document that skipped the write-path normalizer.
33
+ // An unsafe link degrades to its text rather than becoming a live one.
34
+ const href = isSafeHref(attrs.href) ? normalizeHref(String(attrs.href)) : "";
35
+ const isLink = mark?.type === "link" && href !== "";
36
+ const title = typeof attrs.title === "string" ? attrs.title : undefined;
37
+ const target = typeof attrs.target === "string" ? attrs.target : undefined;
38
+ // hasOwn: a plain index would resolve `constructor`/`toString` off the prototype.
39
+ const markType = mark?.type ?? "";
40
+ const Tag = Object.hasOwn(MARK_TAGS, markType) ? MARK_TAGS[markType] : "span";
41
+ ---
42
+
43
+ {
44
+ !mark ? (
45
+ text
46
+ ) : isLink ? (
47
+ <a href={href} title={title} target={target} rel={target ? "noopener noreferrer" : undefined}>
48
+ <Self text={text} marks={rest} />
49
+ </a>
50
+ ) : (
51
+ <Tag>
52
+ <Self text={text} marks={rest} />
53
+ </Tag>
54
+ )
55
+ }
package/src/index.ts CHANGED
@@ -11,6 +11,9 @@
11
11
 
12
12
  import type { Loader, LoaderContext } from "astro/loaders";
13
13
 
14
+ /** Any JSON value — the wire form of everything the CMS stores. */
15
+ export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
16
+
14
17
  /** A resolved media reference (a `"media"` block field, resolved by the CMS). */
15
18
  export interface ResolvedMedia {
16
19
  id: string;
@@ -21,8 +24,53 @@ export interface ResolvedMedia {
21
24
  filename: string | null;
22
25
  }
23
26
 
27
+ /** A rich-text document — the structured JSON a `richtext` field stores. Mirrors
28
+ * `RichTextDoc` in @pramen/cms; render it with `RichText.astro`. Never an HTML string:
29
+ * nothing on this path uses `set:html`. */
30
+ export interface RichTextDoc {
31
+ type: "doc";
32
+ content?: RichTextNode[];
33
+ }
34
+
35
+ /** One node in a {@link RichTextDoc}. */
36
+ export interface RichTextNode {
37
+ type: string;
38
+ content?: RichTextNode[];
39
+ text?: string;
40
+ marks?: RichTextMark[];
41
+ attrs?: Record<string, JsonValue>;
42
+ }
43
+
44
+ /** An inline mark on a text node. */
45
+ export interface RichTextMark {
46
+ type: string;
47
+ attrs?: Record<string, JsonValue>;
48
+ }
49
+
50
+ /** Allow-list for a link href: http(s), mailto, tel, or a relative/anchor path — a single
51
+ * leading slash, and the next character neither `/` nor `\` (both resolve off-site while
52
+ * looking local; the URL parser folds `\` to `/` at path-start).
53
+ *
54
+ * A local mirror of `isSafeHref` in @pramen/cms, because this package deliberately has no
55
+ * dependency on it. `RichText.astro` checks every link with it: the write path normalizes,
56
+ * but a row written by an app's own mutation, a bootstrap seed or an import script never
57
+ * passed through that, and this renderer is what puts it on a page. */
58
+ export function isSafeHref(raw: unknown): boolean {
59
+ return typeof raw === "string" && /^(https?:\/\/|mailto:|tel:|\/(?![/\\])|#)/i.test(normalizeHref(raw));
60
+ }
61
+
62
+ /** Strip the characters the WHATWG URL parser ignores before parsing (ASCII tab/CR/LF),
63
+ * then trim. Without this, `/\r\n/evil.example/x` passes a naive prefix test and still
64
+ * resolves to `https://evil.example/x`. Render THIS form, so what was checked is what
65
+ * the browser resolves. */
66
+ export function normalizeHref(raw: string): string {
67
+ return raw.replace(/[\t\n\r]/g, "").trim();
68
+ }
69
+
24
70
  export interface RenderedBlock {
25
71
  id: string;
72
+ /** Optimistic-concurrency token — pass back as `expectedVersion` on a write. */
73
+ version: number;
26
74
  block_id: string;
27
75
  block_type: string;
28
76
  title: string | null;
@@ -45,8 +93,13 @@ export interface AssembledPage {
45
93
  metaTitle: string | null;
46
94
  metaDescription: string | null;
47
95
  seo?: Record<string, unknown>;
96
+ /** Optimistic-concurrency token — pass back as `expectedVersion` on a write. */
97
+ version: number;
48
98
  };
49
99
  regions: Record<string, RenderedBlock[]>;
100
+ /** True when this is a live draft fetched through a preview link, not the published
101
+ * snapshot — render a "viewing a draft" banner off it. */
102
+ isPreview?: boolean;
50
103
  }
51
104
 
52
105
  export interface PublishedPageRef {
@@ -68,6 +121,9 @@ export interface CmsClientOptions {
68
121
 
69
122
  export interface CmsClient {
70
123
  getPage(slug: string, locale?: string): Promise<AssembledPage | null>;
124
+ /** Redeem a signed preview link. The token names one page and carries its own expiry,
125
+ * so this needs no session — pass through whatever arrived in the request's query. */
126
+ getPreview(token: string): Promise<AssembledPage | null>;
71
127
  listPublishedPages(): Promise<PublishedPageRef[]>;
72
128
  /** Absolute URL for a relative CMS path (e.g. a media `/media/...` url). */
73
129
  resolve(path: string): string;
@@ -78,13 +134,14 @@ export interface CmsClient {
78
134
  export function createCmsClient(opts: CmsClientOptions): CmsClient {
79
135
  const base = opts.baseUrl.replace(/\/+$/, "");
80
136
  const call = async <T>(name: string, input: unknown): Promise<T | null> => {
137
+ const headers = new Headers({
138
+ "content-type": "application/json",
139
+ "x-pramen-tenant": opts.tenant ?? "main",
140
+ });
141
+ if (opts.token) headers.set("authorization", `Bearer ${opts.token}`);
81
142
  const res = await fetch(`${base}/rpc/${name}`, {
82
143
  method: "POST",
83
- headers: {
84
- "content-type": "application/json",
85
- "x-pramen-tenant": opts.tenant ?? "main",
86
- ...(opts.token ? { authorization: `Bearer ${opts.token}` } : {}),
87
- },
144
+ headers,
88
145
  body: JSON.stringify(input ?? {}),
89
146
  });
90
147
  const body = (await res.json().catch(() => ({}))) as { ok?: boolean; result?: unknown; code?: string };
@@ -97,6 +154,18 @@ export function createCmsClient(opts: CmsClientOptions): CmsClient {
97
154
  return {
98
155
  baseUrl: base,
99
156
  getPage: (slug, locale) => call<AssembledPage>("getPage", { slug, locale }),
157
+ getPreview: async (token) => {
158
+ // Not an /rpc call: the preview route is public and pre-auth, and the signature IS
159
+ // the authorization, so there is no bearer token to send.
160
+ const res = await fetch(`${base}/cms/preview?token=${encodeURIComponent(token)}`);
161
+ if (res.ok) return (await res.json().catch(() => null)) as AssembledPage | null;
162
+ // 403/404 mean "this link is not valid" — an ordinary not-found for the caller.
163
+ // Anything else (notably 503, "preview is not configured") is an operator problem
164
+ // and must not masquerade as a missing page.
165
+ if (res.status === 403 || res.status === 404) return null;
166
+ const body = (await res.json().catch(() => ({}))) as { error?: string; code?: string };
167
+ throw new Error(`@pramen/cms-astro: preview failed (HTTP ${res.status}${body.code ? `, ${body.code}` : ""}${body.error ? `: ${body.error}` : ""})`);
168
+ },
100
169
  listPublishedPages: async () => (await call<PublishedPageRef[]>("listPublishedPages", {})) ?? [],
101
170
  resolve: (path) => (path.startsWith("http") ? path : `${base}${path}`),
102
171
  };