@cosmicdrift/kumiko-bundled-features 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 (78) hide show
  1. package/dist/inbound-provider-imap/feature.js +2 -91
  2. package/dist/inbound-provider-imap/watch-mailbox.d.ts +5 -0
  3. package/dist/inbound-provider-imap/watch-mailbox.js +92 -0
  4. package/dist/managed-pages/constants.d.ts +1 -0
  5. package/dist/managed-pages/constants.js +1 -0
  6. package/dist/managed-pages/feature.js +5 -3
  7. package/dist/managed-pages/handlers/by-slug.query.d.ts +1 -0
  8. package/dist/managed-pages/handlers/by-slug.query.js +2 -0
  9. package/dist/managed-pages/handlers/page-body-guard.d.ts +2 -0
  10. package/dist/managed-pages/handlers/page-body-guard.js +46 -0
  11. package/dist/managed-pages/handlers/page-body-issues.d.ts +10 -0
  12. package/dist/managed-pages/handlers/page-body-issues.js +21 -0
  13. package/dist/managed-pages/handlers/page-crud.js +5 -4
  14. package/dist/managed-pages/handlers/set.write.d.ts +4 -0
  15. package/dist/managed-pages/handlers/set.write.js +16 -4
  16. package/dist/managed-pages/render-page-body.d.ts +10 -0
  17. package/dist/managed-pages/render-page-body.js +19 -0
  18. package/dist/managed-pages/screens/page-screens.js +6 -2
  19. package/dist/managed-pages/seeding.d.ts +2 -0
  20. package/dist/managed-pages/seeding.js +12 -2
  21. package/dist/managed-pages/table.d.ts +46 -0
  22. package/dist/managed-pages/table.js +5 -3
  23. package/dist/managed-pages/web/client-plugin.js +6 -4
  24. package/dist/managed-pages/web/constants.d.ts +5 -0
  25. package/dist/managed-pages/web/constants.js +9 -0
  26. package/dist/managed-pages/web/i18n.js +12 -0
  27. package/dist/managed-pages/web/lossy-markdown.d.ts +3 -0
  28. package/dist/managed-pages/web/lossy-markdown.js +59 -0
  29. package/dist/managed-pages/web/markdown-to-document.d.ts +1 -0
  30. package/dist/managed-pages/web/markdown-to-document.js +26 -0
  31. package/dist/managed-pages/web/page-body-section.d.ts +3 -0
  32. package/dist/managed-pages/web/page-body-section.js +41 -0
  33. package/dist/template-resolver/document-sanitizer.d.ts +6 -2
  34. package/dist/template-resolver/document-sanitizer.js +5 -4
  35. package/dist/template-resolver/feature.js +12 -1
  36. package/dist/template-resolver/handlers/collection-set.write.d.ts +1 -6
  37. package/dist/template-resolver/handlers/collection-set.write.js +21 -6
  38. package/dist/template-resolver/handlers/collection-shared.d.ts +2 -0
  39. package/dist/template-resolver/handlers/collection-shared.js +5 -0
  40. package/dist/template-resolver/handlers/set.write.js +1 -0
  41. package/dist/template-resolver/handlers/shared.js +3 -3
  42. package/dist/template-resolver/web/client-plugin.d.ts +0 -15
  43. package/dist/template-resolver/web/client-plugin.js +8 -363
  44. package/dist/template-resolver/web/collection-entry-create-form.d.ts +11 -0
  45. package/dist/template-resolver/web/collection-entry-create-form.js +31 -0
  46. package/dist/template-resolver/web/collection-lookup.d.ts +4 -0
  47. package/dist/template-resolver/web/collection-lookup.js +24 -0
  48. package/dist/template-resolver/web/constants.d.ts +2 -0
  49. package/dist/template-resolver/web/constants.js +2 -0
  50. package/dist/template-resolver/web/content-field.d.ts +9 -0
  51. package/dist/template-resolver/web/content-field.js +14 -0
  52. package/dist/template-resolver/web/content-tree-provider.d.ts +14 -0
  53. package/dist/template-resolver/web/content-tree-provider.js +197 -0
  54. package/dist/template-resolver/web/document-reference-name.d.ts +1 -0
  55. package/dist/template-resolver/web/document-reference-name.js +9 -0
  56. package/dist/template-resolver/web/document-schema.d.ts +2 -1
  57. package/dist/template-resolver/web/document-schema.js +2 -8
  58. package/dist/template-resolver/web/i18n.js +6 -0
  59. package/dist/template-resolver/web/legacy-variable-text.d.ts +1 -0
  60. package/dist/template-resolver/web/legacy-variable-text.js +23 -0
  61. package/dist/template-resolver/web/tenant-override.d.ts +3 -0
  62. package/dist/template-resolver/web/tenant-override.js +4 -0
  63. package/dist/template-resolver/web/text-block-editor.d.ts +6 -0
  64. package/dist/template-resolver/web/text-block-editor.js +22 -0
  65. package/dist/template-resolver/web/use-collection-editor-context.d.ts +14 -0
  66. package/dist/template-resolver/web/use-collection-editor-context.js +21 -0
  67. package/dist/template-resolver/web/use-collection-entry-create.d.ts +18 -0
  68. package/dist/template-resolver/web/use-collection-entry-create.js +78 -0
  69. package/dist/template-resolver/web/use-content-write.d.ts +17 -0
  70. package/dist/template-resolver/web/use-content-write.js +54 -0
  71. package/dist/template-resolver/web/use-text-block-editor.d.ts +19 -0
  72. package/dist/template-resolver/web/use-text-block-editor.js +85 -0
  73. package/dist/tier-engine/feature.js +9 -1
  74. package/package.json +11 -11
  75. package/src/inbound-provider-imap/changes.json +6 -0
  76. package/src/managed-pages/changes.json +7 -0
  77. package/src/template-resolver/changes.json +8 -0
  78. package/src/tier-engine/changes.json +6 -0
@@ -1,3 +1,4 @@
1
+ import { type BodyFormat } from "./constants.ts";
1
2
  export declare const pageEntity: import("@cosmicdrift/kumiko-types/fields").EntityDefinition<{
2
3
  readonly slug: import("@cosmicdrift/kumiko-types/fields").EntityTextFieldDef & {
3
4
  required: true;
@@ -11,6 +12,28 @@ export declare const pageEntity: import("@cosmicdrift/kumiko-types/fields").Enti
11
12
  readonly body: import("@cosmicdrift/kumiko-types/fields").EntityTextFieldDef & {
12
13
  required: false;
13
14
  };
15
+ readonly bodyFormat: {
16
+ readonly type: "select";
17
+ readonly description?: string;
18
+ readonly options: readonly ["markdown", "document"];
19
+ readonly required?: boolean;
20
+ readonly sortable?: boolean;
21
+ readonly filterable?: boolean;
22
+ readonly sensitive?: boolean;
23
+ readonly default?: "document" | "markdown" | undefined;
24
+ readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
25
+ readonly display?: "radio" | "dropdown";
26
+ readonly optionTones?: {
27
+ readonly document?: "accent" | "bad" | "critical" | "neutral" | "ok" | "warn" | undefined;
28
+ readonly markdown?: "accent" | "bad" | "critical" | "neutral" | "ok" | "warn" | undefined;
29
+ } | undefined;
30
+ readonly optionsQuery?: string;
31
+ readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
32
+ readonly optionsAvailabilityQuery?: string;
33
+ readonly conditionalOptions?: readonly import("@cosmicdrift/kumiko-types/fields").SelectConditionalOptions<"document" | "markdown">[] | undefined;
34
+ } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
35
+ required: false;
36
+ };
14
37
  readonly description: import("@cosmicdrift/kumiko-types/fields").EntityTextFieldDef & {
15
38
  required: false;
16
39
  };
@@ -35,6 +58,28 @@ export declare const pagesTable: import("@cosmicdrift/kumiko-framework/db").Enti
35
58
  readonly body: import("@cosmicdrift/kumiko-types/fields").EntityTextFieldDef & {
36
59
  required: false;
37
60
  };
61
+ readonly bodyFormat: {
62
+ readonly type: "select";
63
+ readonly description?: string;
64
+ readonly options: readonly ["markdown", "document"];
65
+ readonly required?: boolean;
66
+ readonly sortable?: boolean;
67
+ readonly filterable?: boolean;
68
+ readonly sensitive?: boolean;
69
+ readonly default?: "document" | "markdown" | undefined;
70
+ readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
71
+ readonly display?: "radio" | "dropdown";
72
+ readonly optionTones?: {
73
+ readonly document?: "accent" | "bad" | "critical" | "neutral" | "ok" | "warn" | undefined;
74
+ readonly markdown?: "accent" | "bad" | "critical" | "neutral" | "ok" | "warn" | undefined;
75
+ } | undefined;
76
+ readonly optionsQuery?: string;
77
+ readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
78
+ readonly optionsAvailabilityQuery?: string;
79
+ readonly conditionalOptions?: readonly import("@cosmicdrift/kumiko-types/fields").SelectConditionalOptions<"document" | "markdown">[] | undefined;
80
+ } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
81
+ required: false;
82
+ };
38
83
  readonly description: import("@cosmicdrift/kumiko-types/fields").EntityTextFieldDef & {
39
84
  required: false;
40
85
  };
@@ -54,6 +99,7 @@ export type PageRow = {
54
99
  readonly lang: string;
55
100
  readonly title: string;
56
101
  readonly body: string | null;
102
+ readonly bodyFormat: BodyFormat | null;
57
103
  readonly description: string | null;
58
104
  readonly ogImage: string | null;
59
105
  readonly published: boolean;
@@ -1,15 +1,16 @@
1
1
  import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
2
- import { createBooleanField, createEntity, createTextField, } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { createBooleanField, createEntity, createSelectField, createTextField, } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { BODY_FORMATS, DEFAULT_BODY_FORMAT } from "./constants.js";
3
4
  // Page — a tenant-editable, server-rendered public page (landing,
4
5
  // about, custom). Exactly one row per (tenantId, slug, lang). The body is
5
- // Markdown, rendered securely on the server via page-render. `published`
6
+ // Markdown or an editor document (bodyFormat), rendered securely on the server. `published`
6
7
  // gates delivery to anonymous visitors: drafts return 404. description and
7
8
  // ogImage support SEO and social previews. SYSTEM_TENANT_ID is used for
8
9
  // app-wide pages; otherwise the page belongs to the tenant resolved from the
9
10
  // host via resolveApexTenant during rendering.
10
11
  export const pageEntity = createEntity({
11
12
  table: "read_pages",
12
- description: "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.",
13
+ description: "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.",
13
14
  fields: {
14
15
  slug: createTextField({
15
16
  required: true,
@@ -45,6 +46,7 @@ export const pageEntity = createEntity({
45
46
  personal: false,
46
47
  reason: "is_business_data",
47
48
  }),
49
+ bodyFormat: createSelectField({ options: BODY_FORMATS, default: DEFAULT_BODY_FORMAT }),
48
50
  description: createTextField({ maxLength: 500, personal: false, reason: "is_business_data" }),
49
51
  ogImage: createTextField({ maxLength: 2000, personal: false, reason: "is_business_data" }),
50
52
  published: createBooleanField({ default: false }),
@@ -1,13 +1,15 @@
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
  import { mergeTranslations } from "@cosmicdrift/kumiko-renderer";
6
+ import { PAGE_BODY_EXTENSION_NAME } from "./constants.js";
7
7
  import { defaultTranslations } from "./default-translations.js";
8
+ import { PageBodySection } from "./page-body-section.js";
8
9
  export function managedPagesClient(options) {
9
10
  return {
10
11
  name: "managed-pages",
11
12
  translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
13
+ extensionSectionComponents: { [PAGE_BODY_EXTENSION_NAME]: PageBodySection },
12
14
  };
13
15
  }
@@ -0,0 +1,5 @@
1
+ export declare const PAGE_BODY_EXTENSION_NAME = "managed-pages:page-body";
2
+ export declare const BODY_FORMATS: readonly ["markdown", "document"];
3
+ export type BodyFormat = (typeof BODY_FORMATS)[number];
4
+ export declare const DEFAULT_BODY_FORMAT: BodyFormat;
5
+ export declare function effectiveBodyFormat(value: unknown): BodyFormat;
@@ -0,0 +1,9 @@
1
+ import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
2
+ // Pure data shared by the server (screen, handlers, render) and the client plugin.
3
+ export const PAGE_BODY_EXTENSION_NAME = "managed-pages:page-body";
4
+ export const BODY_FORMATS = ["markdown", "document"];
5
+ // Rows and events written before bodyFormat existed carry no value.
6
+ export const DEFAULT_BODY_FORMAT = "markdown";
7
+ export function effectiveBodyFormat(value) {
8
+ return isOneOf(BODY_FORMATS, value) ? value : DEFAULT_BODY_FORMAT;
9
+ }
@@ -43,6 +43,18 @@ export const MANAGED_PAGES_I18N = {
43
43
  "managed-pages:section.meta": { en: "Metadata" },
44
44
  "managed-pages:section.body": { en: "Content" },
45
45
  "managed-pages:branding.section.identity": { en: "Identity" },
46
+ // Document body: write-time rejection and the Markdown conversion warning
47
+ "managed-pages.errors.document.rejected": {
48
+ en: "The content contains elements that are not allowed: {detail}",
49
+ },
50
+ "managed-pages:body.lossy.warning": {
51
+ en: "This page was written in Markdown. Converting it to the editor drops: {features}. Check the result before saving.",
52
+ },
53
+ "managed-pages:body.lossy.image": { en: "images" },
54
+ "managed-pages:body.lossy.blockquote": { en: "block quotes" },
55
+ "managed-pages:body.lossy.code": { en: "code" },
56
+ "managed-pages:body.lossy.deepHeading": { en: "headings below level 3" },
57
+ "managed-pages:body.lossy.relativeLink": { en: "relative links" },
46
58
  // Row-Actions + Confirm (pageListScreen)
47
59
  "managed-pages:actions.edit": { en: "Edit" },
48
60
  "managed-pages:actions.delete": { en: "Delete" },
@@ -0,0 +1,3 @@
1
+ export declare const LOSSY_MARKDOWN_FEATURES: readonly ["image", "blockquote", "code", "deepHeading", "relativeLink"];
2
+ export type LossyMarkdownFeature = (typeof LOSSY_MARKDOWN_FEATURES)[number];
3
+ export declare function detectLossyMarkdown(markdown: string): readonly LossyMarkdownFeature[];
@@ -0,0 +1,59 @@
1
+ import { isAllowedDocumentLinkHref } from "@cosmicdrift/kumiko-bundled-features/template-resolver/document-schema";
2
+ import { Lexer } from "marked";
3
+ export const LOSSY_MARKDOWN_FEATURES = [
4
+ "image",
5
+ "blockquote",
6
+ "code",
7
+ "deepHeading",
8
+ "relativeLink",
9
+ ];
10
+ // The document editor has no image, quote or code nodes, only three heading
11
+ // levels, and links must pass the document link policy (absolute http(s),
12
+ // mailto, tel, placeholders).
13
+ const MAX_DOCUMENT_HEADING_DEPTH = 3;
14
+ function tokenList(value) {
15
+ return Array.isArray(value) ? value : [];
16
+ }
17
+ function childTokens(token) {
18
+ const nested = [...tokenList("tokens" in token ? token.tokens : undefined)];
19
+ if (token.type === "list")
20
+ nested.push(...tokenList("items" in token ? token.items : undefined));
21
+ return nested;
22
+ }
23
+ function isDeepHeading(token) {
24
+ return ("depth" in token && typeof token.depth === "number" && token.depth > MAX_DOCUMENT_HEADING_DEPTH);
25
+ }
26
+ function isRelativeLink(token) {
27
+ return ("href" in token && typeof token.href === "string" && !isAllowedDocumentLinkHref(token.href));
28
+ }
29
+ function featuresOf(token) {
30
+ switch (token.type) {
31
+ case "image":
32
+ return ["image"];
33
+ case "blockquote":
34
+ return ["blockquote"];
35
+ case "code":
36
+ case "codespan":
37
+ return ["code"];
38
+ case "heading":
39
+ return isDeepHeading(token) ? ["deepHeading"] : [];
40
+ case "link":
41
+ return isRelativeLink(token) ? ["relativeLink"] : [];
42
+ default:
43
+ return [];
44
+ }
45
+ }
46
+ function collect(tokens, found) {
47
+ for (const token of tokens) {
48
+ for (const feature of featuresOf(token))
49
+ found.add(feature);
50
+ collect(childTokens(token), found);
51
+ }
52
+ }
53
+ // Lexer options match the renderer used for conversion (gfm off), so the
54
+ // warning lists exactly what the converted HTML loses.
55
+ export function detectLossyMarkdown(markdown) {
56
+ const found = new Set();
57
+ collect(new Lexer({ gfm: false, breaks: false }).lex(markdown), found);
58
+ return LOSSY_MARKDOWN_FEATURES.filter((feature) => found.has(feature));
59
+ }
@@ -0,0 +1 @@
1
+ export declare function markdownToDocumentHtml(markdown: string): string;
@@ -0,0 +1,26 @@
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
+ // The server sanitizer rejects what it cannot keep, so the conversion drops it up front and the
5
+ // lossy banner stays true: a link outside the document link policy keeps its text, an image
6
+ // (no uploaded resource to point at) is dropped, and raw HTML is shown as text.
7
+ const converter = new Marked({ gfm: false, breaks: false });
8
+ converter.use({
9
+ renderer: {
10
+ html({ text }) {
11
+ return escapeHtml(text);
12
+ },
13
+ link({ href, tokens }) {
14
+ return isAllowedDocumentLinkHref(href) ? false : this.parser.parseInline(tokens);
15
+ },
16
+ image() {
17
+ return "";
18
+ },
19
+ },
20
+ });
21
+ export function markdownToDocumentHtml(markdown) {
22
+ const html = converter.parse(markdown, { async: false });
23
+ if (typeof html !== "string")
24
+ throw new Error("marked returned a promise for a sync parse");
25
+ return html;
26
+ }
@@ -0,0 +1,3 @@
1
+ import { type ExtensionSectionProps } from "@cosmicdrift/kumiko-renderer";
2
+ import { type ReactNode } from "react";
3
+ export declare function PageBodySection({ values, patch }: ExtensionSectionProps): ReactNode;
@@ -0,0 +1,41 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { usePrimitives, useTranslation, } from "@cosmicdrift/kumiko-renderer";
3
+ import { useMemo, useState } from "react";
4
+ import { DocumentContentEditor } from "../../template-resolver/web/index.js";
5
+ import { effectiveBodyFormat } from "./constants.js";
6
+ import { detectLossyMarkdown } from "./lossy-markdown.js";
7
+ import { markdownToDocumentHtml } from "./markdown-to-document.js";
8
+ const LOSSY_LABEL_KEYS = {
9
+ image: "managed-pages:body.lossy.image",
10
+ blockquote: "managed-pages:body.lossy.blockquote",
11
+ code: "managed-pages:body.lossy.code",
12
+ deepHeading: "managed-pages:body.lossy.deepHeading",
13
+ relativeLink: "managed-pages:body.lossy.relativeLink",
14
+ };
15
+ const BODY_EDITOR_ID = "managed-pages-page-body";
16
+ function stringValue(value) {
17
+ return typeof value === "string" ? value : "";
18
+ }
19
+ // A Markdown body is converted for display only: nothing is patched until the
20
+ // user edits, so merely opening a page never turns it into a document.
21
+ export function PageBodySection({ values, patch }) {
22
+ const t = useTranslation();
23
+ const { Banner } = usePrimitives();
24
+ const body = stringValue(values?.["body"]);
25
+ const isMarkdown = effectiveBodyFormat(values?.["bodyFormat"]) === "markdown";
26
+ const [editedHtml, setEditedHtml] = useState(null);
27
+ const [lossyAtFirstEdit, setLossyAtFirstEdit] = useState(null);
28
+ const pristine = editedHtml === null;
29
+ const convertedHtml = useMemo(() => (isMarkdown ? markdownToDocumentHtml(body) : body), [isMarkdown, body]);
30
+ const liveLossy = useMemo(() => (pristine && isMarkdown ? detectLossyMarkdown(body) : []), [pristine, isMarkdown, body]);
31
+ const lossy = lossyAtFirstEdit ?? liveLossy;
32
+ const handleChange = (next) => {
33
+ if (pristine)
34
+ setLossyAtFirstEdit(liveLossy);
35
+ setEditedHtml(next);
36
+ patch?.({ body: next, bodyFormat: "document" });
37
+ };
38
+ return (_jsxs(_Fragment, { children: [lossy.length > 0 && (_jsx(Banner, { variant: "warning", children: t("managed-pages:body.lossy.warning", {
39
+ features: lossy.map((feature) => t(LOSSY_LABEL_KEYS[feature])).join(", "),
40
+ }) })), _jsx(DocumentContentEditor, { id: BODY_EDITOR_ID, value: editedHtml ?? convertedHtml, onChange: handleChange, variables: [], readOnly: false })] }));
41
+ }
@@ -28,10 +28,14 @@ export declare function sanitizeDocumentHtml(html: string): SanitizeHtmlResult;
28
28
  export declare function parseDocumentJson(html: string): JSONContent;
29
29
  export declare function serializeDocumentJson(doc: JSONContent): string;
30
30
  export declare function flattenParagraphsToLineBreaks(sanitizedHtml: string): string;
31
- export declare function sanitizeTemplateBody(input: {
31
+ type SanitizeBodyInput = {
32
32
  readonly kind: string;
33
33
  readonly contentFormat: ContentFormat;
34
34
  readonly content: string | null;
35
- }): SanitizeBodyResult;
35
+ /** A document collection stores html whatever its kind, so its html is always rebuilt. */
36
+ readonly alwaysSanitizeHtml?: boolean;
37
+ };
38
+ export declare function sanitizeTemplateBody(input: SanitizeBodyInput): SanitizeBodyResult;
36
39
  export declare function documentViolationsToFieldIssues(violations: readonly DocumentViolation[]): FieldIssue[];
37
40
  export declare function conditionScopeElement(block: ScanElement): ScanElement;
41
+ export {};
@@ -176,6 +176,9 @@ export function flattenParagraphsToLineBreaks(sanitizedHtml) {
176
176
  function isSanitizedHtmlKind(kind) {
177
177
  return kind === "snippet" || isOneOf(DOCUMENT_HTML_KINDS, kind);
178
178
  }
179
+ function isSanitizedBody(input) {
180
+ return input.alwaysSanitizeHtml === true || isSanitizedHtmlKind(input.kind);
181
+ }
179
182
  const HTML_ONLY_KINDS = new Set(DOCUMENT_HTML_KINDS);
180
183
  function requiredFormatViolation(kind, contentFormat) {
181
184
  if (HTML_ONLY_KINDS.has(kind) && contentFormat !== "html") {
@@ -188,7 +191,7 @@ const IMAGES_BLOCK_KIND = "document-pdf";
188
191
  // rebuild; only a PDF resolves the record's images.
189
192
  function imagesBlockViolation(input) {
190
193
  if (input.kind === IMAGES_BLOCK_KIND ||
191
- !isSanitizedHtmlKind(input.kind) ||
194
+ !isSanitizedBody(input) ||
192
195
  input.contentFormat !== "html" ||
193
196
  input.content === null) {
194
197
  return null;
@@ -205,9 +208,7 @@ export function sanitizeTemplateBody(input) {
205
208
  const imagesViolation = imagesBlockViolation(input);
206
209
  if (imagesViolation !== null)
207
210
  return { ok: false, violations: [imagesViolation] };
208
- if (!isSanitizedHtmlKind(input.kind) ||
209
- input.contentFormat !== "html" ||
210
- input.content === null) {
211
+ if (!isSanitizedBody(input) || input.contentFormat !== "html" || input.content === null) {
211
212
  return { ok: true, content: input.content };
212
213
  }
213
214
  const result = sanitizeDocumentHtml(input.content);
@@ -58,6 +58,13 @@ function assertTenantEditableValid(entries) {
58
58
  }
59
59
  }
60
60
  }
61
+ function newEntryAction(collectionId) {
62
+ return {
63
+ icon: "plus",
64
+ label: "template-resolver.editor.newEntry",
65
+ target: { featureId: "template-resolver", action: "create", args: { collectionId } },
66
+ };
67
+ }
61
68
  export function createTemplateResolverFeature(opts = {}) {
62
69
  const collections = opts.collections ?? [];
63
70
  assertCollectionKindsAllowed(collections);
@@ -77,7 +84,7 @@ export function createTemplateResolverFeature(opts = {}) {
77
84
  "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.",
78
85
  '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.',
79
86
  "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.",
80
- "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).",
87
+ "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).",
81
88
  '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.',
82
89
  "`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.",
83
90
  "`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.",
@@ -143,6 +150,10 @@ export function createTemplateResolverFeature(opts = {}) {
143
150
  r.contentCollection({
144
151
  ...collection,
145
152
  access: collection.access ?? DEFAULT_COLLECTION_ACCESS,
153
+ nav: {
154
+ ...collection.nav,
155
+ createAction: collection.nav.createAction ?? newEntryAction(collection.id),
156
+ },
146
157
  });
147
158
  r.queryHandler(makeCollectionListQuery(collection));
148
159
  r.queryHandler(makeCollectionItemQuery(collection));
@@ -6,13 +6,8 @@ export declare function makeCollectionSetWrite(collection: ContentCollectionDefi
6
6
  locale: z.ZodString;
7
7
  title: z.ZodString;
8
8
  content: z.ZodNullable<z.ZodString>;
9
- contentFormat: z.ZodDefault<z.ZodEnum<{
10
- html: "html";
11
- markdown: "markdown";
12
- mjml: "mjml";
13
- plain: "plain";
14
- }>>;
15
9
  folder: z.ZodOptional<z.ZodNullable<z.ZodString>>;
10
+ createOnly: z.ZodOptional<z.ZodBoolean>;
16
11
  tenantIdOverride: z.ZodOptional<z.ZodString>;
17
12
  }, z.core.$strip>, {
18
13
  slug: string;
@@ -7,8 +7,8 @@ import { systemTemplateExists } from "../api.js";
7
7
  import { documentViolationsToFieldIssues, sanitizeTemplateBody } from "../document-sanitizer.js";
8
8
  import { refuseTemplateEdit } from "../template-edit-policy.js";
9
9
  import { DEFAULT_COLLECTION_ACCESS, isCollectionRefusedKind } from "../web/constants.js";
10
- import { collectionStore } from "./collection-shared.js";
11
- import { contentFormatSchema, folderSchema, localeSchema, slugSchema } from "./shared.js";
10
+ import { collectionStore, storageFormatOf } from "./collection-shared.js";
11
+ import { folderSchema, localeSchema, slugSchema } from "./shared.js";
12
12
  // Upsert inside one collection. Mirrors set.write for text-blocks, but the
13
13
  // kind comes from the declaration and the access rule is the collection's own,
14
14
  // so an app can let agents edit their snippets without letting them near the
@@ -18,6 +18,7 @@ import { contentFormatSchema, folderSchema, localeSchema, slugSchema } from "./s
18
18
  // — the draft stage lives on upsertTenant + publish.
19
19
  export function makeCollectionSetWrite(collection, editPolicy) {
20
20
  const store = collectionStore(collection);
21
+ const contentFormat = storageFormatOf(collection);
21
22
  return defineWriteHandler({
22
23
  name: `${collection.id}-set`,
23
24
  schema: z.object({
@@ -25,8 +26,9 @@ export function makeCollectionSetWrite(collection, editPolicy) {
25
26
  locale: localeSchema,
26
27
  title: z.string().min(1).max(200),
27
28
  content: z.string().max(200_000).nullable(),
28
- contentFormat: contentFormatSchema.default("markdown"),
29
29
  folder: folderSchema.nullable().optional(),
30
+ /** The create form sets it so a taken slug is reported instead of overwritten. */
31
+ createOnly: z.boolean().optional(),
30
32
  /** Optional cross-tenant write — SystemAdmin only. */
31
33
  tenantIdOverride: z.string().min(1).optional(),
32
34
  }),
@@ -59,8 +61,9 @@ export function makeCollectionSetWrite(collection, editPolicy) {
59
61
  const owner = store.scopeOf(event.user);
60
62
  const body = sanitizeTemplateBody({
61
63
  kind: collection.kind,
62
- contentFormat: event.payload.contentFormat,
64
+ contentFormat,
63
65
  content: event.payload.content,
66
+ alwaysSanitizeHtml: collection.contentFormat === "document",
64
67
  });
65
68
  if (!body.ok) {
66
69
  return writeFailure(new ValidationError({ fields: documentViolationsToFieldIssues(body.violations) }));
@@ -82,6 +85,18 @@ export function makeCollectionSetWrite(collection, editPolicy) {
82
85
  locale: event.payload.locale,
83
86
  ...owner,
84
87
  });
88
+ if (existing && event.payload.createOnly === true) {
89
+ return writeFailure(new ValidationError({
90
+ fields: [
91
+ {
92
+ path: "slug",
93
+ code: "slug_exists",
94
+ i18nKey: "templateResolver.errors.collection.slugExists",
95
+ params: { detail: event.payload.slug },
96
+ },
97
+ ],
98
+ }));
99
+ }
85
100
  if (existing) {
86
101
  const result = await store.executor.update({
87
102
  id: existing.id,
@@ -92,7 +107,7 @@ export function makeCollectionSetWrite(collection, editPolicy) {
92
107
  changes: {
93
108
  title: event.payload.title,
94
109
  content: body.content,
95
- contentFormat: event.payload.contentFormat,
110
+ contentFormat,
96
111
  folder: event.payload.folder ?? null,
97
112
  },
98
113
  }, event.user, db, { streamTenantId });
@@ -110,7 +125,7 @@ export function makeCollectionSetWrite(collection, editPolicy) {
110
125
  locale: event.payload.locale,
111
126
  title: event.payload.title,
112
127
  content: body.content,
113
- contentFormat: event.payload.contentFormat,
128
+ contentFormat,
114
129
  folder: event.payload.folder ?? null,
115
130
  ...store.createDefaults(tenantId),
116
131
  ...owner,
@@ -3,6 +3,7 @@ import type { ContentCollectionDefinition, SessionUser, TenantId } from "@cosmic
3
3
  import type { Temporal } from "@cosmicdrift/kumiko-types/temporal";
4
4
  import { templateResourcesTable } from "../table.ts";
5
5
  import { userContentEntriesTable } from "../user-content-table.ts";
6
+ import type { ContentFormat } from "../web/constants.ts";
6
7
  export type CollectionEntryRow = {
7
8
  readonly id: string | number;
8
9
  readonly version: number;
@@ -43,3 +44,4 @@ export declare function toCollectionEntry(row: CollectionEntryRow): {
43
44
  folder: string | null;
44
45
  modifiedAt: globalThis.Temporal.Instant | null;
45
46
  };
47
+ export declare function storageFormatOf(collection: ContentCollectionDefinition): ContentFormat;
@@ -60,3 +60,8 @@ export function toCollectionEntry(row) {
60
60
  modifiedAt: row.modifiedAt,
61
61
  };
62
62
  }
63
+ // The client never names the storage format: the declared editor decides it, so a
64
+ // stale or tampered client cannot stamp a "plain" collection with html or the reverse.
65
+ export function storageFormatOf(collection) {
66
+ return collection.contentFormat === "document" ? "html" : "plain";
67
+ }
@@ -39,6 +39,7 @@ export function makeSetWrite(policy) {
39
39
  kind: z.enum(TEMPLATE_KINDS).optional(),
40
40
  title: z.string().min(1).max(200),
41
41
  content: z.string().max(200_000).nullable(),
42
+ // Provisioning callers omit it and rely on markdown.
42
43
  contentFormat: contentFormatSchema.default("markdown"),
43
44
  /** Folder path for the content tree. Optional + null = root node. */
44
45
  folder: folderSchema.nullable().optional(),
@@ -2,7 +2,7 @@ import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
2
2
  import * as z from "zod";
3
3
  import { templateResourceEntity, templateResourcesTable } from "../table.js";
4
4
  import { templateSlugSchema } from "../template-meta.js";
5
- import { CONTENT_FORMATS, TEMPLATE_STATUSES, UPSERT_KINDS } from "../web/constants.js";
5
+ import { CONTENT_FORMATS, SLUG_MAX_LENGTH, SLUG_PATTERN, TEMPLATE_STATUSES, UPSERT_KINDS, } from "../web/constants.js";
6
6
  // One executor per bundle, shared by all four handlers for create/update via
7
7
  // event store + optimistic lock.
8
8
  export const executor = createEventStoreExecutor(templateResourcesTable, templateResourceEntity, {
@@ -12,8 +12,8 @@ export const executor = createEventStoreExecutor(templateResourcesTable, templat
12
12
  export const slugSchema = z
13
13
  .string()
14
14
  .min(1)
15
- .max(80)
16
- .regex(/^[a-z0-9][a-z0-9-]*$/, "slug must be kebab-case (lowercase, digits, dashes)");
15
+ .max(SLUG_MAX_LENGTH)
16
+ .regex(SLUG_PATTERN, "slug must be kebab-case (lowercase, digits, dashes)");
17
17
  export { templateSlugSchema };
18
18
  export const localeSchema = z
19
19
  .string()
@@ -1,20 +1,5 @@
1
- import type { TreeNode } from "@cosmicdrift/kumiko-framework/engine";
2
- import { type FeatureSchema } from "@cosmicdrift/kumiko-renderer";
3
1
  import { type ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer/web";
4
- export type BlockSummary = {
5
- readonly slug: string;
6
- readonly locale: string;
7
- readonly title: string | null;
8
- readonly content: string | null;
9
- readonly folder: string | null;
10
- readonly modifiedAt: string | null;
11
- };
12
- export declare function groupBlocksByFolder(blocks: readonly BlockSummary[], tenantIdOverride?: string, collectionId?: string): readonly TreeNode[];
13
- type ContentCollectionSchema = NonNullable<FeatureSchema["contentCollections"]>[number];
14
- export declare function canWriteContent(collection: Pick<ContentCollectionSchema, "access"> | undefined, roles: readonly string[] | undefined): boolean;
15
- export declare function resolveWriteContentFormat(collectionContentFormat: string | undefined, loadedContentFormat: string | undefined): string | undefined;
16
2
  export declare function textBlocksClient(opts?: {
17
3
  readonly navId?: string;
18
4
  readonly tenantId?: string;
19
5
  }): ClientFeatureDefinition;
20
- export {};