@pramen/cms-astro 0.0.14

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 ADDED
@@ -0,0 +1,48 @@
1
+ # @pramen/cms-astro
2
+
3
+ Consume a [`@pramen/cms`](../cms) backend from an **Astro** site. Self-contained (no
4
+ `@pramen/server` dependency — it speaks the CMS's public HTTP content API).
5
+
6
+ - **`createCmsClient({ baseUrl })`** — `getPage(slug, locale?)` and `listPublishedPages()`.
7
+ - **`cmsLoader({ client })`** — an Astro **content-collection loader**. Wire it into a
8
+ collection and the CMS's published pages become available via `getCollection()` /
9
+ `getEntry()`, rendered to static HTML at build time (re-run the build — a publish webhook —
10
+ to refresh). Works with `output: 'static'`; no SSR required.
11
+ - **`BlockRenderer.astro`** — render a page's blocks with your own `.astro` components.
12
+
13
+ ```ts
14
+ // src/content.config.ts
15
+ import { defineCollection } from "astro:content";
16
+ import { createCmsClient, cmsLoader } from "@pramen/cms-astro";
17
+
18
+ const client = createCmsClient({ baseUrl: import.meta.env.CMS_URL });
19
+ export const collections = {
20
+ clanky: defineCollection({ loader: cmsLoader({ client, locale: "cs" }) }),
21
+ };
22
+ ```
23
+
24
+ ```astro
25
+ ---
26
+ // src/pages/clanky/[slug].astro
27
+ import { getCollection, getEntry } from "astro:content";
28
+ import BlockRenderer from "@pramen/cms-astro/BlockRenderer.astro";
29
+ import RichText from "../../components/blocks/RichText.astro";
30
+ import ImageBlock from "../../components/blocks/ImageBlock.astro";
31
+
32
+ export async function getStaticPaths() {
33
+ const pages = await getCollection("clanky");
34
+ return pages.map((p) => ({ params: { slug: p.id }, props: { page: p.data } }));
35
+ }
36
+ const { page } = Astro.props;
37
+ const components = { rich_text: RichText, image: ImageBlock };
38
+ ---
39
+ <h1>{page.title}</h1>
40
+ <BlockRenderer blocks={page.blocks} {components} />
41
+ ```
42
+
43
+ The `cmsLoader`'s default entry `data` flattens the page's own `fields` to the top level and
44
+ adds `title / slug / locale / seo / regions / blocks` (blocks in document order). Pass
45
+ `transform` to shape it differently, or a Zod `schema` on the collection to validate it.
46
+
47
+ Media `"media"` fields arrive already resolved to `{ url, alt, ... }`; use `client.resolve()`
48
+ (or Cloudflare Image Resizing) to turn a relative `/media/...` url into an absolute one.
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@pramen/cms-astro",
3
+ "version": "0.0.14",
4
+ "description": "Astro integration for @pramen/cms — a build-time content-collection loader + a BlockRenderer for rendering CMS blocks in .astro pages.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/netvarec/pramen.git",
9
+ "directory": "packages/cms-astro"
10
+ },
11
+ "homepage": "https://github.com/netvarec/pramen#readme",
12
+ "bugs": "https://github.com/netvarec/pramen/issues",
13
+ "type": "module",
14
+ "sideEffects": false,
15
+ "exports": {
16
+ ".": "./src/index.ts",
17
+ "./BlockRenderer.astro": "./src/BlockRenderer.astro"
18
+ },
19
+ "files": ["src"],
20
+ "peerDependencies": {
21
+ "astro": ">=4"
22
+ },
23
+ "publishConfig": {
24
+ "access": "public"
25
+ }
26
+ }
@@ -0,0 +1,32 @@
1
+ ---
2
+ // Render a list of @pramen/cms blocks with your own .astro components. Map each block's
3
+ // `block_type` slug to a component; each component receives `fields` (the resolved block
4
+ // content), `block` (the RenderedBlock), and `region`.
5
+ //
6
+ // import BlockRenderer from "@pramen/cms-astro/BlockRenderer.astro";
7
+ // import RichText from "../components/blocks/RichText.astro";
8
+ // const components = { rich_text: RichText, image: Image };
9
+ // <BlockRenderer blocks={page.blocks} {components} />
10
+
11
+ import type { RenderedBlock } from "./index";
12
+ import type { AstroComponentFactory } from "astro/runtime/server/index.js";
13
+
14
+ interface Props {
15
+ blocks: RenderedBlock[];
16
+ components: Record<string, AstroComponentFactory>;
17
+ region?: string;
18
+ }
19
+
20
+ const { blocks, components, region } = Astro.props;
21
+ ---
22
+
23
+ {
24
+ blocks.map((block) => {
25
+ const Component = components[block.block_type];
26
+ return Component ? (
27
+ <Component fields={block.fields} block={block} region={region} />
28
+ ) : (
29
+ <div data-unknown-block={block.block_type}>Unknown block type: {block.block_type}</div>
30
+ );
31
+ })
32
+ }
package/src/index.ts ADDED
@@ -0,0 +1,153 @@
1
+ // @pramen/cms-astro — consume a @pramen/cms backend from a (static or SSR) Astro site.
2
+ //
3
+ // - `createCmsClient({ baseUrl })` — fetch the public content API (getPage, listPublishedPages).
4
+ // - `cmsLoader({ client })` — an Astro **content-collection loader** that pulls published
5
+ // pages at BUILD time, so `defineCollection({ loader: cmsLoader(...) })` makes CMS content
6
+ // available via `getCollection()` / `getEntry()` and the site renders it as static HTML.
7
+ // Re-run the build (a publish webhook) to refresh.
8
+ // - `BlockRenderer.astro` (separate import) renders a page's blocks with your components.
9
+ //
10
+ // Self-contained: no @pramen/server dependency — it just speaks the CMS's HTTP RPC.
11
+
12
+ import type { Loader, LoaderContext } from "astro/loaders";
13
+
14
+ /** A resolved media reference (a `"media"` block field, resolved by the CMS). */
15
+ export interface ResolvedMedia {
16
+ id: string;
17
+ key: string;
18
+ url: string;
19
+ alt: string | null;
20
+ contentType: string | null;
21
+ filename: string | null;
22
+ }
23
+
24
+ export interface RenderedBlock {
25
+ id: string;
26
+ block_id: string;
27
+ block_type: string;
28
+ title: string | null;
29
+ fields: Record<string, unknown>;
30
+ is_shared: boolean;
31
+ }
32
+
33
+ export interface AssembledPage {
34
+ page: {
35
+ id: string;
36
+ title: string;
37
+ slug: string;
38
+ status: string;
39
+ locale: string;
40
+ translationGroupId: string | null;
41
+ translations: { locale: string; slug: string }[];
42
+ fields: Record<string, unknown> | null;
43
+ metaTitle: string | null;
44
+ metaDescription: string | null;
45
+ seo?: Record<string, unknown>;
46
+ };
47
+ regions: Record<string, RenderedBlock[]>;
48
+ }
49
+
50
+ export interface PublishedPageRef {
51
+ slug: string;
52
+ locale: string;
53
+ updatedAt: string;
54
+ }
55
+
56
+ export interface CmsClientOptions {
57
+ /** Base URL of the deployed @pramen/cms Worker, e.g. https://cms.example.workers.dev. */
58
+ baseUrl: string;
59
+ /** Tenant (`x-pramen-tenant`). Default "main". */
60
+ tenant?: string;
61
+ /** Optional bearer token (only needed to fetch drafts via preview). */
62
+ token?: string;
63
+ }
64
+
65
+ export interface CmsClient {
66
+ getPage(slug: string, locale?: string): Promise<AssembledPage | null>;
67
+ listPublishedPages(): Promise<PublishedPageRef[]>;
68
+ /** Absolute URL for a relative CMS path (e.g. a media `/media/...` url). */
69
+ resolve(path: string): string;
70
+ readonly baseUrl: string;
71
+ }
72
+
73
+ /** A typed HTTP client for the CMS public content API. */
74
+ export function createCmsClient(opts: CmsClientOptions): CmsClient {
75
+ const base = opts.baseUrl.replace(/\/+$/, "");
76
+ const call = async <T>(name: string, input: unknown): Promise<T | null> => {
77
+ const res = await fetch(`${base}/rpc/${name}`, {
78
+ method: "POST",
79
+ headers: {
80
+ "content-type": "application/json",
81
+ "x-pramen-tenant": opts.tenant ?? "main",
82
+ ...(opts.token ? { authorization: `Bearer ${opts.token}` } : {}),
83
+ },
84
+ body: JSON.stringify(input ?? {}),
85
+ });
86
+ const body = (await res.json().catch(() => ({}))) as { ok?: boolean; result?: unknown; code?: string };
87
+ if (body.ok !== true) {
88
+ if (body.code === "not_found") return null;
89
+ throw new Error(`@pramen/cms-astro: ${name} failed (HTTP ${res.status}${body.code ? `, ${body.code}` : ""})`);
90
+ }
91
+ return body.result as T;
92
+ };
93
+ return {
94
+ baseUrl: base,
95
+ getPage: (slug, locale) => call<AssembledPage>("getPage", { slug, locale }),
96
+ listPublishedPages: async () => (await call<PublishedPageRef[]>("listPublishedPages", {})) ?? [],
97
+ resolve: (path) => (path.startsWith("http") ? path : `${base}${path}`),
98
+ };
99
+ }
100
+
101
+ export interface CmsLoaderOptions {
102
+ client: CmsClient;
103
+ /** Restrict to one locale (else every published page of every locale is loaded). */
104
+ locale?: string;
105
+ /**
106
+ * Map an AssembledPage to the entry `data` stored in the collection. Default flattens the
107
+ * page's own `fields` up to the top level and adds `title/slug/locale/seo/regions/blocks`
108
+ * (all blocks in document order) — so an Astro schema can read the page's fields directly.
109
+ */
110
+ transform?: (page: AssembledPage) => Record<string, unknown>;
111
+ }
112
+
113
+ const defaultTransform = (p: AssembledPage): Record<string, unknown> => {
114
+ const blocks = Object.values(p.regions).flat();
115
+ return {
116
+ ...(p.page.fields ?? {}),
117
+ title: p.page.title,
118
+ slug: p.page.slug,
119
+ locale: p.page.locale,
120
+ status: p.page.status,
121
+ seo: p.page.seo ?? null,
122
+ translations: p.page.translations,
123
+ regions: p.regions,
124
+ blocks,
125
+ };
126
+ };
127
+
128
+ /** An Astro content-collection loader backed by a @pramen/cms Worker. Fetches every
129
+ * published page at build time; the entry `id` is the page slug. Wire it into a collection:
130
+ *
131
+ * const clanky = defineCollection({ loader: cmsLoader({ client }) });
132
+ */
133
+ export function cmsLoader(opts: CmsLoaderOptions): Loader {
134
+ const transform = opts.transform ?? defaultTransform;
135
+ return {
136
+ name: "@pramen/cms",
137
+ async load({ store, logger, parseData, generateDigest }: LoaderContext): Promise<void> {
138
+ const refs = await opts.client.listPublishedPages();
139
+ const wanted = opts.locale ? refs.filter((r) => r.locale === opts.locale) : refs;
140
+ store.clear();
141
+ let loaded = 0;
142
+ for (const ref of wanted) {
143
+ const page = await opts.client.getPage(ref.slug, ref.locale);
144
+ if (!page) continue;
145
+ const raw = transform(page);
146
+ const data = await parseData({ id: ref.slug, data: raw });
147
+ store.set({ id: ref.slug, data, digest: generateDigest(data) });
148
+ loaded++;
149
+ }
150
+ logger.info(`@pramen/cms: loaded ${loaded} published page(s)`);
151
+ },
152
+ };
153
+ }