@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 +16 -1
- package/package.json +8 -4
- package/src/BlockRenderer.astro +3 -1
- package/src/RichText.astro +106 -0
- package/src/RichTextMarks.astro +55 -0
- package/src/index.ts +74 -5
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?)
|
|
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.
|
|
4
|
-
"description": "Astro integration for @pramen/cms
|
|
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": [
|
|
21
|
+
"files": [
|
|
22
|
+
"src"
|
|
23
|
+
],
|
|
20
24
|
"peerDependencies": {
|
|
21
25
|
"astro": ">=4"
|
|
22
26
|
},
|
package/src/BlockRenderer.astro
CHANGED
|
@@ -22,7 +22,9 @@ const { blocks, components, region } = Astro.props;
|
|
|
22
22
|
|
|
23
23
|
{
|
|
24
24
|
blocks.map((block) => {
|
|
25
|
-
|
|
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
|
};
|