@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,358 +1,9 @@
1
- import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { EMPTY_VALUE_PLACEHOLDER } from "@cosmicdrift/kumiko-renderer/headless";
3
- // Client feature factory for the text-block content tree. Apps mount it via
4
- // createKumikoApp({ clientFeatures: [textBlocksClient()] }); it loads the
5
- // blocks from the by-tenant query, groups them by their `folder` field and
6
- // emits them as TreeNode[], plus the editor behind the edit action.
7
- //
8
- // **Folder grouping**: block.folder !== null puts the node under a container
9
- // node labelled with the folder; folder === null keeps it at the root.
10
- //
11
- // **State**: TreeNode.state = "filled" when content is set, otherwise "stub"
12
- // (a hint that the slug exists but is empty).
13
- import { useShellUser } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/web";
14
- import { isUiAccessGranted } from "@cosmicdrift/kumiko-framework/ui-types";
15
- import { ContentPreview, useAppFeatures, useContentEditor, useDispatcher, usePrimitives, useQuery, useTranslation, } from "@cosmicdrift/kumiko-renderer";
16
- import { CSRF_HEADER_NAME, readCsrfToken } from "@cosmicdrift/kumiko-renderer/live";
17
- import { PlainContentEditor, RichContentEditor, } from "@cosmicdrift/kumiko-renderer/web";
18
- import { useEffect, useId, useState } from "react";
19
- import { DEFAULT_COLLECTION_ACCESS } from "./constants.js";
20
- import { DocumentBlocksContext, DocumentContentEditor } from "./document-editor.js";
21
- import { documentBlocksForKind } from "./document-toolbar-blocks.js";
1
+ import { PlainContentEditor } from "@cosmicdrift/kumiko-renderer/web";
2
+ import { createCollectionEntryCreateForm } from "./collection-entry-create-form.js";
3
+ import { makeTreeProvider } from "./content-tree-provider.js";
4
+ import { DocumentContentEditor } from "./document-editor.js";
22
5
  import { defaultTranslations } from "./i18n.js";
23
- import { collectionHandlerName, collectionQueryName, TemplateResolverHandlers, TemplateResolverQueries, } from "./qualified-names.js";
24
- function newFolderNode() {
25
- return { leaves: [], subFolders: new Map() };
26
- }
27
- function attachBlock(root, block, tenantIdOverride, collectionId) {
28
- const leaf = {
29
- label: block.title || block.slug,
30
- icon: "file",
31
- target: {
32
- featureId: "template-resolver",
33
- action: "edit",
34
- // tenantIdOverride and collectionId travel with the node → the editor
35
- // reads and writes through the same tenant and the same collection the
36
- // tree was loaded from. Omitted → session tenant, public text-blocks.
37
- args: {
38
- slug: block.slug,
39
- locale: block.locale,
40
- ...(tenantIdOverride !== undefined && { tenantIdOverride }),
41
- ...(collectionId !== undefined && { collectionId }),
42
- },
43
- },
44
- state: block.content ? "filled" : "stub",
45
- };
46
- if (block.folder === null) {
47
- root.leaves.push(leaf);
48
- return;
49
- }
50
- let cur = root;
51
- for (const segment of block.folder.split("/")) {
52
- let next = cur.subFolders.get(segment);
53
- if (next === undefined) {
54
- next = newFolderNode();
55
- cur.subFolders.set(segment, next);
56
- }
57
- cur = next;
58
- }
59
- cur.leaves.push(leaf);
60
- }
61
- function renderFolderNode(node) {
62
- // Leaves first (root items), then folders alphabetically.
63
- const result = [...node.leaves];
64
- for (const folderName of [...node.subFolders.keys()].sort()) {
65
- const subNode = node.subFolders.get(folderName);
66
- if (subNode === undefined)
67
- continue;
68
- result.push({
69
- label: folderName,
70
- icon: "folder",
71
- state: "filled",
72
- children: renderFolderNode(subNode),
73
- });
74
- }
75
- return result;
76
- }
77
- export function groupBlocksByFolder(blocks, tenantIdOverride, collectionId) {
78
- const root = newFolderNode();
79
- for (const block of blocks) {
80
- attachBlock(root, block, tenantIdOverride, collectionId);
81
- }
82
- const rendered = renderFolderNode(root);
83
- if (rendered.length === 0)
84
- return [];
85
- return [
86
- {
87
- label: "Content",
88
- icon: "folder",
89
- state: "filled",
90
- children: rendered,
91
- },
92
- ];
93
- }
94
- // A save in the editor re-fires the tree of the collection it wrote to. The
95
- // SSE refresh does not cover every collection: per-user entries carry no
96
- // entity signal, because naming their ids to every collection reader would
97
- // reveal when colleagues change their own entries.
98
- const TEXT_BLOCK_TREE_KEY = "";
99
- const treeRefreshListeners = new Map();
100
- function onTreeRefresh(treeKey, listener) {
101
- const listeners = treeRefreshListeners.get(treeKey) ?? new Set();
102
- listeners.add(listener);
103
- treeRefreshListeners.set(treeKey, listeners);
104
- return () => listeners.delete(listener);
105
- }
106
- function refreshTree(treeKey) {
107
- for (const listener of treeRefreshListeners.get(treeKey) ?? [])
108
- listener();
109
- }
110
- async function postBlockList(queryType, tenantIdOverride) {
111
- // CSRF header is mandatory on authenticated requests (auth-middleware
112
- // double-submit pattern). Anonymous/pre-login has no token → header
113
- // omitted → the server takes the anonymous path.
114
- const headers = { "content-type": "application/json" };
115
- const csrf = readCsrfToken();
116
- if (csrf !== undefined)
117
- headers[CSRF_HEADER_NAME] = csrf;
118
- // kumiko-lint-ignore no-raw-hooks not yet converted to the framework hook set
119
- const response = await fetch("/api/query", {
120
- method: "POST",
121
- headers,
122
- body: JSON.stringify({
123
- type: queryType,
124
- payload: tenantIdOverride !== undefined ? { tenantIdOverride } : {},
125
- }),
126
- });
127
- if (!response.ok) {
128
- return {
129
- ok: false,
130
- status: response.status,
131
- body: await response.text().catch(() => response.statusText),
132
- };
133
- }
134
- const data = await response.json();
135
- return { ok: true, blocks: data.data.blocks };
136
- }
137
- const TENANT_OVERRIDE_DENIED_REASON = "tenant_override_requires_system_admin";
138
- // The dispatcher accepts tenantIdOverride from a SystemAdmin only, and the
139
- // provider cannot see the session roles. Everyone else gets this exact denial
140
- // and reads their session tenant instead, which is the only tenant they may
141
- // edit anyway.
142
- function isTenantOverrideDenied(result) {
143
- return !result.ok && result.status === 403 && result.body.includes(TENANT_OVERRIDE_DENIED_REASON);
144
- }
145
- async function loadBlocks(queryType, tenantIdOverride) {
146
- let result = await postBlockList(queryType, tenantIdOverride);
147
- let effectiveOverride = tenantIdOverride;
148
- if (tenantIdOverride !== undefined && isTenantOverrideDenied(result)) {
149
- result = await postBlockList(queryType, undefined);
150
- effectiveOverride = undefined;
151
- }
152
- if (!result.ok) {
153
- // 403 (CSRF/auth) or 5xx — explicit error instead of silent empty.
154
- throw new Error(`text-block load failed: ${result.status} ${result.body}`);
155
- }
156
- return {
157
- blocks: result.blocks,
158
- ...(effectiveOverride !== undefined && { tenantIdOverride: effectiveOverride }),
159
- };
160
- }
161
- // tenantIdOverride (SystemAdmin-only) lets an app point the content tree at a
162
- // tenant other than the session's — publicstatus seeds marketing/legal blocks
163
- // on SYSTEM_TENANT_ID, so its SystemAdmin must read that tenant, not their own.
164
- // Without a collection: the anonymous-capable by-tenant query, so a public
165
- // page keeps its content sidebar without a session. With one: that
166
- // collection's own list handler, which carries the collection's access rule.
167
- function makeTreeProvider(tenantIdOverride, collection) {
168
- const collectionId = collection?.id;
169
- const queryType = collectionId === undefined
170
- ? TemplateResolverQueries.byTenant
171
- : collectionQueryName(collectionId, "list");
172
- // A per-user entry belongs to its author in the session tenant; pointing the
173
- // tree at another tenant would only hide the author's own entries.
174
- const requestedOverride = collection?.ownership === "user" ? undefined : tenantIdOverride;
175
- return () => (emit, emitError) => {
176
- let active = true;
177
- // Each denial logs a client-fault warning on the server, so a reload after a
178
- // save does not ask again once this subscription learned the answer.
179
- let override = requestedOverride;
180
- const load = () => {
181
- loadBlocks(queryType, override)
182
- .then(({ blocks, tenantIdOverride: effectiveOverride }) => {
183
- if (!active)
184
- return;
185
- override = effectiveOverride;
186
- // The app-side r.nav node IS the "Content" container → the provider's
187
- // children are the folders/leaves below it, not the wrapper.
188
- const content = groupBlocksByFolder(blocks, effectiveOverride, collectionId)[0];
189
- emit(content !== undefined && Array.isArray(content.children) ? content.children : []);
190
- })
191
- .catch((e) => {
192
- if (!active)
193
- return;
194
- // Explicit error signal via emitError; ProviderBranch shows a banner
195
- // plus retry. Falls back to emit([]) for consumers without emitError.
196
- if (emitError) {
197
- emitError(e instanceof Error ? e : new Error(String(e)));
198
- }
199
- else {
200
- emit([]);
201
- }
202
- });
203
- };
204
- load();
205
- const unsubscribe = onTreeRefresh(collectionId ?? TEXT_BLOCK_TREE_KEY, load);
206
- return () => {
207
- active = false;
208
- unsubscribe();
209
- };
210
- };
211
- }
212
- // A node without a collectionId (the hand-wired text-block tree) has no
213
- // collection to look up — contentFormat/variableSchema then fall back to
214
- // their own callers' defaults.
215
- function findCollection(features, collectionId) {
216
- if (collectionId === undefined)
217
- return undefined;
218
- for (const feature of features) {
219
- const match = feature.contentCollections?.find((c) => c.id === collectionId);
220
- if (match !== undefined)
221
- return match;
222
- }
223
- return undefined;
224
- }
225
- function findContentFormat(features, collectionId) {
226
- return findCollection(features, collectionId)?.contentFormat;
227
- }
228
- // The collection's fixed variable names (ContentCollectionDefinition.
229
- // variableSchema) — the chip bar's data source and, keyed, the example
230
- // values the Preview substitutes in for `{{name}}`.
231
- function findVariableExamples(features, collectionId) {
232
- return findCollection(features, collectionId)?.variableSchema ?? {};
233
- }
234
- // Edit form: loads the current values, dispatches set on submit. Who may save
235
- // follows the server: a collection's set handler carries the collection's own
236
- // access rule (one rule for list, item and set), the public text-block pair
237
- // stays TenantAdmin/SystemAdmin. Other users see the form read-only.
238
- export function canWriteContent(collection, roles) {
239
- if (collection !== undefined) {
240
- return isUiAccessGranted(collection.access ?? DEFAULT_COLLECTION_ACCESS, roles);
241
- }
242
- return roles?.includes("TenantAdmin") === true || roles?.includes("SystemAdmin") === true;
243
- }
244
- // The collection's declared contentFormat ("plain" | "rich" | "markdown" |
245
- // "document", see ContentCollectionDefinition.contentFormat) uses a different vocabulary
246
- // than the stored entry's contentFormat ("plain" | "html" | "markdown" |
247
- // "mjml", see CONTENT_FORMATS) — "rich" and "document" always store HTML. Without this,
248
- // handleSave omitting contentFormat let the write schema's
249
- // contentFormatSchema.default("markdown") silently stamp every save as
250
- // markdown, clobbering e.g. a "plain" entry. Undefined (no collection, or
251
- // the collection didn't declare a format) keeps whatever was already saved
252
- // instead of guessing.
253
- // Exported for the unit test — resolveWriteContentFormat is a pure function.
254
- export function resolveWriteContentFormat(collectionContentFormat, loadedContentFormat) {
255
- if (collectionContentFormat === "plain")
256
- return "plain";
257
- if (collectionContentFormat === "rich" || collectionContentFormat === "document")
258
- return "html";
259
- if (collectionContentFormat === "markdown")
260
- return "markdown";
261
- return loadedContentFormat;
262
- }
263
- function TextBlockEditor({ target, }) {
264
- // @cast-boundary visual-tree-args — TargetRef.args is erased to
265
- // Record<string, unknown>; the resolver knows the action shape (see the
266
- // treeActions.edit definition in feature.ts) and de-erases per action, the
267
- // same way event payloads do. Optional chaining absorbs missing fields so a
268
- // hand-edited URL cannot crash the editor.
269
- const args = target.args;
270
- const slug = args?.slug ?? "";
271
- const locale = args?.locale ?? "";
272
- const tenantIdOverride = args?.tenantIdOverride;
273
- // Which collection the node came from. Absent → the public text-block pair,
274
- // which is what the hand-wired content tree uses.
275
- const collectionId = args?.collectionId;
276
- const { Form, Field, Input, Button, Banner, PageHeader } = usePrimitives();
277
- const dispatcher = useDispatcher();
278
- const user = useShellUser();
279
- const t = useTranslation();
280
- const contentEditorId = useId();
281
- const features = useAppFeatures();
282
- const contentFormat = findContentFormat(features, collectionId);
283
- const variableExamples = findVariableExamples(features, collectionId);
284
- const variables = Object.keys(variableExamples);
285
- const collection = findCollection(features, collectionId);
286
- const documentBlocks = documentBlocksForKind(collection?.kind);
287
- const ContentEditor = useContentEditor(contentFormat);
288
- const canWrite = canWriteContent(collection, user?.roles);
289
- // Same split as the tree provider: a node without a collection reads through
290
- // the public by-slug, a collection node through that collection's handlers.
291
- const readQuery = collectionId === undefined
292
- ? TemplateResolverQueries.bySlug
293
- : collectionQueryName(collectionId, "item");
294
- const writeHandler = collectionId === undefined ? TemplateResolverHandlers.set : collectionHandlerName(collectionId);
295
- const { data: loaded, loading, error: loadError, } = useQuery(readQuery, { slug, locale, ...(tenantIdOverride !== undefined && { tenantIdOverride }) }, { enabled: slug !== "" && locale !== "" });
296
- const [title, setTitle] = useState("");
297
- const [content, setContent] = useState("");
298
- const [previewing, setPreviewing] = useState(false);
299
- const [submitting, setSubmitting] = useState(false);
300
- const [saveError, setSaveError] = useState(null);
301
- const [savedMsg, setSavedMsg] = useState(null);
302
- // Sync loaded data into the form state. loaded === null means the block does
303
- // not exist yet — empty form for the create flow.
304
- // kumiko-lint-ignore no-raw-hooks not yet converted to the framework hook set
305
- useEffect(() => {
306
- if (loading)
307
- return;
308
- setTitle(loaded?.title ?? "");
309
- setContent(loaded?.content ?? "");
310
- setSaveError(null);
311
- setSavedMsg(null);
312
- }, [loading, loaded]);
313
- const handleSave = async () => {
314
- setSubmitting(true);
315
- setSaveError(null);
316
- setSavedMsg(null);
317
- try {
318
- const resolvedContentFormat = resolveWriteContentFormat(contentFormat, loaded?.contentFormat);
319
- const result = await dispatcher.write(writeHandler, {
320
- slug,
321
- locale,
322
- title,
323
- content: content.length > 0 ? content : null,
324
- folder: loaded?.folder ?? null,
325
- ...(resolvedContentFormat !== undefined && { contentFormat: resolvedContentFormat }),
326
- ...(tenantIdOverride !== undefined && { tenantIdOverride }),
327
- });
328
- if (result.isSuccess) {
329
- refreshTree(collectionId ?? TEXT_BLOCK_TREE_KEY);
330
- setSavedMsg(result.data.isNew
331
- ? t("template-resolver.editor.created")
332
- : t("template-resolver.editor.saved"));
333
- return;
334
- }
335
- setSaveError(result.error.message ?? result.error.code ?? t("template-resolver.editor.saveFailed"));
336
- }
337
- catch (e) {
338
- // Network blip / dispatcher throw — otherwise submitting stays true and
339
- // the save button is locked forever with no feedback.
340
- setSaveError(e instanceof Error ? e.message : t("template-resolver.editor.networkError"));
341
- }
342
- finally {
343
- setSubmitting(false);
344
- }
345
- };
346
- const onSubmit = (e) => {
347
- e?.preventDefault();
348
- void handleSave();
349
- };
350
- const disabled = submitting || loading || !canWrite;
351
- const headerTitle = title || slug || undefined;
352
- return (_jsxs(_Fragment, { children: [PageHeader !== undefined && headerTitle !== undefined && _jsx(PageHeader, { title: headerTitle }), _jsxs(Form, { onSubmit: onSubmit, testId: "text-block-editor", title: title || slug || EMPTY_VALUE_PLACEHOLDER, subtitle: locale !== "" ? `${slug} · (${locale})` : slug, actions: canWrite ? (_jsx(Button, { type: "submit", loading: submitting, disabled: disabled, children: submitting
353
- ? t("template-resolver.editor.saving")
354
- : t("template-resolver.editor.save") })) : undefined, children: [loading && _jsx(Banner, { variant: "loading", children: t("template-resolver.editor.loading") }), loadError !== null && (_jsxs(Banner, { variant: "error", children: [t("template-resolver.editor.loadFailed"), ": ", loadError.code] })), !canWrite && !loading && (_jsx(Banner, { variant: "info", children: t("template-resolver.editor.readOnly") })), _jsx(Field, { id: "text-block-title", label: t("template-resolver.editor.titleLabel"), required: true, children: _jsx(Input, { kind: "text", id: "text-block-title", name: "text-block-title", value: title, onChange: setTitle, disabled: disabled, required: true }) }), _jsx(Field, { id: contentEditorId, label: t("template-resolver.editor.contentLabel"), labelAppendix: _jsx(Button, { type: "button", variant: "secondary", size: "sm", onClick: () => setPreviewing((p) => !p), children: previewing ? t("kumiko.contentEditor.editMode") : t("kumiko.contentEditor.preview") }), children: previewing ? (_jsx(ContentPreview, { content: content, variables: variableExamples, contentFormat: contentFormat })) : (_jsx(DocumentBlocksContext.Provider, { value: documentBlocks, children: _jsx(ContentEditor, { id: contentEditorId, value: content, onChange: setContent, variables: variables, readOnly: disabled }) })) }), saveError !== null && _jsx(Banner, { variant: "error", children: saveError }), savedMsg !== null && _jsx(Banner, { variant: "info", children: savedMsg })] })] }));
355
- }
6
+ import { TextBlockEditor } from "./text-block-editor.js";
356
7
  // `navId` = the QN of the r.nav({ provider: true }) node the app registers for
357
8
  // the content tree (e.g. "publicstatus:nav:content"). The app owns the node's
358
9
  // label/icon/access, the feature only supplies children plus the editor.
@@ -386,19 +37,13 @@ export function textBlocksClient(opts) {
386
37
  }),
387
38
  resolvers: {
388
39
  "template-resolver:edit": TextBlockEditor,
40
+ "template-resolver:create": createCollectionEntryCreateForm(tenantId),
389
41
  },
390
- // The default for every "plain" collection (ai-prompt, mail without
391
- // an explicit rich override) — an app-registered "plain" editor on
392
- // another clientFeature still wins, last-wins same as columnRenderers.
42
+ // An app-registered editor for a format on another clientFeature still wins,
43
+ // last-wins same as columnRenderers.
393
44
  contentEditors: {
394
45
  plain: PlainContentEditor,
395
- rich: RichContentEditor,
396
46
  document: DocumentContentEditor,
397
- // ponytail: same textarea+chips as "plain" — markdown is stored as
398
- // text either way. Shares PlainContentEditor's fixed-DOM-id ceiling
399
- // (see its own file comment); swap for a live-preview widget or a
400
- // forwarded-ref variant if a collection actually needs one.
401
- markdown: PlainContentEditor,
402
47
  },
403
48
  translations: defaultTranslations,
404
49
  };
@@ -0,0 +1,11 @@
1
+ import type { TargetRef } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { ReactNode } from "react";
3
+ export declare function createCollectionEntryCreateForm(tenantId: string | undefined): (props: {
4
+ readonly target: TargetRef;
5
+ readonly onClose: () => void;
6
+ }) => ReactNode;
7
+ export declare function CollectionEntryCreateForm({ target, tenantId, }: {
8
+ readonly target: TargetRef;
9
+ readonly onClose: () => void;
10
+ readonly tenantId?: string | undefined;
11
+ }): ReactNode;
@@ -0,0 +1,31 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { FormScreenLayout, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
3
+ import { ContentField } from "./content-field.js";
4
+ import { useCollectionEntryCreate } from "./use-collection-entry-create.js";
5
+ const SLUG_INVALID_ISSUE = {
6
+ path: "slug",
7
+ code: "slug_invalid",
8
+ i18nKey: "template-resolver.editor.slugInvalid",
9
+ };
10
+ // A new entry in the app's own locale; the tree then lists it under that locale like any other.
11
+ // `tenantId` is the plugin's SystemAdmin tenant, the one the collection tree lists, so a new entry
12
+ // lands where the tree looks. Same rule as the tree: per-user collections stay on the session tenant.
13
+ export function createCollectionEntryCreateForm(tenantId) {
14
+ return function BoundCollectionEntryCreateForm(props) {
15
+ return _jsx(CollectionEntryCreateForm, { ...props, tenantId: tenantId });
16
+ };
17
+ }
18
+ function CreateFields({ state }) {
19
+ const { Field, Input, Banner } = usePrimitives();
20
+ const t = useTranslation();
21
+ const { canWrite } = state.editorContext;
22
+ return (_jsxs(_Fragment, { children: [!canWrite && _jsx(Banner, { variant: "info", children: t("template-resolver.editor.readOnly") }), _jsx(Field, { id: "create-entry-slug", label: t("template-resolver.editor.slugLabel"), required: true, ...(state.slugInvalid && { issues: [SLUG_INVALID_ISSUE] }), children: _jsx(Input, { kind: "text", id: "create-entry-slug", name: "create-entry-slug", value: state.slug, onChange: state.setSlug, disabled: state.disabled, required: true }) }), _jsx(Field, { id: "create-entry-title", label: t("template-resolver.editor.titleLabel"), required: true, children: _jsx(Input, { kind: "text", id: "create-entry-title", name: "create-entry-title", value: state.title, onChange: state.setTitle, disabled: state.disabled, required: true }) }), _jsx(ContentField, { editorContext: state.editorContext, value: state.content, onChange: state.setContent, disabled: state.disabled }), state.error !== null && _jsx(Banner, { variant: "error", children: state.error })] }));
23
+ }
24
+ export function CollectionEntryCreateForm({ target, tenantId, }) {
25
+ const { Button, PageHeader, Section } = usePrimitives();
26
+ const t = useTranslation();
27
+ const state = useCollectionEntryCreate(target, tenantId);
28
+ return (_jsxs(_Fragment, { children: [PageHeader !== undefined && _jsx(PageHeader, { title: state.headerTitle }), _jsx(FormScreenLayout, { onSubmit: () => {
29
+ void state.create();
30
+ }, testId: "collection-entry-create", actions: state.editorContext.canWrite ? (_jsx(Button, { type: "submit", loading: state.submitting, disabled: state.createDisabled, children: t("template-resolver.editor.create") })) : undefined, children: _jsx(Section, { children: _jsx(CreateFields, { state: state }) }) })] }));
31
+ }
@@ -0,0 +1,4 @@
1
+ import type { FeatureSchema } from "@cosmicdrift/kumiko-renderer";
2
+ export type ContentCollectionSchema = NonNullable<FeatureSchema["contentCollections"]>[number];
3
+ export declare function findCollection(features: readonly FeatureSchema[], collectionId?: string): ContentCollectionSchema | undefined;
4
+ export declare function canWriteContent(collection: Pick<ContentCollectionSchema, "access"> | undefined, roles: readonly string[] | undefined): boolean;
@@ -0,0 +1,24 @@
1
+ import { isUiAccessGranted } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import { DEFAULT_COLLECTION_ACCESS } from "./constants.js";
3
+ // A node without a collectionId (the hand-wired text-block tree) has no
4
+ // collection to look up — contentFormat/variableSchema then fall back to
5
+ // their own callers' defaults.
6
+ export function findCollection(features, collectionId) {
7
+ if (collectionId === undefined)
8
+ return undefined;
9
+ for (const feature of features) {
10
+ const match = feature.contentCollections?.find((c) => c.id === collectionId);
11
+ if (match !== undefined)
12
+ return match;
13
+ }
14
+ return undefined;
15
+ }
16
+ // Who may save follows the server: a collection's set handler carries the
17
+ // collection's own access rule (one rule for list, item and set), the public
18
+ // text-block pair stays TenantAdmin/SystemAdmin. Other users see the form read-only.
19
+ export function canWriteContent(collection, roles) {
20
+ if (collection !== undefined) {
21
+ return isUiAccessGranted(collection.access ?? DEFAULT_COLLECTION_ACCESS, roles);
22
+ }
23
+ return roles?.includes("TenantAdmin") === true || roles?.includes("SystemAdmin") === true;
24
+ }
@@ -11,6 +11,8 @@ export type UpsertKind = (typeof UPSERT_KINDS)[number];
11
11
  export declare const TEXT_BLOCK_KIND: "text-block";
12
12
  export declare const COLLECTION_REFUSED_KINDS: readonly ["mail", "mail-layout", "pdf-layout"];
13
13
  export declare function isCollectionRefusedKind(kind: unknown): kind is (typeof COLLECTION_REFUSED_KINDS)[number];
14
+ export declare const SLUG_PATTERN: RegExp;
15
+ export declare const SLUG_MAX_LENGTH = 80;
14
16
  export declare const CONTENT_FORMATS: readonly ["markdown", "mjml", "html", "plain"];
15
17
  export type ContentFormat = (typeof CONTENT_FORMATS)[number];
16
18
  export declare const TEMPLATE_SCOPES: readonly ["system", "tenant"];
@@ -47,6 +47,8 @@ export const COLLECTION_REFUSED_KINDS = [
47
47
  export function isCollectionRefusedKind(kind) {
48
48
  return isOneOf(COLLECTION_REFUSED_KINDS, kind);
49
49
  }
50
+ export const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
51
+ export const SLUG_MAX_LENGTH = 80;
50
52
  export const CONTENT_FORMATS = ["markdown", "mjml", "html", "plain"];
51
53
  export const TEMPLATE_SCOPES = ["system", "tenant"];
52
54
  export const TEMPLATE_STATUSES = ["draft", "active", "archived"];
@@ -0,0 +1,9 @@
1
+ import { type ReactNode } from "react";
2
+ import type { CollectionEditorContext } from "./use-collection-editor-context.ts";
3
+ export type ContentFieldProps = {
4
+ readonly editorContext: CollectionEditorContext;
5
+ readonly value: string;
6
+ readonly onChange: (value: string) => void;
7
+ readonly disabled: boolean;
8
+ };
9
+ export declare function ContentField({ editorContext, value, onChange, disabled, }: ContentFieldProps): ReactNode;
@@ -0,0 +1,14 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { ContentPreview, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
3
+ import { useId, useState } from "react";
4
+ import { DocumentBlocksContext } from "./document-editor.js";
5
+ // The content input shared by the edit and create forms: the collection's editor
6
+ // plus a toggle to its preview with the example variables substituted.
7
+ export function ContentField({ editorContext, value, onChange, disabled, }) {
8
+ const { Field, Button } = usePrimitives();
9
+ const t = useTranslation();
10
+ const contentEditorId = useId();
11
+ const [previewing, setPreviewing] = useState(false);
12
+ const { ContentEditor, contentFormat, documentBlocks, variableExamples, variables } = editorContext;
13
+ return (_jsx(Field, { id: contentEditorId, label: t("template-resolver.editor.contentLabel"), labelAppendix: _jsx(Button, { type: "button", variant: "secondary", size: "sm", onClick: () => setPreviewing((p) => !p), children: previewing ? t("kumiko.contentEditor.editMode") : t("kumiko.contentEditor.preview") }), children: previewing ? (_jsx(ContentPreview, { content: value, variables: variableExamples, contentFormat: contentFormat })) : (_jsx(DocumentBlocksContext.Provider, { value: documentBlocks, children: _jsx(ContentEditor, { id: contentEditorId, value: value, onChange: onChange, variables: variables, readOnly: disabled }) })) }));
14
+ }
@@ -0,0 +1,14 @@
1
+ import type { TreeChildrenSubscribe, TreeNode } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { ContentCollectionSchema } from "./collection-lookup.ts";
3
+ export type BlockSummary = {
4
+ readonly slug: string;
5
+ readonly locale: string;
6
+ readonly title: string | null;
7
+ readonly content: string | null;
8
+ readonly folder: string | null;
9
+ readonly modifiedAt: string | null;
10
+ };
11
+ export declare function groupBlocksByFolder(blocks: readonly BlockSummary[], tenantIdOverride?: string, collectionId?: string): readonly TreeNode[];
12
+ export declare const TEXT_BLOCK_TREE_KEY = "";
13
+ export declare function refreshTree(treeKey: string): void;
14
+ export declare function makeTreeProvider(tenantIdOverride?: string, collection?: Pick<ContentCollectionSchema, "id" | "ownership">): TreeChildrenSubscribe;