@pramen/cms 0.0.14 → 0.0.16
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/dist/index.d.ts +1076 -0
- package/dist/index.js +1360 -0
- package/dist/react.d.ts +41 -0
- package/dist/react.js +30 -0
- package/package.json +6 -3
- package/src/index.ts +34 -1
package/dist/react.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ComponentType, ReactElement } from "react";
|
|
2
|
+
import type { RenderedBlock, BlockTypeDef, BlockFieldsOf } from "./index";
|
|
3
|
+
/** Props a component for a specific typed block type receives — `fields` is inferred from
|
|
4
|
+
* the block type's schema via `BlockFieldsOf` (see `defineBlockType`). */
|
|
5
|
+
export interface TypedBlockProps<D extends BlockTypeDef> {
|
|
6
|
+
fields: BlockFieldsOf<D>;
|
|
7
|
+
block: RenderedBlock;
|
|
8
|
+
region?: string;
|
|
9
|
+
position: number;
|
|
10
|
+
}
|
|
11
|
+
/** A React component for a typed block type: `const Hero: TypedBlockComponent<typeof heroDef>`. */
|
|
12
|
+
export type TypedBlockComponent<D extends BlockTypeDef> = ComponentType<TypedBlockProps<D>>;
|
|
13
|
+
/** Props a block component receives. `fields` is the block's (override-merged) content. */
|
|
14
|
+
export interface BlockComponentProps {
|
|
15
|
+
fields: Record<string, unknown>;
|
|
16
|
+
block: RenderedBlock;
|
|
17
|
+
region?: string;
|
|
18
|
+
position: number;
|
|
19
|
+
}
|
|
20
|
+
export type BlockComponents = Record<string, ComponentType<BlockComponentProps>>;
|
|
21
|
+
export interface BlockRendererProps {
|
|
22
|
+
blocks: RenderedBlock[];
|
|
23
|
+
region?: string;
|
|
24
|
+
components: BlockComponents;
|
|
25
|
+
/** Rendered when no component is registered for a block's type. Defaults to a text node. */
|
|
26
|
+
fallback?: ComponentType<{
|
|
27
|
+
block: RenderedBlock;
|
|
28
|
+
}>;
|
|
29
|
+
}
|
|
30
|
+
/** Render an ordered list of blocks, each via its registered component. */
|
|
31
|
+
export declare function BlockRenderer({ blocks, region, components, fallback }: BlockRendererProps): ReactElement;
|
|
32
|
+
export interface RegionRendererProps {
|
|
33
|
+
regions: Record<string, RenderedBlock[]>;
|
|
34
|
+
name: string;
|
|
35
|
+
components: BlockComponents;
|
|
36
|
+
fallback?: ComponentType<{
|
|
37
|
+
block: RenderedBlock;
|
|
38
|
+
}>;
|
|
39
|
+
}
|
|
40
|
+
/** Render a single named region from an AssembledPage's `regions` map. */
|
|
41
|
+
export declare function RegionRenderer({ regions, name, components, fallback }: RegionRendererProps): ReactElement;
|
package/dist/react.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// @pramen/cms/react — the headless renderer. Like WollyCMS's BlockRenderer, it maps a
|
|
2
|
+
// block's `block_type` slug to a React component from a registry you provide; the CMS
|
|
3
|
+
// stays headless and never dictates markup. Pair with useLiveQuery from @pramen/react
|
|
4
|
+
// for a live-updating page:
|
|
5
|
+
//
|
|
6
|
+
// import { BlockRenderer, RegionRenderer } from "@pramen/cms/react";
|
|
7
|
+
// const components = { hero: Hero, rich_text: RichText, cta: Cta };
|
|
8
|
+
// const { data } = useLiveQuery(client, "getPage", { slug }); // AssembledPage
|
|
9
|
+
// return RegionRenderer({ regions: data.regions, name: "content", components });
|
|
10
|
+
//
|
|
11
|
+
// Written with `createElement` (not JSX) so the package needs no DOM lib / JSX build
|
|
12
|
+
// config and stays on the same tsconfig as the rest of pramen. In a JSX codebase you'd
|
|
13
|
+
// normally write `<BlockRenderer .../>` — it's an ordinary component either way.
|
|
14
|
+
import { createElement, Fragment } from "react";
|
|
15
|
+
/** Render an ordered list of blocks, each via its registered component. */
|
|
16
|
+
export function BlockRenderer({ blocks, region, components, fallback }) {
|
|
17
|
+
return createElement(Fragment, null, blocks.map((block, index) => {
|
|
18
|
+
const Component = components[block.block_type];
|
|
19
|
+
if (!Component) {
|
|
20
|
+
return fallback
|
|
21
|
+
? createElement(fallback, { key: block.id, block })
|
|
22
|
+
: createElement(Fragment, { key: block.id }, `Unknown block type: ${block.block_type}`);
|
|
23
|
+
}
|
|
24
|
+
return createElement(Component, { key: block.id, fields: block.fields, block, region, position: index });
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
27
|
+
/** Render a single named region from an AssembledPage's `regions` map. */
|
|
28
|
+
export function RegionRenderer({ regions, name, components, fallback }) {
|
|
29
|
+
return BlockRenderer({ blocks: regions[name] ?? [], region: name, components, fallback });
|
|
30
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pramen/cms",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.16",
|
|
4
4
|
"description": "Optional block/page builder for pramen — Drupal-Paragraphs-style typed blocks in named regions, reusable blocks, scheduled publishing, built entirely from pramen primitives.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -30,7 +30,10 @@
|
|
|
30
30
|
},
|
|
31
31
|
"main": "./dist/index.js",
|
|
32
32
|
"types": "./dist/index.d.ts",
|
|
33
|
-
"files": [
|
|
33
|
+
"files": [
|
|
34
|
+
"dist",
|
|
35
|
+
"src"
|
|
36
|
+
],
|
|
34
37
|
"scripts": {
|
|
35
38
|
"build": "rm -rf dist && tsc -p tsconfig.build.json"
|
|
36
39
|
},
|
|
@@ -38,7 +41,7 @@
|
|
|
38
41
|
"access": "public"
|
|
39
42
|
},
|
|
40
43
|
"dependencies": {
|
|
41
|
-
"@pramen/server": "
|
|
44
|
+
"@pramen/server": "0.0.16"
|
|
42
45
|
},
|
|
43
46
|
"peerDependencies": {
|
|
44
47
|
"react": ">=18"
|
package/src/index.ts
CHANGED
|
@@ -58,6 +58,8 @@ export interface FieldDefinition {
|
|
|
58
58
|
| "url"
|
|
59
59
|
| "number"
|
|
60
60
|
| "boolean"
|
|
61
|
+
| "date"
|
|
62
|
+
| "datetime"
|
|
61
63
|
| "media"
|
|
62
64
|
| "select"
|
|
63
65
|
| "repeater"
|
|
@@ -101,7 +103,7 @@ export type RichText = string | { type: string; content?: unknown[] };
|
|
|
101
103
|
|
|
102
104
|
/** Map one FieldDefinition (as a const literal) to the TS type of its RENDERED value.
|
|
103
105
|
* Media resolves to `ResolvedMedia` (the assemble-time shape a component receives). */
|
|
104
|
-
export type FieldTsType<D extends FieldDefinition> = D["type"] extends "text" | "textarea" | "url" | "select"
|
|
106
|
+
export type FieldTsType<D extends FieldDefinition> = D["type"] extends "text" | "textarea" | "url" | "select" | "date" | "datetime"
|
|
105
107
|
? string
|
|
106
108
|
: D["type"] extends "richtext"
|
|
107
109
|
? RichText
|
|
@@ -171,6 +173,8 @@ function tsTypeOf(f: FieldDefinition): string {
|
|
|
171
173
|
case "textarea":
|
|
172
174
|
case "url":
|
|
173
175
|
case "select":
|
|
176
|
+
case "date":
|
|
177
|
+
case "datetime":
|
|
174
178
|
return "string";
|
|
175
179
|
case "richtext":
|
|
176
180
|
return "RichText";
|
|
@@ -356,6 +360,15 @@ export interface ValidateOpts {
|
|
|
356
360
|
|
|
357
361
|
/** Validate a block/page's `fields` payload against a field schema, throwing a 400 on
|
|
358
362
|
* the first violation. Recursive (repeater/group). Lenient on unknown field types. */
|
|
363
|
+
/** A calendar date, `YYYY-MM-DD` (what an <input type="date"> emits) — must also parse. */
|
|
364
|
+
function isDateString(v: string): boolean {
|
|
365
|
+
return /^\d{4}-\d{2}-\d{2}$/.test(v) && Number.isFinite(Date.parse(v));
|
|
366
|
+
}
|
|
367
|
+
/** A date-time: `YYYY-MM-DDTHH:MM[:SS[.sss]][Z|±HH:MM]` (ISO 8601 / datetime-local). */
|
|
368
|
+
function isDateTimeString(v: string): boolean {
|
|
369
|
+
return /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/.test(v) && Number.isFinite(Date.parse(v));
|
|
370
|
+
}
|
|
371
|
+
|
|
359
372
|
export function validateFields(schema: FieldDefinition[] | undefined | null, values: unknown, path = "", opts: ValidateOpts = {}): void {
|
|
360
373
|
const requireRequired = opts.requireRequired !== false;
|
|
361
374
|
const defs = Array.isArray(schema) ? schema : [];
|
|
@@ -388,6 +401,12 @@ export function validateFields(schema: FieldDefinition[] | undefined | null, val
|
|
|
388
401
|
case "boolean":
|
|
389
402
|
if (typeof v !== "boolean") throw new BadRequest(`field '${at}' must be a boolean`);
|
|
390
403
|
break;
|
|
404
|
+
case "date":
|
|
405
|
+
if (typeof v !== "string" || !isDateString(v)) throw new BadRequest(`field '${at}' must be a date (YYYY-MM-DD)`);
|
|
406
|
+
break;
|
|
407
|
+
case "datetime":
|
|
408
|
+
if (typeof v !== "string" || !isDateTimeString(v)) throw new BadRequest(`field '${at}' must be a date-time (ISO 8601)`);
|
|
409
|
+
break;
|
|
391
410
|
case "media":
|
|
392
411
|
// Media ids are uuids (strings) — reject numbers so the value always resolves
|
|
393
412
|
// (collectMediaIds/resolveMediaFields only handle string ids).
|
|
@@ -872,6 +891,20 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
|
|
|
872
891
|
},
|
|
873
892
|
}),
|
|
874
893
|
|
|
894
|
+
/** Edit a media asset's metadata (currently just `alt` text). Editor-gated. */
|
|
895
|
+
updateMedia: mutation(async (ctx, input: { id: string; alt: string | null }) => {
|
|
896
|
+
const updated = await cdb(ctx).update("cms_media", input.id, { alt: input.alt ?? null });
|
|
897
|
+
if (!updated) throw notFound("media");
|
|
898
|
+
return updated;
|
|
899
|
+
}, {
|
|
900
|
+
...editor,
|
|
901
|
+
input: (raw): { id: string; alt: string | null } => {
|
|
902
|
+
const o = asObj(raw);
|
|
903
|
+
if (typeof o.id !== "string") throw new BadRequest("id is required");
|
|
904
|
+
return { id: o.id, alt: typeof o.alt === "string" ? o.alt : null };
|
|
905
|
+
},
|
|
906
|
+
}),
|
|
907
|
+
|
|
875
908
|
/** Delete a media row AND its R2 blob. (Automatic orphan sweeping — media no longer
|
|
876
909
|
* referenced by any block — is future work; refs live inside opaque block JSON.) */
|
|
877
910
|
deleteMedia: mutation(async (ctx, input: { id: string }) => {
|