@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.
@@ -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.14",
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": ["dist", "src"],
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": "workspace:*"
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 }) => {