@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.
Files changed (53) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/inbound-provider-imap/changes.json +6 -0
  4. package/packages/bundled-features/src/inbound-provider-imap/feature.ts +3 -104
  5. package/packages/bundled-features/src/inbound-provider-imap/watch-mailbox.ts +109 -0
  6. package/packages/bundled-features/src/managed-pages/changes.json +7 -0
  7. package/packages/bundled-features/src/managed-pages/constants.ts +7 -0
  8. package/packages/bundled-features/src/managed-pages/feature.ts +6 -4
  9. package/packages/bundled-features/src/managed-pages/handlers/by-slug.query.ts +2 -0
  10. package/packages/bundled-features/src/managed-pages/handlers/page-body-guard.ts +61 -0
  11. package/packages/bundled-features/src/managed-pages/handlers/page-body-issues.ts +26 -0
  12. package/packages/bundled-features/src/managed-pages/handlers/page-crud.ts +17 -10
  13. package/packages/bundled-features/src/managed-pages/handlers/set.write.ts +15 -4
  14. package/packages/bundled-features/src/managed-pages/render-page-body.ts +28 -0
  15. package/packages/bundled-features/src/managed-pages/screens/page-screens.ts +6 -2
  16. package/packages/bundled-features/src/managed-pages/seeding.ts +13 -2
  17. package/packages/bundled-features/src/managed-pages/table.ts +6 -2
  18. package/packages/bundled-features/src/managed-pages/web/client-plugin.tsx +6 -4
  19. package/packages/bundled-features/src/managed-pages/web/constants.ts +14 -0
  20. package/packages/bundled-features/src/managed-pages/web/i18n.ts +13 -0
  21. package/packages/bundled-features/src/managed-pages/web/lossy-markdown.ts +71 -0
  22. package/packages/bundled-features/src/managed-pages/web/markdown-to-document.ts +27 -0
  23. package/packages/bundled-features/src/managed-pages/web/page-body-section.tsx +73 -0
  24. package/packages/bundled-features/src/template-resolver/README.md +2 -2
  25. package/packages/bundled-features/src/template-resolver/changes.json +8 -0
  26. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +16 -16
  27. package/packages/bundled-features/src/template-resolver/feature.ts +14 -1
  28. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +24 -6
  29. package/packages/bundled-features/src/template-resolver/handlers/collection-shared.ts +7 -0
  30. package/packages/bundled-features/src/template-resolver/handlers/set.write.ts +1 -0
  31. package/packages/bundled-features/src/template-resolver/handlers/shared.ts +9 -3
  32. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +8 -576
  33. package/packages/bundled-features/src/template-resolver/web/collection-entry-create-form.tsx +105 -0
  34. package/packages/bundled-features/src/template-resolver/web/collection-lookup.ts +33 -0
  35. package/packages/bundled-features/src/template-resolver/web/constants.ts +3 -0
  36. package/packages/bundled-features/src/template-resolver/web/content-field.tsx +61 -0
  37. package/packages/bundled-features/src/template-resolver/web/content-tree-provider.ts +256 -0
  38. package/packages/bundled-features/src/template-resolver/web/document-reference-name.ts +12 -0
  39. package/packages/bundled-features/src/template-resolver/web/document-schema.ts +3 -11
  40. package/packages/bundled-features/src/template-resolver/web/i18n.ts +8 -0
  41. package/packages/bundled-features/src/template-resolver/web/legacy-variable-text.ts +30 -0
  42. package/packages/bundled-features/src/template-resolver/web/tenant-override.ts +7 -0
  43. package/packages/bundled-features/src/template-resolver/web/text-block-editor.tsx +92 -0
  44. package/packages/bundled-features/src/template-resolver/web/use-collection-editor-context.ts +42 -0
  45. package/packages/bundled-features/src/template-resolver/web/use-collection-entry-create.ts +92 -0
  46. package/packages/bundled-features/src/template-resolver/web/use-content-write.ts +81 -0
  47. package/packages/bundled-features/src/template-resolver/web/use-text-block-editor.ts +127 -0
  48. package/packages/bundled-features/src/tier-engine/changes.json +6 -0
  49. package/packages/bundled-features/src/tier-engine/feature.ts +9 -1
  50. package/samples/apps/use-all-bundled/feature-manifest.json +2 -2
  51. package/samples/apps/use-all-bundled/src/app/client.tsx +6 -1
  52. package/samples/apps/use-all-bundled/src/app/seed.ts +13 -1
  53. 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: opts.body ?? null,
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: opts.body ?? null,
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 via page-render. `published`
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. It provides ONLY translations —
2
- // the screens (page-list/page-edit/branding-settings) are schema-driven
3
- // (entityList/entityEdit/configEdit), so no custom React components are
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 requires an explicit `nav.createAction`, whose target is usually
236
- `treeHandle.create`; without it, the collection only lists existing items. `nav.actions` adds hover actions to the row.
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
- !isSanitizedHtmlKind(input.kind) ||
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, `rich` a small WYSIWYG (bold, italic, headings, lists, links), `markdown` a text area that stores markdown text (same insertable chips as `plain`). All three 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).",
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 { contentFormatSchema, folderSchema, localeSchema, slugSchema } from "./shared.ts";
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: event.payload.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: event.payload.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: event.payload.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 { CONTENT_FORMATS, TEMPLATE_STATUSES, UPSERT_KINDS } from "../web/constants.ts";
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(80)
18
- .regex(/^[a-z0-9][a-z0-9-]*$/, "slug must be kebab-case (lowercase, digits, dashes)");
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