@pramen/cms 0.0.49 → 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 +364 -1
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +138 -0
- package/dist/href.d.ts +14 -0
- package/dist/href.js +22 -0
- package/dist/index.d.ts +519 -19
- package/dist/index.js +1726 -83
- package/dist/react.d.ts +15 -2
- package/dist/react.js +96 -1
- package/package.json +8 -3
- package/src/cli.ts +148 -0
- package/src/href.ts +24 -0
- package/src/index.ts +1958 -91
- package/src/react.ts +132 -3
package/dist/react.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { ComponentType, ReactElement } from "react";
|
|
2
|
-
import type { RenderedBlock, BlockTypeDef, BlockFieldsOf } from "./index";
|
|
1
|
+
import type { ComponentType, ReactElement, ReactNode } from "react";
|
|
2
|
+
import type { RenderedBlock, BlockTypeDef, BlockFieldsOf, RichTextDoc, RichTextNode } from "./index";
|
|
3
3
|
/** Props a component for a specific typed block type receives — `fields` is inferred from
|
|
4
4
|
* the block type's schema via `BlockFieldsOf` (see `defineBlockType`). */
|
|
5
5
|
export interface TypedBlockProps<D extends BlockTypeDef> {
|
|
@@ -39,3 +39,16 @@ export interface RegionRendererProps {
|
|
|
39
39
|
}
|
|
40
40
|
/** Render a single named region from an AssembledPage's `regions` map. */
|
|
41
41
|
export declare function RegionRenderer({ regions, name, components, fallback }: RegionRendererProps): ReactElement;
|
|
42
|
+
/** Props a rich-text node component receives. `children` is the already-rendered subtree. */
|
|
43
|
+
export interface RichTextNodeProps {
|
|
44
|
+
node: RichTextNode;
|
|
45
|
+
children: ReactNode;
|
|
46
|
+
}
|
|
47
|
+
/** Per-node-type component overrides, keyed by node type (`"paragraph"`, `"heading"`, …). */
|
|
48
|
+
export type RichTextNodeComponents = Record<string, ComponentType<RichTextNodeProps>>;
|
|
49
|
+
export interface RichTextRendererProps {
|
|
50
|
+
value: RichTextDoc | null | undefined;
|
|
51
|
+
components?: RichTextNodeComponents;
|
|
52
|
+
}
|
|
53
|
+
/** Render a rich-text document as React elements. */
|
|
54
|
+
export declare function RichTextRenderer({ value, components }: RichTextRendererProps): ReactElement;
|
package/dist/react.js
CHANGED
|
@@ -12,10 +12,13 @@
|
|
|
12
12
|
// config and stays on the same tsconfig as the rest of pramen. In a JSX codebase you'd
|
|
13
13
|
// normally write `<BlockRenderer .../>` — it's an ordinary component either way.
|
|
14
14
|
import { createElement, Fragment } from "react";
|
|
15
|
+
import { isSafeHref, normalizeHref } from "./href";
|
|
15
16
|
/** Render an ordered list of blocks, each via its registered component. */
|
|
16
17
|
export function BlockRenderer({ blocks, region, components, fallback }) {
|
|
17
18
|
return createElement(Fragment, null, blocks.map((block, index) => {
|
|
18
|
-
|
|
19
|
+
// hasOwn: a block type slugged `constructor`/`valueOf` would otherwise resolve off
|
|
20
|
+
// the prototype and crash the whole page render (createBlockType accepts any string).
|
|
21
|
+
const Component = Object.hasOwn(components, block.block_type) ? components[block.block_type] : undefined;
|
|
19
22
|
if (!Component) {
|
|
20
23
|
return fallback
|
|
21
24
|
? createElement(fallback, { key: block.id, block })
|
|
@@ -28,3 +31,95 @@ export function BlockRenderer({ blocks, region, components, fallback }) {
|
|
|
28
31
|
export function RegionRenderer({ regions, name, components, fallback }) {
|
|
29
32
|
return BlockRenderer({ blocks: regions[name] ?? [], region: name, components, fallback });
|
|
30
33
|
}
|
|
34
|
+
const MARK_TAGS = {
|
|
35
|
+
bold: "strong",
|
|
36
|
+
italic: "em",
|
|
37
|
+
underline: "u",
|
|
38
|
+
strike: "s",
|
|
39
|
+
code: "code",
|
|
40
|
+
highlight: "mark",
|
|
41
|
+
};
|
|
42
|
+
/** Wrap a text leaf in its marks, innermost-first. A `link` is the only mark carrying
|
|
43
|
+
* attributes we pass through; `target="_blank"` gets `rel` forced, since the renderer owns
|
|
44
|
+
* the markup and a bare `_blank` hands the opened page a `window.opener` handle. */
|
|
45
|
+
function renderMarks(text, marks) {
|
|
46
|
+
let out = text;
|
|
47
|
+
// Innermost-first, so `marks[0]` ends up OUTERMOST — matching ProseMirror's own
|
|
48
|
+
// serializer and RichTextMarks.astro. Folding forwards put marks[0] innermost, so the
|
|
49
|
+
// same document rendered `<code><a>x</a></code>` here and `<a><code>x</code></a>` in
|
|
50
|
+
// Astro: different clickable area, different CSS selectors, same content.
|
|
51
|
+
for (let i = (marks?.length ?? 0) - 1; i >= 0; i--) {
|
|
52
|
+
const mark = marks[i];
|
|
53
|
+
if (mark.type === "link") {
|
|
54
|
+
const attrs = mark.attrs ?? {};
|
|
55
|
+
// The href is the one attribute that can execute script, and this renderer declares
|
|
56
|
+
// itself a rescue for content that never passed through normalizeRichText (an app
|
|
57
|
+
// writing rows via its own mutation, a bootstrap seed, an import). So check it here
|
|
58
|
+
// too: an unsafe href drops the anchor and renders the text, never a live link.
|
|
59
|
+
if (!isSafeHref(attrs.href))
|
|
60
|
+
continue;
|
|
61
|
+
const target = typeof attrs.target === "string" ? attrs.target : undefined;
|
|
62
|
+
out = createElement("a", {
|
|
63
|
+
href: normalizeHref(String(attrs.href ?? "")),
|
|
64
|
+
title: typeof attrs.title === "string" ? attrs.title : undefined,
|
|
65
|
+
target,
|
|
66
|
+
// Any named target opens a window holding a live `window.opener`, not just
|
|
67
|
+
// `_blank` — browsers imply noopener for `_blank` alone.
|
|
68
|
+
rel: target ? "noopener noreferrer" : undefined,
|
|
69
|
+
}, out);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
// hasOwn: a plain index would resolve `constructor`/`toString` off the prototype.
|
|
73
|
+
out = createElement(Object.hasOwn(MARK_TAGS, mark.type) ? MARK_TAGS[mark.type] : "span", null, out);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
function renderRichTextNode(node, key, components) {
|
|
79
|
+
if (node.type === "text")
|
|
80
|
+
return createElement(Fragment, { key }, renderMarks(node.text ?? "", node.marks));
|
|
81
|
+
const children = (node.content ?? []).map((child, i) => renderRichTextNode(child, i, components));
|
|
82
|
+
const Override = components && Object.hasOwn(components, node.type) ? components[node.type] : undefined;
|
|
83
|
+
if (Override)
|
|
84
|
+
return createElement(Override, { key, node, children });
|
|
85
|
+
const attrs = node.attrs ?? {};
|
|
86
|
+
switch (node.type) {
|
|
87
|
+
case "paragraph":
|
|
88
|
+
return createElement("p", { key }, children);
|
|
89
|
+
case "heading": {
|
|
90
|
+
// Range-checked, matching RichText.astro. Both renderers declare themselves a rescue
|
|
91
|
+
// for hand-written content that never passed through normalizeRichText, so neither
|
|
92
|
+
// may trust the attribute — an out-of-range level would emit <h0>/<h99>.
|
|
93
|
+
// Integer too: `h2.5` throws InvalidCharacterError in document.createElement.
|
|
94
|
+
const raw = attrs.level;
|
|
95
|
+
const level = typeof raw === "number" && Number.isInteger(raw) && raw >= 1 && raw <= 6 ? raw : 2;
|
|
96
|
+
return createElement(`h${level}`, { key }, children);
|
|
97
|
+
}
|
|
98
|
+
case "blockquote":
|
|
99
|
+
return createElement("blockquote", { key }, children);
|
|
100
|
+
case "codeBlock":
|
|
101
|
+
return createElement("pre", { key }, createElement("code", { className: typeof attrs.language === "string" ? `language-${attrs.language}` : undefined }, children));
|
|
102
|
+
case "bulletList":
|
|
103
|
+
return createElement("ul", { key }, children);
|
|
104
|
+
case "orderedList":
|
|
105
|
+
return createElement("ol", { key, start: typeof attrs.start === "number" ? attrs.start : undefined }, children);
|
|
106
|
+
case "listItem":
|
|
107
|
+
return createElement("li", { key }, children);
|
|
108
|
+
case "taskList":
|
|
109
|
+
return createElement("ul", { key, "data-type": "taskList" }, children);
|
|
110
|
+
case "taskItem":
|
|
111
|
+
return createElement("li", { key, "data-type": "taskItem", "data-checked": attrs.checked === true ? "true" : "false" }, children);
|
|
112
|
+
case "hardBreak":
|
|
113
|
+
return createElement("br", { key });
|
|
114
|
+
case "horizontalRule":
|
|
115
|
+
return createElement("hr", { key });
|
|
116
|
+
default:
|
|
117
|
+
// Unreachable through the write path (normalizeRichText drops unknown types), so
|
|
118
|
+
// rendering the subtree is a rescue for hand-written content, not a policy.
|
|
119
|
+
return createElement(Fragment, { key }, children);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** Render a rich-text document as React elements. */
|
|
123
|
+
export function RichTextRenderer({ value, components }) {
|
|
124
|
+
return createElement(Fragment, null, (value?.content ?? []).map((node, i) => renderRichTextNode(node, i, components)));
|
|
125
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pramen/cms",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.50",
|
|
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": {
|
|
@@ -41,8 +41,7 @@
|
|
|
41
41
|
"access": "public"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@pramen/server": "0.0.
|
|
45
|
-
"xss": "^1.0.15"
|
|
44
|
+
"@pramen/server": "0.0.50"
|
|
46
45
|
},
|
|
47
46
|
"peerDependencies": {
|
|
48
47
|
"react": ">=18"
|
|
@@ -51,5 +50,11 @@
|
|
|
51
50
|
"react": {
|
|
52
51
|
"optional": true
|
|
53
52
|
}
|
|
53
|
+
},
|
|
54
|
+
"bin": {
|
|
55
|
+
"pramen-cms": "./dist/cli.js"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@types/node": "^22.0.0"
|
|
54
59
|
}
|
|
55
60
|
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @pramen/cms CLI — ships as the `pramen-cms` bin.
|
|
3
|
+
//
|
|
4
|
+
// pramen-cms help
|
|
5
|
+
// pramen-cms types [--tenant t] [--url u] [--token jwt] [--out path]
|
|
6
|
+
//
|
|
7
|
+
// A separate bin from `pramen` on purpose: @pramen/cms is optional, so the runtime CLI
|
|
8
|
+
// should not carry a command named after it. `pramen` knows about schemas, tokens and
|
|
9
|
+
// scaffolding; this knows about content types. Both mint their dev token with the same
|
|
10
|
+
// `signDevToken` from @pramen/server, so there is one signer, not two.
|
|
11
|
+
//
|
|
12
|
+
// Bun shebang, matching the `pramen` bin: the built dist/ uses extensionless ESM imports.
|
|
13
|
+
|
|
14
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { dirname, resolve } from "node:path";
|
|
16
|
+
import { signDevToken } from "@pramen/server/dev";
|
|
17
|
+
import { generateBlockTypes, type FieldDefinition } from "./index";
|
|
18
|
+
|
|
19
|
+
const argv = process.argv.slice(2);
|
|
20
|
+
|
|
21
|
+
const KNOWN_FLAGS = ["url", "tenant", "token", "out"] as const;
|
|
22
|
+
|
|
23
|
+
/** Read `--name value` or `--name=value`.
|
|
24
|
+
*
|
|
25
|
+
* Both forms, because `--out=path` silently printed to stdout and exited 0 — a green
|
|
26
|
+
* regenerate-and-diff CI with no file written. And a value is required, because `--out`
|
|
27
|
+
* with an empty $OUT did the same, while `--tenant --out x` set the tenant to "--out". */
|
|
28
|
+
function flag(name: string): string | undefined {
|
|
29
|
+
const eq = argv.find((a) => a.startsWith(`--${name}=`));
|
|
30
|
+
if (eq !== undefined) {
|
|
31
|
+
const v = eq.slice(name.length + 3);
|
|
32
|
+
if (v === "") fail(`--${name} needs a value`);
|
|
33
|
+
return v;
|
|
34
|
+
}
|
|
35
|
+
const i = argv.indexOf(`--${name}`);
|
|
36
|
+
if (i < 0) return undefined;
|
|
37
|
+
const v = argv[i + 1];
|
|
38
|
+
if (v === undefined || v.startsWith("--")) fail(`--${name} needs a value`);
|
|
39
|
+
return v;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Reject a misspelled flag rather than silently ignoring it and using the default. */
|
|
43
|
+
function assertKnownFlags(): void {
|
|
44
|
+
for (const a of argv) {
|
|
45
|
+
if (!a.startsWith("--")) continue;
|
|
46
|
+
const name = a.slice(2).split("=")[0]!;
|
|
47
|
+
if (!KNOWN_FLAGS.includes(name as (typeof KNOWN_FLAGS)[number])) {
|
|
48
|
+
fail(`unknown flag --${name} (expected ${KNOWN_FLAGS.map((f) => `--${f}`).join(", ")})`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function fail(msg: string): never {
|
|
53
|
+
console.error(`pramen-cms: ${msg}`);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
throw new Error(msg); // unreachable — process.exit is typed as returning
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const HELP = `pramen-cms — CLI for @pramen/cms
|
|
59
|
+
|
|
60
|
+
Usage: pramen-cms <command>
|
|
61
|
+
|
|
62
|
+
help show this help
|
|
63
|
+
types generate TS interfaces for a tenant's block types
|
|
64
|
+
[--tenant t] [--url u] [--token jwt] [--out path]
|
|
65
|
+
|
|
66
|
+
Block types are DATA — rows a webmaster adds with no deploy — so their shape is only
|
|
67
|
+
knowable from a running instance. \`types\` reads them from one and emits an interface per
|
|
68
|
+
slug plus a BlockFieldsBySlug registry. With no --out it prints.`;
|
|
69
|
+
|
|
70
|
+
/** A block-type row as the content API returns it. */
|
|
71
|
+
interface BlockTypeRow {
|
|
72
|
+
slug: string;
|
|
73
|
+
fieldsSchema?: FieldDefinition[] | null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function typesCmd(): Promise<void> {
|
|
77
|
+
assertKnownFlags();
|
|
78
|
+
// Read every flag BEFORE the network call, so a malformed invocation fails on the
|
|
79
|
+
// invocation rather than on whatever the fetch happens to do first.
|
|
80
|
+
const url = flag("url") ?? "http://localhost:8787";
|
|
81
|
+
const tenant = flag("tenant") ?? "main";
|
|
82
|
+
const dest = flag("out");
|
|
83
|
+
const token = flag("token") ?? (await signDevToken({ sub: "cli", roles: ["admin"] }));
|
|
84
|
+
|
|
85
|
+
let res: Response;
|
|
86
|
+
try {
|
|
87
|
+
res = await fetch(`${url}/rpc/listBlockTypes`, {
|
|
88
|
+
method: "POST",
|
|
89
|
+
headers: { "content-type": "application/json", "x-pramen-tenant": tenant, authorization: `Bearer ${token}` },
|
|
90
|
+
body: "{}",
|
|
91
|
+
});
|
|
92
|
+
} catch (e) {
|
|
93
|
+
// A wrong --url is the common mistake; surface it as a CLI error, not a stack trace.
|
|
94
|
+
fail(`types: cannot reach ${url} (${e instanceof Error ? e.message : String(e)})`);
|
|
95
|
+
}
|
|
96
|
+
const body = (await res!.json().catch(() => ({}))) as { ok?: boolean; result?: BlockTypeRow[]; error?: string };
|
|
97
|
+
if (!res!.ok || body.ok !== true || !Array.isArray(body.result)) {
|
|
98
|
+
fail(`types: listBlockTypes failed (${body.error ?? res!.status})`);
|
|
99
|
+
}
|
|
100
|
+
const rows = body.result!;
|
|
101
|
+
// Refuse rather than write an empty module. A --tenant typo routes to a fresh Durable
|
|
102
|
+
// Object whose cms_block_types is legitimately empty, so this is the likely cause — and
|
|
103
|
+
// exiting 0 after clobbering src/cms.gen.ts would sail through a regenerate-and-diff CI.
|
|
104
|
+
if (rows.length === 0) {
|
|
105
|
+
fail(`types: tenant '${tenant}' has no block types — refusing to write an empty module (check --tenant/--url)`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let out: string;
|
|
109
|
+
try {
|
|
110
|
+
out = generateBlockTypes(rows);
|
|
111
|
+
} catch (e) {
|
|
112
|
+
// It throws for a slug that is not a distinct valid identifier — a data problem the
|
|
113
|
+
// user must fix in the CMS, so name it rather than print a stack trace.
|
|
114
|
+
fail(`types: ${e instanceof Error ? e.message : String(e)}`);
|
|
115
|
+
}
|
|
116
|
+
if (!dest) {
|
|
117
|
+
process.stdout.write(out!); // composes with a pipe, like `pramen schema sql`
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const path = resolve(process.cwd(), dest);
|
|
121
|
+
try {
|
|
122
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
123
|
+
writeFileSync(path, out!);
|
|
124
|
+
} catch (e) {
|
|
125
|
+
// Same treatment as the fetch above — an unwritable --out is a CLI error, not a stack.
|
|
126
|
+
fail(`types: cannot write ${dest} (${e instanceof Error ? e.message : String(e)})`);
|
|
127
|
+
}
|
|
128
|
+
console.log(` + ${dest} (${rows.length} block type${rows.length === 1 ? "" : "s"} from tenant '${tenant}')`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function main(): Promise<void> {
|
|
132
|
+
switch (argv[0]) {
|
|
133
|
+
case undefined:
|
|
134
|
+
case "help":
|
|
135
|
+
case "-h":
|
|
136
|
+
case "--help":
|
|
137
|
+
console.log(HELP);
|
|
138
|
+
return;
|
|
139
|
+
case "types":
|
|
140
|
+
return typesCmd();
|
|
141
|
+
default:
|
|
142
|
+
console.error(`pramen-cms: unknown command "${argv[0]}"\n`);
|
|
143
|
+
console.log(HELP);
|
|
144
|
+
process.exit(1);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
await main();
|
package/src/href.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Link-href safety. A LEAF module on purpose: `@pramen/cms/react` needs these at runtime,
|
|
2
|
+
// and importing them from `./index` pulled the whole server SDK into every browser bundle
|
|
3
|
+
// that renders rich text (`index.ts` evaluates `Entity(...)` calls at module scope for
|
|
4
|
+
// `cmsSchema`, which no bundler can tree-shake — measured 785 B -> 56 kB).
|
|
5
|
+
|
|
6
|
+
/** Strip the characters the WHATWG URL parser ignores before parsing (ASCII tab/CR/LF),
|
|
7
|
+
* then trim. Store and render THIS form, so what was validated is what a browser resolves. */
|
|
8
|
+
export function normalizeHref(raw: string): string {
|
|
9
|
+
return raw.replace(/[\t\n\r]/g, "").trim();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Allow-list for a link href: http(s), mailto, tel, or a relative/anchor path.
|
|
13
|
+
*
|
|
14
|
+
* Whitespace is stripped FIRST, because the URL parser does the same — `/\r\n/evil.example/x`
|
|
15
|
+
* otherwise passes a prefix test and still resolves to `https://evil.example/x`. Then a
|
|
16
|
+
* single leading slash only, whose next character may be neither `/` nor `\` (the parser
|
|
17
|
+
* folds `\` to `/` at path-start for special schemes). The scheme allow-list inherently
|
|
18
|
+
* rejects `javascript:`, `data:` and `vbscript:`.
|
|
19
|
+
*
|
|
20
|
+
* This is the security boundary — the write-path normalizer and BOTH renderers call it —
|
|
21
|
+
* not the UI hint that @podoba/react's `safeLinkUrl` is. */
|
|
22
|
+
export function isSafeHref(raw: unknown): boolean {
|
|
23
|
+
return typeof raw === "string" && /^(https?:\/\/|mailto:|tel:|\/(?![/\\])|#)/i.test(normalizeHref(raw));
|
|
24
|
+
}
|