@cosmicdrift/kumiko-samples 0.394.0 → 0.396.0
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/package.json +1 -1
- package/packages/bundled-features/package.json +1 -1
- package/packages/bundled-features/src/inbound-provider-imap/changes.json +6 -0
- package/packages/bundled-features/src/inbound-provider-imap/feature.ts +3 -104
- package/packages/bundled-features/src/inbound-provider-imap/watch-mailbox.ts +109 -0
- package/packages/bundled-features/src/managed-pages/changes.json +7 -0
- package/packages/bundled-features/src/managed-pages/constants.ts +7 -0
- package/packages/bundled-features/src/managed-pages/feature.ts +6 -4
- package/packages/bundled-features/src/managed-pages/handlers/by-slug.query.ts +2 -0
- package/packages/bundled-features/src/managed-pages/handlers/page-body-guard.ts +61 -0
- package/packages/bundled-features/src/managed-pages/handlers/page-body-issues.ts +26 -0
- package/packages/bundled-features/src/managed-pages/handlers/page-crud.ts +17 -10
- package/packages/bundled-features/src/managed-pages/handlers/set.write.ts +15 -4
- package/packages/bundled-features/src/managed-pages/render-page-body.ts +28 -0
- package/packages/bundled-features/src/managed-pages/screens/page-screens.ts +6 -2
- package/packages/bundled-features/src/managed-pages/seeding.ts +13 -2
- package/packages/bundled-features/src/managed-pages/table.ts +6 -2
- package/packages/bundled-features/src/managed-pages/web/client-plugin.tsx +6 -4
- package/packages/bundled-features/src/managed-pages/web/constants.ts +14 -0
- package/packages/bundled-features/src/managed-pages/web/i18n.ts +13 -0
- package/packages/bundled-features/src/managed-pages/web/lossy-markdown.ts +71 -0
- package/packages/bundled-features/src/managed-pages/web/markdown-to-document.ts +27 -0
- package/packages/bundled-features/src/managed-pages/web/page-body-section.tsx +73 -0
- package/packages/bundled-features/src/template-resolver/README.md +2 -2
- package/packages/bundled-features/src/template-resolver/changes.json +8 -0
- package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +16 -16
- package/packages/bundled-features/src/template-resolver/feature.ts +14 -1
- package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +24 -6
- package/packages/bundled-features/src/template-resolver/handlers/collection-shared.ts +7 -0
- package/packages/bundled-features/src/template-resolver/handlers/set.write.ts +1 -0
- package/packages/bundled-features/src/template-resolver/handlers/shared.ts +9 -3
- package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +8 -576
- package/packages/bundled-features/src/template-resolver/web/collection-entry-create-form.tsx +105 -0
- package/packages/bundled-features/src/template-resolver/web/collection-lookup.ts +33 -0
- package/packages/bundled-features/src/template-resolver/web/constants.ts +3 -0
- package/packages/bundled-features/src/template-resolver/web/content-field.tsx +61 -0
- package/packages/bundled-features/src/template-resolver/web/content-tree-provider.ts +256 -0
- package/packages/bundled-features/src/template-resolver/web/document-reference-name.ts +12 -0
- package/packages/bundled-features/src/template-resolver/web/document-schema.ts +3 -11
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +8 -0
- package/packages/bundled-features/src/template-resolver/web/legacy-variable-text.ts +30 -0
- package/packages/bundled-features/src/template-resolver/web/tenant-override.ts +7 -0
- package/packages/bundled-features/src/template-resolver/web/text-block-editor.tsx +92 -0
- package/packages/bundled-features/src/template-resolver/web/use-collection-editor-context.ts +42 -0
- package/packages/bundled-features/src/template-resolver/web/use-collection-entry-create.ts +92 -0
- package/packages/bundled-features/src/template-resolver/web/use-content-write.ts +81 -0
- package/packages/bundled-features/src/template-resolver/web/use-text-block-editor.ts +127 -0
- package/packages/bundled-features/src/tier-engine/changes.json +6 -0
- package/packages/bundled-features/src/tier-engine/feature.ts +9 -1
- package/samples/apps/use-all-bundled/feature-manifest.json +2 -2
- package/samples/apps/use-all-bundled/src/app/client.tsx +6 -1
- package/samples/apps/use-all-bundled/src/app/seed.ts +13 -1
- package/samples/apps/use-all-bundled/src/run-config.ts +12 -6
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
16
16
|
import { runEventStoreSeed, type SeedIfExists } from "@cosmicdrift/kumiko-framework/seeding";
|
|
17
17
|
|
|
18
|
+
import { type BodyFormat, DEFAULT_BODY_FORMAT } from "./constants.ts";
|
|
19
|
+
import { sanitizePageBody } from "./handlers/page-body-issues.ts";
|
|
18
20
|
import { type PageRow, pageEntity, pagesTable } from "./table.ts";
|
|
19
21
|
|
|
20
22
|
const executor = createEventStoreExecutor(pagesTable, pageEntity, { entityName: "page" });
|
|
@@ -25,6 +27,7 @@ export type SeedPageOptions = {
|
|
|
25
27
|
readonly lang: string;
|
|
26
28
|
readonly title: string;
|
|
27
29
|
readonly body?: string | null;
|
|
30
|
+
readonly bodyFormat?: BodyFormat;
|
|
28
31
|
readonly description?: string | null;
|
|
29
32
|
readonly ogImage?: string | null;
|
|
30
33
|
readonly published?: boolean;
|
|
@@ -53,6 +56,12 @@ export async function seedPage(db: DbConnection, opts: SeedPageOptions): Promise
|
|
|
53
56
|
const description = opts.description ?? null;
|
|
54
57
|
const ogImage = opts.ogImage ?? null;
|
|
55
58
|
const published = opts.published ?? false;
|
|
59
|
+
const bodyFormat = opts.bodyFormat ?? DEFAULT_BODY_FORMAT;
|
|
60
|
+
const sanitized = sanitizePageBody(opts.body, bodyFormat);
|
|
61
|
+
if (!sanitized.ok) {
|
|
62
|
+
throw new Error(`seedPage rejected the body: ${JSON.stringify(sanitized.issues)}`);
|
|
63
|
+
}
|
|
64
|
+
const { body } = sanitized;
|
|
56
65
|
|
|
57
66
|
return runEventStoreSeed({
|
|
58
67
|
existing,
|
|
@@ -63,7 +72,8 @@ export async function seedPage(db: DbConnection, opts: SeedPageOptions): Promise
|
|
|
63
72
|
slug: opts.slug,
|
|
64
73
|
lang: opts.lang,
|
|
65
74
|
title: opts.title,
|
|
66
|
-
body
|
|
75
|
+
body,
|
|
76
|
+
bodyFormat,
|
|
67
77
|
description,
|
|
68
78
|
ogImage,
|
|
69
79
|
published,
|
|
@@ -90,7 +100,8 @@ export async function seedPage(db: DbConnection, opts: SeedPageOptions): Promise
|
|
|
90
100
|
version: row.version,
|
|
91
101
|
changes: {
|
|
92
102
|
title: opts.title,
|
|
93
|
-
body
|
|
103
|
+
body,
|
|
104
|
+
bodyFormat,
|
|
94
105
|
description,
|
|
95
106
|
ogImage,
|
|
96
107
|
published,
|
|
@@ -2,12 +2,14 @@ import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
|
2
2
|
import {
|
|
3
3
|
createBooleanField,
|
|
4
4
|
createEntity,
|
|
5
|
+
createSelectField,
|
|
5
6
|
createTextField,
|
|
6
7
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { BODY_FORMATS, type BodyFormat, DEFAULT_BODY_FORMAT } from "./constants.ts";
|
|
7
9
|
|
|
8
10
|
// Page — a tenant-editable, server-rendered public page (landing,
|
|
9
11
|
// about, custom). Exactly one row per (tenantId, slug, lang). The body is
|
|
10
|
-
// Markdown, rendered securely on the server
|
|
12
|
+
// Markdown or an editor document (bodyFormat), rendered securely on the server. `published`
|
|
11
13
|
// gates delivery to anonymous visitors: drafts return 404. description and
|
|
12
14
|
// ogImage support SEO and social previews. SYSTEM_TENANT_ID is used for
|
|
13
15
|
// app-wide pages; otherwise the page belongs to the tenant resolved from the
|
|
@@ -15,7 +17,7 @@ import {
|
|
|
15
17
|
export const pageEntity = createEntity({
|
|
16
18
|
table: "read_pages",
|
|
17
19
|
description:
|
|
18
|
-
"One tenant-editable public web page, unique per slug and language, holding a markdown body plus the title, SEO description and OG image, with a published flag that decides whether anonymous visitors are served it or get a 404.",
|
|
20
|
+
"One tenant-editable public web page, unique per slug and language, holding a markdown or document (HTML) body plus the title, SEO description and OG image, with a published flag that decides whether anonymous visitors are served it or get a 404.",
|
|
19
21
|
fields: {
|
|
20
22
|
slug: createTextField({
|
|
21
23
|
required: true,
|
|
@@ -51,6 +53,7 @@ export const pageEntity = createEntity({
|
|
|
51
53
|
personal: false,
|
|
52
54
|
reason: "is_business_data",
|
|
53
55
|
}),
|
|
56
|
+
bodyFormat: createSelectField({ options: BODY_FORMATS, default: DEFAULT_BODY_FORMAT }),
|
|
54
57
|
description: createTextField({ maxLength: 500, personal: false, reason: "is_business_data" }),
|
|
55
58
|
ogImage: createTextField({ maxLength: 2000, personal: false, reason: "is_business_data" }),
|
|
56
59
|
published: createBooleanField({ default: false }),
|
|
@@ -71,6 +74,7 @@ export type PageRow = {
|
|
|
71
74
|
readonly lang: string;
|
|
72
75
|
readonly title: string;
|
|
73
76
|
readonly body: string | null;
|
|
77
|
+
readonly bodyFormat: BodyFormat | null;
|
|
74
78
|
readonly description: string | null;
|
|
75
79
|
readonly ogImage: string | null;
|
|
76
80
|
readonly published: boolean;
|
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
// Client feature factory for managed-pages
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// registered. Apps mount it in createKumikoApp({ clientFeatures:
|
|
1
|
+
// Client feature factory for managed-pages: translations plus the page-body
|
|
2
|
+
// extension section (document editor) of the page-edit screen. The screens
|
|
3
|
+
// themselves are schema-driven. Apps mount it in createKumikoApp({ clientFeatures:
|
|
5
4
|
// [managedPagesClient()] }) so the admin labels render translated.
|
|
6
5
|
|
|
7
6
|
import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
8
7
|
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer/web";
|
|
8
|
+
import { PAGE_BODY_EXTENSION_NAME } from "./constants.ts";
|
|
9
9
|
import { defaultTranslations } from "./default-translations.ts";
|
|
10
|
+
import { PageBodySection } from "./page-body-section.tsx";
|
|
10
11
|
|
|
11
12
|
export type ManagedPagesClientOptions = {
|
|
12
13
|
/** Key-wise overrides of the default bundles (de/en). */
|
|
@@ -17,5 +18,6 @@ export function managedPagesClient(options?: ManagedPagesClientOptions): ClientF
|
|
|
17
18
|
return {
|
|
18
19
|
name: "managed-pages",
|
|
19
20
|
translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
|
|
21
|
+
extensionSectionComponents: { [PAGE_BODY_EXTENSION_NAME]: PageBodySection },
|
|
20
22
|
};
|
|
21
23
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
2
|
+
|
|
3
|
+
// Pure data shared by the server (screen, handlers, render) and the client plugin.
|
|
4
|
+
export const PAGE_BODY_EXTENSION_NAME = "managed-pages:page-body";
|
|
5
|
+
|
|
6
|
+
export const BODY_FORMATS = ["markdown", "document"] as const;
|
|
7
|
+
export type BodyFormat = (typeof BODY_FORMATS)[number];
|
|
8
|
+
|
|
9
|
+
// Rows and events written before bodyFormat existed carry no value.
|
|
10
|
+
export const DEFAULT_BODY_FORMAT: BodyFormat = "markdown";
|
|
11
|
+
|
|
12
|
+
export function effectiveBodyFormat(value: unknown): BodyFormat {
|
|
13
|
+
return isOneOf(BODY_FORMATS, value) ? value : DEFAULT_BODY_FORMAT;
|
|
14
|
+
}
|
|
@@ -50,6 +50,19 @@ export const MANAGED_PAGES_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
|
50
50
|
"managed-pages:section.body": { en: "Content" },
|
|
51
51
|
"managed-pages:branding.section.identity": { en: "Identity" },
|
|
52
52
|
|
|
53
|
+
// Document body: write-time rejection and the Markdown conversion warning
|
|
54
|
+
"managed-pages.errors.document.rejected": {
|
|
55
|
+
en: "The content contains elements that are not allowed: {detail}",
|
|
56
|
+
},
|
|
57
|
+
"managed-pages:body.lossy.warning": {
|
|
58
|
+
en: "This page was written in Markdown. Converting it to the editor drops: {features}. Check the result before saving.",
|
|
59
|
+
},
|
|
60
|
+
"managed-pages:body.lossy.image": { en: "images" },
|
|
61
|
+
"managed-pages:body.lossy.blockquote": { en: "block quotes" },
|
|
62
|
+
"managed-pages:body.lossy.code": { en: "code" },
|
|
63
|
+
"managed-pages:body.lossy.deepHeading": { en: "headings below level 3" },
|
|
64
|
+
"managed-pages:body.lossy.relativeLink": { en: "relative links" },
|
|
65
|
+
|
|
53
66
|
// Row-Actions + Confirm (pageListScreen)
|
|
54
67
|
"managed-pages:actions.edit": { en: "Edit" },
|
|
55
68
|
"managed-pages:actions.delete": { en: "Delete" },
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { isAllowedDocumentLinkHref } from "@cosmicdrift/kumiko-bundled-features/template-resolver/document-schema";
|
|
2
|
+
import { Lexer, type Token } from "marked";
|
|
3
|
+
|
|
4
|
+
export const LOSSY_MARKDOWN_FEATURES = [
|
|
5
|
+
"image",
|
|
6
|
+
"blockquote",
|
|
7
|
+
"code",
|
|
8
|
+
"deepHeading",
|
|
9
|
+
"relativeLink",
|
|
10
|
+
] as const;
|
|
11
|
+
export type LossyMarkdownFeature = (typeof LOSSY_MARKDOWN_FEATURES)[number];
|
|
12
|
+
|
|
13
|
+
// The document editor has no image, quote or code nodes, only three heading
|
|
14
|
+
// levels, and links must pass the document link policy (absolute http(s),
|
|
15
|
+
// mailto, tel, placeholders).
|
|
16
|
+
const MAX_DOCUMENT_HEADING_DEPTH = 3;
|
|
17
|
+
|
|
18
|
+
function tokenList(value: unknown): readonly Token[] {
|
|
19
|
+
return Array.isArray(value) ? value : [];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function childTokens(token: Token): readonly Token[] {
|
|
23
|
+
const nested = [...tokenList("tokens" in token ? token.tokens : undefined)];
|
|
24
|
+
if (token.type === "list") nested.push(...tokenList("items" in token ? token.items : undefined));
|
|
25
|
+
return nested;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isDeepHeading(token: Token): boolean {
|
|
29
|
+
return (
|
|
30
|
+
"depth" in token && typeof token.depth === "number" && token.depth > MAX_DOCUMENT_HEADING_DEPTH
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isRelativeLink(token: Token): boolean {
|
|
35
|
+
return (
|
|
36
|
+
"href" in token && typeof token.href === "string" && !isAllowedDocumentLinkHref(token.href)
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function featuresOf(token: Token): readonly LossyMarkdownFeature[] {
|
|
41
|
+
switch (token.type) {
|
|
42
|
+
case "image":
|
|
43
|
+
return ["image"];
|
|
44
|
+
case "blockquote":
|
|
45
|
+
return ["blockquote"];
|
|
46
|
+
case "code":
|
|
47
|
+
case "codespan":
|
|
48
|
+
return ["code"];
|
|
49
|
+
case "heading":
|
|
50
|
+
return isDeepHeading(token) ? ["deepHeading"] : [];
|
|
51
|
+
case "link":
|
|
52
|
+
return isRelativeLink(token) ? ["relativeLink"] : [];
|
|
53
|
+
default:
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function collect(tokens: readonly Token[], found: Set<LossyMarkdownFeature>): void {
|
|
59
|
+
for (const token of tokens) {
|
|
60
|
+
for (const feature of featuresOf(token)) found.add(feature);
|
|
61
|
+
collect(childTokens(token), found);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Lexer options match the renderer used for conversion (gfm off), so the
|
|
66
|
+
// warning lists exactly what the converted HTML loses.
|
|
67
|
+
export function detectLossyMarkdown(markdown: string): readonly LossyMarkdownFeature[] {
|
|
68
|
+
const found = new Set<LossyMarkdownFeature>();
|
|
69
|
+
collect(new Lexer({ gfm: false, breaks: false }).lex(markdown), found);
|
|
70
|
+
return LOSSY_MARKDOWN_FEATURES.filter((feature) => found.has(feature));
|
|
71
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { isAllowedDocumentLinkHref } from "@cosmicdrift/kumiko-bundled-features/template-resolver/document-schema";
|
|
2
|
+
import { escapeHtml } from "@cosmicdrift/kumiko-renderer/headless";
|
|
3
|
+
import { Marked } from "marked";
|
|
4
|
+
|
|
5
|
+
// The server sanitizer rejects what it cannot keep, so the conversion drops it up front and the
|
|
6
|
+
// lossy banner stays true: a link outside the document link policy keeps its text, an image
|
|
7
|
+
// (no uploaded resource to point at) is dropped, and raw HTML is shown as text.
|
|
8
|
+
const converter = new Marked({ gfm: false, breaks: false });
|
|
9
|
+
converter.use({
|
|
10
|
+
renderer: {
|
|
11
|
+
html({ text }) {
|
|
12
|
+
return escapeHtml(text);
|
|
13
|
+
},
|
|
14
|
+
link({ href, tokens }) {
|
|
15
|
+
return isAllowedDocumentLinkHref(href) ? false : this.parser.parseInline(tokens);
|
|
16
|
+
},
|
|
17
|
+
image() {
|
|
18
|
+
return "";
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export function markdownToDocumentHtml(markdown: string): string {
|
|
24
|
+
const html = converter.parse(markdown, { async: false });
|
|
25
|
+
if (typeof html !== "string") throw new Error("marked returned a promise for a sync parse");
|
|
26
|
+
return html;
|
|
27
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ExtensionSectionProps,
|
|
3
|
+
usePrimitives,
|
|
4
|
+
useTranslation,
|
|
5
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
6
|
+
import { type ReactNode, useMemo, useState } from "react";
|
|
7
|
+
import { DocumentContentEditor } from "../../template-resolver/web/index.ts";
|
|
8
|
+
import { effectiveBodyFormat } from "./constants.ts";
|
|
9
|
+
import { detectLossyMarkdown, type LossyMarkdownFeature } from "./lossy-markdown.ts";
|
|
10
|
+
import { markdownToDocumentHtml } from "./markdown-to-document.ts";
|
|
11
|
+
|
|
12
|
+
const LOSSY_LABEL_KEYS: Readonly<Record<LossyMarkdownFeature, string>> = {
|
|
13
|
+
image: "managed-pages:body.lossy.image",
|
|
14
|
+
blockquote: "managed-pages:body.lossy.blockquote",
|
|
15
|
+
code: "managed-pages:body.lossy.code",
|
|
16
|
+
deepHeading: "managed-pages:body.lossy.deepHeading",
|
|
17
|
+
relativeLink: "managed-pages:body.lossy.relativeLink",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const BODY_EDITOR_ID = "managed-pages-page-body";
|
|
21
|
+
|
|
22
|
+
function stringValue(value: unknown): string {
|
|
23
|
+
return typeof value === "string" ? value : "";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// A Markdown body is converted for display only: nothing is patched until the
|
|
27
|
+
// user edits, so merely opening a page never turns it into a document.
|
|
28
|
+
export function PageBodySection({ values, patch }: ExtensionSectionProps): ReactNode {
|
|
29
|
+
const t = useTranslation();
|
|
30
|
+
const { Banner } = usePrimitives();
|
|
31
|
+
const body = stringValue(values?.["body"]);
|
|
32
|
+
const isMarkdown = effectiveBodyFormat(values?.["bodyFormat"]) === "markdown";
|
|
33
|
+
const [editedHtml, setEditedHtml] = useState<string | null>(null);
|
|
34
|
+
const [lossyAtFirstEdit, setLossyAtFirstEdit] = useState<readonly LossyMarkdownFeature[] | null>(
|
|
35
|
+
null,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const pristine = editedHtml === null;
|
|
39
|
+
const convertedHtml = useMemo(
|
|
40
|
+
() => (isMarkdown ? markdownToDocumentHtml(body) : body),
|
|
41
|
+
[isMarkdown, body],
|
|
42
|
+
);
|
|
43
|
+
const liveLossy = useMemo(
|
|
44
|
+
() => (pristine && isMarkdown ? detectLossyMarkdown(body) : []),
|
|
45
|
+
[pristine, isMarkdown, body],
|
|
46
|
+
);
|
|
47
|
+
const lossy = lossyAtFirstEdit ?? liveLossy;
|
|
48
|
+
|
|
49
|
+
const handleChange = (next: string): void => {
|
|
50
|
+
if (pristine) setLossyAtFirstEdit(liveLossy);
|
|
51
|
+
setEditedHtml(next);
|
|
52
|
+
patch?.({ body: next, bodyFormat: "document" });
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<>
|
|
57
|
+
{lossy.length > 0 && (
|
|
58
|
+
<Banner variant="warning">
|
|
59
|
+
{t("managed-pages:body.lossy.warning", {
|
|
60
|
+
features: lossy.map((feature) => t(LOSSY_LABEL_KEYS[feature])).join(", "),
|
|
61
|
+
})}
|
|
62
|
+
</Banner>
|
|
63
|
+
)}
|
|
64
|
+
<DocumentContentEditor
|
|
65
|
+
id={BODY_EDITOR_ID}
|
|
66
|
+
value={editedHtml ?? convertedHtml}
|
|
67
|
+
onChange={handleChange}
|
|
68
|
+
variables={[]}
|
|
69
|
+
readOnly={false}
|
|
70
|
+
/>
|
|
71
|
+
</>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -232,8 +232,8 @@ including SSE refresh, and an editor target with the `collectionId`. The app pas
|
|
|
232
232
|
under an unmounted feature causes boot to fail instead of silently disappearing from the sidebar. Node
|
|
233
233
|
visibility follows `access` unless `nav.access` specifies otherwise.
|
|
234
234
|
|
|
235
|
-
A "+" on the node
|
|
236
|
-
`
|
|
235
|
+
A "+" on the node opens the built-in create form (`template-resolver:create`) unless
|
|
236
|
+
`nav.createAction` points elsewhere, usually at `treeHandle.create`. `nav.actions` adds hover actions to the row.
|
|
237
237
|
|
|
238
238
|
Saving **publishes immediately**, with status `active` and an empty `variableSchema`.
|
|
239
239
|
Anyone who needs a draft stage or a variable schema uses
|
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.395.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "Content collections edit in plain or document format; rich and markdown are gone",
|
|
6
|
+
"detail": "ContentEditorFormat is \"plain\" | \"document\". RichContentEditor and the old tiptap editor are removed from @cosmicdrift/kumiko-renderer/web; the document editor is DocumentContentEditor from @cosmicdrift/kumiko-bundled-features/template-resolver/web. A collection's storage format comes from its declaration on the server (plain stores plain, document stores html sanitized with sanitizeDocumentHtml for every collection kind) and the collection write no longer takes contentFormat. Saved content with {{name}} as text opens with variable nodes. Every collection gets a create form behind a \"+\" in its nav (unless it declares its own createAction); the collection write takes createOnly and answers an existing slug with the field error slug_exists. The text block editor and the secrets edit screen use FormScreenLayout, which takes an optional unsavedCount. useDispatchTarget is exported from @cosmicdrift/kumiko-renderer/web.",
|
|
7
|
+
"migration": "Run bun kumiko-upgrade --apply. It rewrites contentFormat \"rich\" and \"markdown\" in collection declarations to \"document\". Replace imports of RichContentEditor with DocumentContentEditor from @cosmicdrift/kumiko-bundled-features/template-resolver/web, and drop contentFormat from collection write payloads.",
|
|
8
|
+
"codemod": "scripts/codemod/content-collection-format.ts"
|
|
9
|
+
},
|
|
2
10
|
{
|
|
3
11
|
"version": "0.394.0",
|
|
4
12
|
"type": "fix",
|
|
@@ -299,6 +299,18 @@ function isSanitizedHtmlKind(kind: string): boolean {
|
|
|
299
299
|
return kind === "snippet" || isOneOf(DOCUMENT_HTML_KINDS, kind);
|
|
300
300
|
}
|
|
301
301
|
|
|
302
|
+
type SanitizeBodyInput = {
|
|
303
|
+
readonly kind: string;
|
|
304
|
+
readonly contentFormat: ContentFormat;
|
|
305
|
+
readonly content: string | null;
|
|
306
|
+
/** A document collection stores html whatever its kind, so its html is always rebuilt. */
|
|
307
|
+
readonly alwaysSanitizeHtml?: boolean;
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
function isSanitizedBody(input: SanitizeBodyInput): boolean {
|
|
311
|
+
return input.alwaysSanitizeHtml === true || isSanitizedHtmlKind(input.kind);
|
|
312
|
+
}
|
|
313
|
+
|
|
302
314
|
const HTML_ONLY_KINDS: ReadonlySet<string> = new Set(DOCUMENT_HTML_KINDS);
|
|
303
315
|
|
|
304
316
|
function requiredFormatViolation(
|
|
@@ -319,14 +331,10 @@ const IMAGES_BLOCK_KIND = "document-pdf";
|
|
|
319
331
|
|
|
320
332
|
// The node is part of the shared document model, so a mail body would keep it after the
|
|
321
333
|
// rebuild; only a PDF resolves the record's images.
|
|
322
|
-
function imagesBlockViolation(input: {
|
|
323
|
-
readonly kind: string;
|
|
324
|
-
readonly contentFormat: ContentFormat;
|
|
325
|
-
readonly content: string | null;
|
|
326
|
-
}): DocumentViolation | null {
|
|
334
|
+
function imagesBlockViolation(input: SanitizeBodyInput): DocumentViolation | null {
|
|
327
335
|
if (
|
|
328
336
|
input.kind === IMAGES_BLOCK_KIND ||
|
|
329
|
-
!
|
|
337
|
+
!isSanitizedBody(input) ||
|
|
330
338
|
input.contentFormat !== "html" ||
|
|
331
339
|
input.content === null
|
|
332
340
|
) {
|
|
@@ -345,20 +353,12 @@ function imagesBlockViolation(input: {
|
|
|
345
353
|
: null;
|
|
346
354
|
}
|
|
347
355
|
|
|
348
|
-
export function sanitizeTemplateBody(input: {
|
|
349
|
-
readonly kind: string;
|
|
350
|
-
readonly contentFormat: ContentFormat;
|
|
351
|
-
readonly content: string | null;
|
|
352
|
-
}): SanitizeBodyResult {
|
|
356
|
+
export function sanitizeTemplateBody(input: SanitizeBodyInput): SanitizeBodyResult {
|
|
353
357
|
const formatViolation = requiredFormatViolation(input.kind, input.contentFormat);
|
|
354
358
|
if (formatViolation !== null) return { ok: false, violations: [formatViolation] };
|
|
355
359
|
const imagesViolation = imagesBlockViolation(input);
|
|
356
360
|
if (imagesViolation !== null) return { ok: false, violations: [imagesViolation] };
|
|
357
|
-
if (
|
|
358
|
-
!isSanitizedHtmlKind(input.kind) ||
|
|
359
|
-
input.contentFormat !== "html" ||
|
|
360
|
-
input.content === null
|
|
361
|
-
) {
|
|
361
|
+
if (!isSanitizedBody(input) || input.contentFormat !== "html" || input.content === null) {
|
|
362
362
|
return { ok: true, content: input.content };
|
|
363
363
|
}
|
|
364
364
|
const result = sanitizeDocumentHtml(input.content);
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
type ContentCollectionDefinition,
|
|
3
3
|
createSystemConfig,
|
|
4
4
|
defineFeature,
|
|
5
|
+
type TreeAction,
|
|
5
6
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
7
|
import { bySlugQuery } from "./handlers/by-slug.query.ts";
|
|
7
8
|
import { byTenantQuery } from "./handlers/by-tenant.query.ts";
|
|
@@ -109,6 +110,14 @@ function assertTenantEditableValid(entries: readonly TenantEditableTemplate[]):
|
|
|
109
110
|
}
|
|
110
111
|
}
|
|
111
112
|
|
|
113
|
+
function newEntryAction(collectionId: string): TreeAction {
|
|
114
|
+
return {
|
|
115
|
+
icon: "plus",
|
|
116
|
+
label: "template-resolver.editor.newEntry",
|
|
117
|
+
target: { featureId: "template-resolver", action: "create", args: { collectionId } },
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
112
121
|
export function createTemplateResolverFeature(opts: TemplateResolverOptions = {}) {
|
|
113
122
|
const collections = opts.collections ?? [];
|
|
114
123
|
assertCollectionKindsAllowed(collections);
|
|
@@ -129,7 +138,7 @@ export function createTemplateResolverFeature(opts: TemplateResolverOptions = {}
|
|
|
129
138
|
"Reading is one call — `ctx.templateResolver.resolveTemplate({ tenantId, slug, kind, locale })`. It walks four levels: tenant+locale, system+locale, tenant+fallback-locale, system+fallback-locale. A tenant overrides a system default by simply having its own record; no application code changes. Writing programmatically goes through the `upsertSystem`, `upsertTenant`, `publish` and `archive` write handlers; a SystemAdmin retires or restores a system default with `publishSystem` and `archiveSystem`, and a tenant drops its override with `resetToSystem`. Saving a draft over an active template keeps the active version served and stores the edit beside it (column `draft`); `publish` makes it the served version, `discardDraft` / `discardDraftSystem` throws it away.",
|
|
130
139
|
'Editing templates is for SystemAdmin only. A TenantAdmin edits only the templates the app unlocks at the mount site, `createTemplateResolverFeature({ tenantEditable: [{ kind: "mail", slug: "status-update", access: { roles: ["TenantAdmin"] } }] })`, and every write handler enforces that on the server (override, draft, publish, archive, reset, copy-locale, import, resources, `set`). Each entry carries the `access` roles allowed to edit it, because a bundled feature cannot know the host\'s roles. The same list decides delivery: a tenant override is served (mail, PDF, layouts, text blocks) only while its template is listed in tenantEditable; otherwise the system version is served. A locked override stays stored, a SystemAdmin sees it in the overview marked as not delivered, and listing the template makes it effective again. Tenant rows of a collection mounted with `access` are always served. A collection mounted without `access` is SystemAdmin only.',
|
|
131
140
|
"Text an editor should be able to change belongs in a collection, declared at mount: `createTemplateResolverFeature({ collections: [{ id, kind, access: { roles }, nav }] })`. It appears in the navigation, and `access` is part of the mount because a bundled feature cannot know the host's roles. Each collection gets its own `<id>-list` / `<id>-item` / `<id>-set` handlers, so the dispatcher enforces the separation.",
|
|
132
|
-
"How a collection is edited follows from `contentFormat`: `plain` gives a text area, `
|
|
141
|
+
"How a collection is edited follows from `contentFormat`: `plain` gives a text area that stores text, `document` a WYSIWYG document editor that stores sanitized HTML (bold, italic, headings, lists, links, variable chips). Both offer the collection's `variableSchema` as insertable chips and a preview rendered with sample data — an editor sees what `{{firstName}}` becomes without sending a mail. An app can register its own editor for a format; the last `clientFeature` that registers a format wins (a conflict logs a warning).",
|
|
133
142
|
'A collection is tenant-wide by default. With `ownership: "user"` every user keeps their own entries — mail signatures being the obvious case. Those rows live in the separate `user-content-entry` entity and count as user data, so mounting one also requires the `template-resolver-user-data` feature and a migration on the app side.',
|
|
134
143
|
"`validate` dry-runs the write pipeline for a draft (sanitizer, layout rules, placeholders) and reports what the template uses and misses. `export-bundle` / `import-bundle` (and `import-bundle-system`) move one template with all locales and resource files between tenants or environments; `copy-locale` starts a translation from an existing locale. Layouts are structured: a mail or PDF layout is `data-header` / `data-content` / `data-signature` / `data-footer` regions, and mjml layout source is system-only.",
|
|
135
144
|
"`preview` renders a draft (or the stored template) with sample values taken from the `example` of each declared variable, through the same gates and the same mail renderer as a real send, and returns subject, html, `displayHtml` (the html with cid images as data URIs for an editor), text, attachments (inline images and files) and `checks` (images resolved, plain text present, size under 10 MiB, required placeholders used); nothing is saved or sent. `send-test` mails that rendering to the calling user's own address only, subject prefixed with `[TEST]`, at most 10 per user and hour; it needs `delivery`, `channel-email` and `renderer-mail-html` to be mounted. `send-test-status` tells an editor beforehand whether that send can work for the caller (`canSend`, else `reason` `no_channel` or `no_address`), never returning the address.",
|
|
@@ -198,6 +207,10 @@ export function createTemplateResolverFeature(opts: TemplateResolverOptions = {}
|
|
|
198
207
|
r.contentCollection({
|
|
199
208
|
...collection,
|
|
200
209
|
access: collection.access ?? DEFAULT_COLLECTION_ACCESS,
|
|
210
|
+
nav: {
|
|
211
|
+
...collection.nav,
|
|
212
|
+
createAction: collection.nav.createAction ?? newEntryAction(collection.id),
|
|
213
|
+
},
|
|
201
214
|
});
|
|
202
215
|
r.queryHandler(makeCollectionListQuery(collection));
|
|
203
216
|
r.queryHandler(makeCollectionItemQuery(collection));
|
|
@@ -11,8 +11,8 @@ import { systemTemplateExists } from "../api.ts";
|
|
|
11
11
|
import { documentViolationsToFieldIssues, sanitizeTemplateBody } from "../document-sanitizer.ts";
|
|
12
12
|
import { refuseTemplateEdit, type TemplateEditPolicy } from "../template-edit-policy.ts";
|
|
13
13
|
import { DEFAULT_COLLECTION_ACCESS, isCollectionRefusedKind } from "../web/constants.ts";
|
|
14
|
-
import { type CollectionEntryRow, collectionStore } from "./collection-shared.ts";
|
|
15
|
-
import {
|
|
14
|
+
import { type CollectionEntryRow, collectionStore, storageFormatOf } from "./collection-shared.ts";
|
|
15
|
+
import { folderSchema, localeSchema, slugSchema } from "./shared.ts";
|
|
16
16
|
|
|
17
17
|
// Upsert inside one collection. Mirrors set.write for text-blocks, but the
|
|
18
18
|
// kind comes from the declaration and the access rule is the collection's own,
|
|
@@ -26,6 +26,7 @@ export function makeCollectionSetWrite(
|
|
|
26
26
|
editPolicy: TemplateEditPolicy,
|
|
27
27
|
) {
|
|
28
28
|
const store = collectionStore(collection);
|
|
29
|
+
const contentFormat = storageFormatOf(collection);
|
|
29
30
|
return defineWriteHandler({
|
|
30
31
|
name: `${collection.id}-set`,
|
|
31
32
|
schema: z.object({
|
|
@@ -33,8 +34,9 @@ export function makeCollectionSetWrite(
|
|
|
33
34
|
locale: localeSchema,
|
|
34
35
|
title: z.string().min(1).max(200),
|
|
35
36
|
content: z.string().max(200_000).nullable(),
|
|
36
|
-
contentFormat: contentFormatSchema.default("markdown"),
|
|
37
37
|
folder: folderSchema.nullable().optional(),
|
|
38
|
+
/** The create form sets it so a taken slug is reported instead of overwritten. */
|
|
39
|
+
createOnly: z.boolean().optional(),
|
|
38
40
|
/** Optional cross-tenant write — SystemAdmin only. */
|
|
39
41
|
tenantIdOverride: z.string().min(1).optional(),
|
|
40
42
|
}),
|
|
@@ -71,8 +73,9 @@ export function makeCollectionSetWrite(
|
|
|
71
73
|
|
|
72
74
|
const body = sanitizeTemplateBody({
|
|
73
75
|
kind: collection.kind,
|
|
74
|
-
contentFormat
|
|
76
|
+
contentFormat,
|
|
75
77
|
content: event.payload.content,
|
|
78
|
+
alwaysSanitizeHtml: collection.contentFormat === "document",
|
|
76
79
|
});
|
|
77
80
|
if (!body.ok) {
|
|
78
81
|
return writeFailure(
|
|
@@ -100,6 +103,21 @@ export function makeCollectionSetWrite(
|
|
|
100
103
|
...owner,
|
|
101
104
|
});
|
|
102
105
|
|
|
106
|
+
if (existing && event.payload.createOnly === true) {
|
|
107
|
+
return writeFailure(
|
|
108
|
+
new ValidationError({
|
|
109
|
+
fields: [
|
|
110
|
+
{
|
|
111
|
+
path: "slug",
|
|
112
|
+
code: "slug_exists",
|
|
113
|
+
i18nKey: "templateResolver.errors.collection.slugExists",
|
|
114
|
+
params: { detail: event.payload.slug },
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
103
121
|
if (existing) {
|
|
104
122
|
const result = await store.executor.update(
|
|
105
123
|
{
|
|
@@ -111,7 +129,7 @@ export function makeCollectionSetWrite(
|
|
|
111
129
|
changes: {
|
|
112
130
|
title: event.payload.title,
|
|
113
131
|
content: body.content,
|
|
114
|
-
contentFormat
|
|
132
|
+
contentFormat,
|
|
115
133
|
folder: event.payload.folder ?? null,
|
|
116
134
|
},
|
|
117
135
|
},
|
|
@@ -134,7 +152,7 @@ export function makeCollectionSetWrite(
|
|
|
134
152
|
locale: event.payload.locale,
|
|
135
153
|
title: event.payload.title,
|
|
136
154
|
content: body.content,
|
|
137
|
-
contentFormat
|
|
155
|
+
contentFormat,
|
|
138
156
|
folder: event.payload.folder ?? null,
|
|
139
157
|
...store.createDefaults(tenantId),
|
|
140
158
|
...owner,
|
|
@@ -14,6 +14,7 @@ import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
14
14
|
import type { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
15
15
|
import { templateResourceEntity, templateResourcesTable } from "../table.ts";
|
|
16
16
|
import { userContentEntriesTable, userContentEntryEntity } from "../user-content-table.ts";
|
|
17
|
+
import type { ContentFormat } from "../web/constants.ts";
|
|
17
18
|
|
|
18
19
|
const templateExecutor = createEventStoreExecutor(templateResourcesTable, templateResourceEntity, {
|
|
19
20
|
entityName: "template-resource",
|
|
@@ -107,3 +108,9 @@ export function toCollectionEntry(row: CollectionEntryRow) {
|
|
|
107
108
|
modifiedAt: row.modifiedAt,
|
|
108
109
|
};
|
|
109
110
|
}
|
|
111
|
+
|
|
112
|
+
// The client never names the storage format: the declared editor decides it, so a
|
|
113
|
+
// stale or tampered client cannot stamp a "plain" collection with html or the reverse.
|
|
114
|
+
export function storageFormatOf(collection: ContentCollectionDefinition): ContentFormat {
|
|
115
|
+
return collection.contentFormat === "document" ? "html" : "plain";
|
|
116
|
+
}
|
|
@@ -48,6 +48,7 @@ export function makeSetWrite(policy: TemplateEditPolicy) {
|
|
|
48
48
|
kind: z.enum(TEMPLATE_KINDS).optional(),
|
|
49
49
|
title: z.string().min(1).max(200),
|
|
50
50
|
content: z.string().max(200_000).nullable(),
|
|
51
|
+
// Provisioning callers omit it and rely on markdown.
|
|
51
52
|
contentFormat: contentFormatSchema.default("markdown"),
|
|
52
53
|
/** Folder path for the content tree. Optional + null = root node. */
|
|
53
54
|
folder: folderSchema.nullable().optional(),
|
|
@@ -2,7 +2,13 @@ import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
|
2
2
|
import * as z from "zod";
|
|
3
3
|
import { templateResourceEntity, templateResourcesTable } from "../table.ts";
|
|
4
4
|
import { templateSlugSchema } from "../template-meta.ts";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
CONTENT_FORMATS,
|
|
7
|
+
SLUG_MAX_LENGTH,
|
|
8
|
+
SLUG_PATTERN,
|
|
9
|
+
TEMPLATE_STATUSES,
|
|
10
|
+
UPSERT_KINDS,
|
|
11
|
+
} from "../web/constants.ts";
|
|
6
12
|
|
|
7
13
|
// One executor per bundle, shared by all four handlers for create/update via
|
|
8
14
|
// event store + optimistic lock.
|
|
@@ -14,8 +20,8 @@ export const executor = createEventStoreExecutor(templateResourcesTable, templat
|
|
|
14
20
|
export const slugSchema = z
|
|
15
21
|
.string()
|
|
16
22
|
.min(1)
|
|
17
|
-
.max(
|
|
18
|
-
.regex(
|
|
23
|
+
.max(SLUG_MAX_LENGTH)
|
|
24
|
+
.regex(SLUG_PATTERN, "slug must be kebab-case (lowercase, digits, dashes)");
|
|
19
25
|
|
|
20
26
|
export { templateSlugSchema };
|
|
21
27
|
|