@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.
- package/dist/inbound-provider-imap/feature.js +2 -91
- package/dist/inbound-provider-imap/watch-mailbox.d.ts +5 -0
- package/dist/inbound-provider-imap/watch-mailbox.js +92 -0
- package/dist/managed-pages/constants.d.ts +1 -0
- package/dist/managed-pages/constants.js +1 -0
- package/dist/managed-pages/feature.js +5 -3
- package/dist/managed-pages/handlers/by-slug.query.d.ts +1 -0
- package/dist/managed-pages/handlers/by-slug.query.js +2 -0
- package/dist/managed-pages/handlers/page-body-guard.d.ts +2 -0
- package/dist/managed-pages/handlers/page-body-guard.js +46 -0
- package/dist/managed-pages/handlers/page-body-issues.d.ts +10 -0
- package/dist/managed-pages/handlers/page-body-issues.js +21 -0
- package/dist/managed-pages/handlers/page-crud.js +5 -4
- package/dist/managed-pages/handlers/set.write.d.ts +4 -0
- package/dist/managed-pages/handlers/set.write.js +16 -4
- package/dist/managed-pages/render-page-body.d.ts +10 -0
- package/dist/managed-pages/render-page-body.js +19 -0
- package/dist/managed-pages/screens/page-screens.js +6 -2
- package/dist/managed-pages/seeding.d.ts +2 -0
- package/dist/managed-pages/seeding.js +12 -2
- package/dist/managed-pages/table.d.ts +46 -0
- package/dist/managed-pages/table.js +5 -3
- package/dist/managed-pages/web/client-plugin.js +6 -4
- package/dist/managed-pages/web/constants.d.ts +5 -0
- package/dist/managed-pages/web/constants.js +9 -0
- package/dist/managed-pages/web/i18n.js +12 -0
- package/dist/managed-pages/web/lossy-markdown.d.ts +3 -0
- package/dist/managed-pages/web/lossy-markdown.js +59 -0
- package/dist/managed-pages/web/markdown-to-document.d.ts +1 -0
- package/dist/managed-pages/web/markdown-to-document.js +26 -0
- package/dist/managed-pages/web/page-body-section.d.ts +3 -0
- package/dist/managed-pages/web/page-body-section.js +41 -0
- package/dist/template-resolver/document-sanitizer.d.ts +6 -2
- package/dist/template-resolver/document-sanitizer.js +5 -4
- package/dist/template-resolver/feature.js +12 -1
- package/dist/template-resolver/handlers/collection-set.write.d.ts +1 -6
- package/dist/template-resolver/handlers/collection-set.write.js +21 -6
- package/dist/template-resolver/handlers/collection-shared.d.ts +2 -0
- package/dist/template-resolver/handlers/collection-shared.js +5 -0
- package/dist/template-resolver/handlers/set.write.js +1 -0
- package/dist/template-resolver/handlers/shared.js +3 -3
- package/dist/template-resolver/web/client-plugin.d.ts +0 -15
- package/dist/template-resolver/web/client-plugin.js +8 -363
- package/dist/template-resolver/web/collection-entry-create-form.d.ts +11 -0
- package/dist/template-resolver/web/collection-entry-create-form.js +31 -0
- package/dist/template-resolver/web/collection-lookup.d.ts +4 -0
- package/dist/template-resolver/web/collection-lookup.js +24 -0
- package/dist/template-resolver/web/constants.d.ts +2 -0
- package/dist/template-resolver/web/constants.js +2 -0
- package/dist/template-resolver/web/content-field.d.ts +9 -0
- package/dist/template-resolver/web/content-field.js +14 -0
- package/dist/template-resolver/web/content-tree-provider.d.ts +14 -0
- package/dist/template-resolver/web/content-tree-provider.js +197 -0
- package/dist/template-resolver/web/document-reference-name.d.ts +1 -0
- package/dist/template-resolver/web/document-reference-name.js +9 -0
- package/dist/template-resolver/web/document-schema.d.ts +2 -1
- package/dist/template-resolver/web/document-schema.js +2 -8
- package/dist/template-resolver/web/i18n.js +6 -0
- package/dist/template-resolver/web/legacy-variable-text.d.ts +1 -0
- package/dist/template-resolver/web/legacy-variable-text.js +23 -0
- package/dist/template-resolver/web/tenant-override.d.ts +3 -0
- package/dist/template-resolver/web/tenant-override.js +4 -0
- package/dist/template-resolver/web/text-block-editor.d.ts +6 -0
- package/dist/template-resolver/web/text-block-editor.js +22 -0
- package/dist/template-resolver/web/use-collection-editor-context.d.ts +14 -0
- package/dist/template-resolver/web/use-collection-editor-context.js +21 -0
- package/dist/template-resolver/web/use-collection-entry-create.d.ts +18 -0
- package/dist/template-resolver/web/use-collection-entry-create.js +78 -0
- package/dist/template-resolver/web/use-content-write.d.ts +17 -0
- package/dist/template-resolver/web/use-content-write.js +54 -0
- package/dist/template-resolver/web/use-text-block-editor.d.ts +19 -0
- package/dist/template-resolver/web/use-text-block-editor.js +85 -0
- package/dist/tier-engine/feature.js +9 -1
- package/package.json +11 -11
- package/src/inbound-provider-imap/changes.json +6 -0
- package/src/managed-pages/changes.json +7 -0
- package/src/template-resolver/changes.json +8 -0
- package/src/tier-engine/changes.json +6 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Content tree behind the text-block and collection navs: loads the blocks from the
|
|
2
|
+
// by-tenant (or a collection's list) query, groups them by their `folder` field and
|
|
3
|
+
// emits them as TreeNode[].
|
|
4
|
+
//
|
|
5
|
+
// **Folder grouping**: block.folder !== null puts the node under a container
|
|
6
|
+
// node labelled with the folder; folder === null keeps it at the root.
|
|
7
|
+
//
|
|
8
|
+
// **State**: TreeNode.state = "filled" when content is set, otherwise "stub"
|
|
9
|
+
// (a hint that the slug exists but is empty).
|
|
10
|
+
import { CSRF_HEADER_NAME, readCsrfToken } from "@cosmicdrift/kumiko-renderer/live";
|
|
11
|
+
import { collectionQueryName, TemplateResolverQueries } from "./qualified-names.js";
|
|
12
|
+
import { TENANT_OVERRIDE_DENIED_REASON } from "./tenant-override.js";
|
|
13
|
+
function newFolderNode() {
|
|
14
|
+
return { leaves: [], subFolders: new Map() };
|
|
15
|
+
}
|
|
16
|
+
function attachBlock(root, block, tenantIdOverride, collectionId) {
|
|
17
|
+
const leaf = {
|
|
18
|
+
label: block.title || block.slug,
|
|
19
|
+
icon: "file",
|
|
20
|
+
target: {
|
|
21
|
+
featureId: "template-resolver",
|
|
22
|
+
action: "edit",
|
|
23
|
+
// tenantIdOverride and collectionId travel with the node → the editor
|
|
24
|
+
// reads and writes through the same tenant and the same collection the
|
|
25
|
+
// tree was loaded from. Omitted → session tenant, public text-blocks.
|
|
26
|
+
args: {
|
|
27
|
+
slug: block.slug,
|
|
28
|
+
locale: block.locale,
|
|
29
|
+
...(tenantIdOverride !== undefined && { tenantIdOverride }),
|
|
30
|
+
...(collectionId !== undefined && { collectionId }),
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
state: block.content ? "filled" : "stub",
|
|
34
|
+
};
|
|
35
|
+
let cur = root;
|
|
36
|
+
for (const segment of block.folder === null ? [] : block.folder.split("/")) {
|
|
37
|
+
let next = cur.subFolders.get(segment);
|
|
38
|
+
if (next === undefined) {
|
|
39
|
+
next = newFolderNode();
|
|
40
|
+
cur.subFolders.set(segment, next);
|
|
41
|
+
}
|
|
42
|
+
cur = next;
|
|
43
|
+
}
|
|
44
|
+
cur.leaves.push(leaf);
|
|
45
|
+
}
|
|
46
|
+
function renderFolderNode(node) {
|
|
47
|
+
// Leaves first (root items), then folders alphabetically.
|
|
48
|
+
const result = [...node.leaves];
|
|
49
|
+
for (const folderName of [...node.subFolders.keys()].sort()) {
|
|
50
|
+
const subNode = node.subFolders.get(folderName);
|
|
51
|
+
if (subNode === undefined)
|
|
52
|
+
continue;
|
|
53
|
+
result.push({
|
|
54
|
+
label: folderName,
|
|
55
|
+
icon: "folder",
|
|
56
|
+
state: "filled",
|
|
57
|
+
children: renderFolderNode(subNode),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
export function groupBlocksByFolder(blocks, tenantIdOverride, collectionId) {
|
|
63
|
+
const root = newFolderNode();
|
|
64
|
+
for (const block of blocks) {
|
|
65
|
+
attachBlock(root, block, tenantIdOverride, collectionId);
|
|
66
|
+
}
|
|
67
|
+
const rendered = renderFolderNode(root);
|
|
68
|
+
if (rendered.length === 0)
|
|
69
|
+
return [];
|
|
70
|
+
return [
|
|
71
|
+
{
|
|
72
|
+
label: "Content",
|
|
73
|
+
icon: "folder",
|
|
74
|
+
state: "filled",
|
|
75
|
+
children: rendered,
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
// A save in the editor re-fires the tree of the collection it wrote to. The
|
|
80
|
+
// SSE refresh does not cover every collection: per-user entries carry no
|
|
81
|
+
// entity signal, because naming their ids to every collection reader would
|
|
82
|
+
// reveal when colleagues change their own entries.
|
|
83
|
+
export const TEXT_BLOCK_TREE_KEY = "";
|
|
84
|
+
const treeRefreshListeners = new Map();
|
|
85
|
+
function onTreeRefresh(treeKey, listener) {
|
|
86
|
+
const listeners = treeRefreshListeners.get(treeKey) ?? new Set();
|
|
87
|
+
listeners.add(listener);
|
|
88
|
+
treeRefreshListeners.set(treeKey, listeners);
|
|
89
|
+
return () => listeners.delete(listener);
|
|
90
|
+
}
|
|
91
|
+
export function refreshTree(treeKey) {
|
|
92
|
+
for (const listener of treeRefreshListeners.get(treeKey) ?? [])
|
|
93
|
+
listener();
|
|
94
|
+
}
|
|
95
|
+
async function postBlockList(queryType, tenantIdOverride) {
|
|
96
|
+
// CSRF header is mandatory on authenticated requests (auth-middleware
|
|
97
|
+
// double-submit pattern). Anonymous/pre-login has no token → header
|
|
98
|
+
// omitted → the server takes the anonymous path.
|
|
99
|
+
const headers = { "content-type": "application/json" };
|
|
100
|
+
const csrf = readCsrfToken();
|
|
101
|
+
if (csrf !== undefined)
|
|
102
|
+
headers[CSRF_HEADER_NAME] = csrf;
|
|
103
|
+
// kumiko-lint-ignore no-raw-hooks not yet converted to the framework hook set
|
|
104
|
+
const response = await fetch("/api/query", {
|
|
105
|
+
method: "POST",
|
|
106
|
+
headers,
|
|
107
|
+
body: JSON.stringify({
|
|
108
|
+
type: queryType,
|
|
109
|
+
payload: tenantIdOverride !== undefined ? { tenantIdOverride } : {},
|
|
110
|
+
}),
|
|
111
|
+
});
|
|
112
|
+
if (!response.ok) {
|
|
113
|
+
return {
|
|
114
|
+
ok: false,
|
|
115
|
+
status: response.status,
|
|
116
|
+
body: await response.text().catch(() => response.statusText),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const data = await response.json();
|
|
120
|
+
return { ok: true, blocks: data.data.blocks };
|
|
121
|
+
}
|
|
122
|
+
// The dispatcher accepts tenantIdOverride from a SystemAdmin only, and the
|
|
123
|
+
// provider cannot see the session roles. Everyone else gets this exact denial
|
|
124
|
+
// and reads their session tenant instead, which is the only tenant they may
|
|
125
|
+
// edit anyway.
|
|
126
|
+
function isTenantOverrideDenied(result) {
|
|
127
|
+
return !result.ok && result.status === 403 && result.body.includes(TENANT_OVERRIDE_DENIED_REASON);
|
|
128
|
+
}
|
|
129
|
+
async function loadBlocks(queryType, tenantIdOverride) {
|
|
130
|
+
let result = await postBlockList(queryType, tenantIdOverride);
|
|
131
|
+
let effectiveOverride = tenantIdOverride;
|
|
132
|
+
if (tenantIdOverride !== undefined && isTenantOverrideDenied(result)) {
|
|
133
|
+
result = await postBlockList(queryType, undefined);
|
|
134
|
+
effectiveOverride = undefined;
|
|
135
|
+
}
|
|
136
|
+
if (!result.ok) {
|
|
137
|
+
// 403 (CSRF/auth) or 5xx — explicit error instead of silent empty.
|
|
138
|
+
throw new Error(`text-block load failed: ${result.status} ${result.body}`);
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
blocks: result.blocks,
|
|
142
|
+
...(effectiveOverride !== undefined && { tenantIdOverride: effectiveOverride }),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
// tenantIdOverride (SystemAdmin-only) lets an app point the content tree at a
|
|
146
|
+
// tenant other than the session's — publicstatus seeds marketing/legal blocks
|
|
147
|
+
// on SYSTEM_TENANT_ID, so its SystemAdmin must read that tenant, not their own.
|
|
148
|
+
// Without a collection: the anonymous-capable by-tenant query, so a public
|
|
149
|
+
// page keeps its content sidebar without a session. With one: that
|
|
150
|
+
// collection's own list handler, which carries the collection's access rule.
|
|
151
|
+
export function makeTreeProvider(tenantIdOverride, collection) {
|
|
152
|
+
const collectionId = collection?.id;
|
|
153
|
+
const queryType = collectionId === undefined
|
|
154
|
+
? TemplateResolverQueries.byTenant
|
|
155
|
+
: collectionQueryName(collectionId, "list");
|
|
156
|
+
// A per-user entry belongs to its author in the session tenant; pointing the
|
|
157
|
+
// tree at another tenant would only hide the author's own entries.
|
|
158
|
+
const requestedOverride = collection?.ownership === "user" ? undefined : tenantIdOverride;
|
|
159
|
+
return () => (emit, emitError) => {
|
|
160
|
+
let active = true;
|
|
161
|
+
// Each denial logs a client-fault warning on the server, so a reload after a
|
|
162
|
+
// save does not ask again once this subscription learned the answer.
|
|
163
|
+
let override = requestedOverride;
|
|
164
|
+
const load = () => {
|
|
165
|
+
loadBlocks(queryType, override)
|
|
166
|
+
.then(({ blocks, tenantIdOverride: effectiveOverride }) => {
|
|
167
|
+
// skip: subscription was cancelled while the request was in flight
|
|
168
|
+
if (!active)
|
|
169
|
+
return;
|
|
170
|
+
override = effectiveOverride;
|
|
171
|
+
// The app-side r.nav node IS the "Content" container → the provider's
|
|
172
|
+
// children are the folders/leaves below it, not the wrapper.
|
|
173
|
+
const content = groupBlocksByFolder(blocks, effectiveOverride, collectionId)[0];
|
|
174
|
+
emit(content !== undefined && Array.isArray(content.children) ? content.children : []);
|
|
175
|
+
})
|
|
176
|
+
.catch((e) => {
|
|
177
|
+
// skip: subscription was cancelled while the request was in flight
|
|
178
|
+
if (!active)
|
|
179
|
+
return;
|
|
180
|
+
// Explicit error signal via emitError; ProviderBranch shows a banner
|
|
181
|
+
// plus retry. Falls back to emit([]) for consumers without emitError.
|
|
182
|
+
if (emitError) {
|
|
183
|
+
emitError(e instanceof Error ? e : new Error(String(e)));
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
emit([]);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
};
|
|
190
|
+
load();
|
|
191
|
+
const unsubscribe = onTreeRefresh(collectionId ?? TEXT_BLOCK_TREE_KEY, load);
|
|
192
|
+
return () => {
|
|
193
|
+
active = false;
|
|
194
|
+
unsubscribe();
|
|
195
|
+
};
|
|
196
|
+
};
|
|
197
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function isDocumentReferenceName(value: string): boolean;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
const REFERENCE_NAME = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
|
|
2
|
+
const FORBIDDEN_REFERENCE_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
|
|
3
|
+
// Segments that hit the prototype chain are refused: the renderer later walks
|
|
4
|
+
// the dotted path into a data object. Kept apart from document-schema so client code
|
|
5
|
+
// can validate a name without loading tiptap.
|
|
6
|
+
export function isDocumentReferenceName(value) {
|
|
7
|
+
return (REFERENCE_NAME.test(value) &&
|
|
8
|
+
!value.split(".").some((segment) => FORBIDDEN_REFERENCE_SEGMENTS.has(segment)));
|
|
9
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Extension, type Extensions, Node } from "@tiptap/core";
|
|
2
|
+
import { isDocumentReferenceName } from "./document-reference-name.ts";
|
|
3
|
+
export { isDocumentReferenceName };
|
|
2
4
|
export declare function isDocumentPlaceholderHref(href: string): boolean;
|
|
3
5
|
export declare function isAllowedDocumentLinkHref(href: string): boolean;
|
|
4
|
-
export declare function isDocumentReferenceName(value: string): boolean;
|
|
5
6
|
export declare function isDocumentResourceKey(value: string): boolean;
|
|
6
7
|
export declare const DOCUMENT_NODE_NAMES: readonly ["variable", "resource", "button", "divider", "pageBreak", "steps", "images"];
|
|
7
8
|
export declare const DocumentVariable: Node<any, any>;
|
|
@@ -8,17 +8,17 @@ import { Link } from "@tiptap/extension-link";
|
|
|
8
8
|
import { Table, TableCell, TableHeader, TableRow } from "@tiptap/extension-table";
|
|
9
9
|
import StarterKit from "@tiptap/starter-kit";
|
|
10
10
|
import { createDocumentImagesNode } from "./document-images.js";
|
|
11
|
+
import { isDocumentReferenceName } from "./document-reference-name.js";
|
|
11
12
|
import { createDocumentStepsNode } from "./document-steps.js";
|
|
12
13
|
const PLACEHOLDER_HREF = /^\{\{(.+)\}\}$/;
|
|
13
|
-
const REFERENCE_NAME = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
|
|
14
14
|
const RESOURCE_KEY = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
|
15
15
|
const ALLOWED_LINK_SCHEMES = ["https://", "http://", "mailto:", "tel:"];
|
|
16
|
+
export { isDocumentReferenceName };
|
|
16
17
|
export function isDocumentPlaceholderHref(href) {
|
|
17
18
|
const name = PLACEHOLDER_HREF.exec(href)?.[1];
|
|
18
19
|
return name !== undefined && isDocumentReferenceName(name);
|
|
19
20
|
}
|
|
20
21
|
const UNSAFE_URL_CHARACTERS = new Set(["<", ">", '"']);
|
|
21
|
-
const FORBIDDEN_REFERENCE_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
|
|
22
22
|
function hasUnsafeUrlCharacter(value) {
|
|
23
23
|
return Array.from(value).some((character) => {
|
|
24
24
|
const code = character.charCodeAt(0);
|
|
@@ -49,12 +49,6 @@ export function isAllowedDocumentLinkHref(href) {
|
|
|
49
49
|
return false;
|
|
50
50
|
return hasAllowedLinkScheme(href) || isDocumentPlaceholderHref(href);
|
|
51
51
|
}
|
|
52
|
-
// Segments that hit the prototype chain are refused: the renderer later walks
|
|
53
|
-
// the dotted path into a data object.
|
|
54
|
-
export function isDocumentReferenceName(value) {
|
|
55
|
-
return (REFERENCE_NAME.test(value) &&
|
|
56
|
-
!value.split(".").some((segment) => FORBIDDEN_REFERENCE_SEGMENTS.has(segment)));
|
|
57
|
-
}
|
|
58
52
|
export function isDocumentResourceKey(value) {
|
|
59
53
|
return RESOURCE_KEY.test(value);
|
|
60
54
|
}
|
|
@@ -134,6 +134,12 @@ export const defaultTranslations = {
|
|
|
134
134
|
"template-resolver.editor.networkError": "Network error while saving.",
|
|
135
135
|
"template-resolver.editor.loading": "Loading current version…",
|
|
136
136
|
"template-resolver.editor.loadFailed": "Could not load block",
|
|
137
|
+
"template-resolver.editor.create": "Create",
|
|
138
|
+
"template-resolver.editor.newEntry": "New entry",
|
|
139
|
+
"template-resolver.editor.newEntryTitle": "New {label}",
|
|
140
|
+
"template-resolver.editor.slugLabel": "Slug",
|
|
141
|
+
"template-resolver.editor.slugInvalid": "Use lowercase letters, digits and dashes only; start with a letter or digit.",
|
|
142
|
+
"templateResolver.errors.collection.slugExists": "An entry with the slug “{detail}” already exists. Pick another one.",
|
|
137
143
|
"template-resolver.editor.readOnly": "Read-only. Your role does not allow changes here.",
|
|
138
144
|
},
|
|
139
145
|
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function convertLegacyVariableText(html: string): string;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { escapeHtmlAttr } from "@cosmicdrift/kumiko-renderer/headless";
|
|
2
|
+
import { isDocumentReferenceName } from "./document-reference-name.js";
|
|
3
|
+
// Matches tags with quoted attribute values, so a `>` inside an attribute does not end the tag.
|
|
4
|
+
// The unquoted branch excludes `<` so a scan stops at the next tag start (no quadratic backtracking).
|
|
5
|
+
const HTML_TAG = /(<(?:"[^"]*"|'[^']*'|[^"'<>])*>)/;
|
|
6
|
+
// Triple braces are raw output and `{{else}}`/`{{this}}` belong to blocks; neither is a variable.
|
|
7
|
+
const TEXT_VARIABLE = /(?<!\{)\{\{\s*([^{}\s]+)\s*\}\}(?!\})/g;
|
|
8
|
+
const BLOCK_KEYWORDS = new Set(["else", "this"]);
|
|
9
|
+
function isLegacyVariableName(name) {
|
|
10
|
+
return isDocumentReferenceName(name) && !BLOCK_KEYWORDS.has(name);
|
|
11
|
+
}
|
|
12
|
+
// Content saved by the old editors holds `{{name}}` as plain text; the document editor
|
|
13
|
+
// keeps variables as data-variable nodes and would show it as literal text.
|
|
14
|
+
export function convertLegacyVariableText(html) {
|
|
15
|
+
return html
|
|
16
|
+
.split(HTML_TAG)
|
|
17
|
+
.map((part, index) => index % 2 === 1
|
|
18
|
+
? part
|
|
19
|
+
: part.replace(TEXT_VARIABLE, (match, name) => isLegacyVariableName(name)
|
|
20
|
+
? `<span data-variable="${escapeHtmlAttr(name)}"></span>`
|
|
21
|
+
: match))
|
|
22
|
+
.join("");
|
|
23
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
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 { useTextBlockEditor } from "./use-text-block-editor.js";
|
|
5
|
+
function LoadBanners({ state }) {
|
|
6
|
+
const { Banner } = usePrimitives();
|
|
7
|
+
const t = useTranslation();
|
|
8
|
+
return (_jsxs(_Fragment, { children: [state.loading && _jsx(Banner, { variant: "loading", children: t("template-resolver.editor.loading") }), state.loadError !== null && (_jsxs(Banner, { variant: "error", children: [t("template-resolver.editor.loadFailed"), ": ", state.loadError.code] })), !state.canWrite && !state.loading && (_jsx(Banner, { variant: "info", children: t("template-resolver.editor.readOnly") }))] }));
|
|
9
|
+
}
|
|
10
|
+
function SaveBanners({ state }) {
|
|
11
|
+
const { Banner } = usePrimitives();
|
|
12
|
+
return (_jsxs(_Fragment, { children: [state.saveError !== null && _jsx(Banner, { variant: "error", children: state.saveError }), state.savedMsg !== null && _jsx(Banner, { variant: "info", children: state.savedMsg })] }));
|
|
13
|
+
}
|
|
14
|
+
export function TextBlockEditor({ target, }) {
|
|
15
|
+
const { Button, Field, Input, PageHeader, Section } = usePrimitives();
|
|
16
|
+
const t = useTranslation();
|
|
17
|
+
const state = useTextBlockEditor(target);
|
|
18
|
+
const headerTitle = state.title || state.slug || undefined;
|
|
19
|
+
return (_jsxs(_Fragment, { children: [PageHeader !== undefined && headerTitle !== undefined && _jsx(PageHeader, { title: headerTitle }), _jsx(FormScreenLayout, { onSubmit: () => {
|
|
20
|
+
void state.save();
|
|
21
|
+
}, testId: "text-block-editor", description: state.locale !== "" ? `${state.slug} · (${state.locale})` : state.slug, actions: state.canWrite ? (_jsx(Button, { type: "submit", loading: state.submitting, disabled: state.disabled, children: t(state.saveLabelKey) })) : undefined, children: _jsxs(Section, { children: [_jsx(LoadBanners, { state: state }), _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: state.title, onChange: state.setTitle, disabled: state.disabled, required: true }) }), _jsx(ContentField, { editorContext: state.editorContext, value: state.content, onChange: state.setContent, disabled: state.disabled }), _jsx(SaveBanners, { state: state })] }) })] }));
|
|
22
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type ContentEditorComponent } from "@cosmicdrift/kumiko-renderer";
|
|
2
|
+
import { type ContentCollectionSchema } from "./collection-lookup.ts";
|
|
3
|
+
import type { DocumentToolbarBlock } from "./document-blocks.ts";
|
|
4
|
+
export type CollectionEditorContext = {
|
|
5
|
+
readonly collection: ContentCollectionSchema | undefined;
|
|
6
|
+
readonly contentFormat: ContentCollectionSchema["contentFormat"];
|
|
7
|
+
readonly ContentEditor: ContentEditorComponent;
|
|
8
|
+
readonly documentBlocks: readonly DocumentToolbarBlock[];
|
|
9
|
+
/** The collection's fixed variable names with the example value the preview substitutes. */
|
|
10
|
+
readonly variableExamples: Readonly<Record<string, string>>;
|
|
11
|
+
readonly variables: readonly string[];
|
|
12
|
+
readonly canWrite: boolean;
|
|
13
|
+
};
|
|
14
|
+
export declare function useCollectionEditorContext(collectionId?: string): CollectionEditorContext;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { useShellUser } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/web";
|
|
2
|
+
import { useAppFeatures, useContentEditor, } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
import { canWriteContent, findCollection, } from "./collection-lookup.js";
|
|
4
|
+
import { documentBlocksForKind } from "./document-toolbar-blocks.js";
|
|
5
|
+
// No collection (the hand-wired text-block tree) → plain editor, no variables.
|
|
6
|
+
export function useCollectionEditorContext(collectionId) {
|
|
7
|
+
const features = useAppFeatures();
|
|
8
|
+
const user = useShellUser();
|
|
9
|
+
const collection = findCollection(features, collectionId);
|
|
10
|
+
const contentFormat = collection?.contentFormat;
|
|
11
|
+
const variableExamples = collection?.variableSchema ?? {};
|
|
12
|
+
return {
|
|
13
|
+
collection,
|
|
14
|
+
contentFormat,
|
|
15
|
+
ContentEditor: useContentEditor(contentFormat),
|
|
16
|
+
documentBlocks: documentBlocksForKind(collection?.kind),
|
|
17
|
+
variableExamples,
|
|
18
|
+
variables: Object.keys(variableExamples),
|
|
19
|
+
canWrite: canWriteContent(collection, user?.roles),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { TargetRef } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
export declare function useCollectionEntryCreate(target: TargetRef, tenantId: string | undefined): {
|
|
3
|
+
editorContext: import("./use-collection-editor-context.ts").CollectionEditorContext;
|
|
4
|
+
headerTitle: string;
|
|
5
|
+
slug: string;
|
|
6
|
+
setSlug: import("react").Dispatch<import("react").SetStateAction<string>>;
|
|
7
|
+
title: string;
|
|
8
|
+
setTitle: import("react").Dispatch<import("react").SetStateAction<string>>;
|
|
9
|
+
content: string;
|
|
10
|
+
setContent: import("react").Dispatch<import("react").SetStateAction<string>>;
|
|
11
|
+
slugValid: boolean;
|
|
12
|
+
createDisabled: boolean;
|
|
13
|
+
slugInvalid: boolean;
|
|
14
|
+
disabled: boolean;
|
|
15
|
+
submitting: boolean;
|
|
16
|
+
error: string | null;
|
|
17
|
+
create: () => Promise<void>;
|
|
18
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { useLocale, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
2
|
+
import { useDispatchTarget } from "@cosmicdrift/kumiko-renderer/web";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { SLUG_MAX_LENGTH, SLUG_PATTERN } from "./constants.js";
|
|
5
|
+
import { refreshTree } from "./content-tree-provider.js";
|
|
6
|
+
import { collectionHandlerName } from "./qualified-names.js";
|
|
7
|
+
import { useCollectionEditorContext } from "./use-collection-editor-context.js";
|
|
8
|
+
import { useContentWrite } from "./use-content-write.js";
|
|
9
|
+
function readCreateTargetArgs(target) {
|
|
10
|
+
// @cast-boundary visual-tree-args — same de-erasure as the edit resolver; a missing
|
|
11
|
+
// collectionId (hand-edited URL) leaves the form without a collection to write to.
|
|
12
|
+
const args = target.args;
|
|
13
|
+
const folder = args?.folder !== undefined && args.folder !== "" ? args.folder : null;
|
|
14
|
+
return { collectionId: args?.collectionId, folder };
|
|
15
|
+
}
|
|
16
|
+
export function useCollectionEntryCreate(target, tenantId) {
|
|
17
|
+
const { collectionId, folder } = readCreateTargetArgs(target);
|
|
18
|
+
const t = useTranslation();
|
|
19
|
+
const locale = useLocale().locale();
|
|
20
|
+
const dispatchTarget = useDispatchTarget();
|
|
21
|
+
const editorContext = useCollectionEditorContext(collectionId);
|
|
22
|
+
const { collection, canWrite } = editorContext;
|
|
23
|
+
const { submitting, error, writeScoped } = useContentWrite();
|
|
24
|
+
const [slug, setSlug] = useState("");
|
|
25
|
+
const [title, setTitle] = useState("");
|
|
26
|
+
const [content, setContent] = useState("");
|
|
27
|
+
const slugValid = slug.length <= SLUG_MAX_LENGTH && SLUG_PATTERN.test(slug);
|
|
28
|
+
const disabled = submitting || !canWrite || collectionId === undefined;
|
|
29
|
+
const headerTitle = t("template-resolver.editor.newEntryTitle", {
|
|
30
|
+
label: collection !== undefined ? t(collection.nav.label) : "",
|
|
31
|
+
});
|
|
32
|
+
const create = async () => {
|
|
33
|
+
// skip: nothing to write to, or the slug is invalid (the field already shows the issue)
|
|
34
|
+
if (collectionId === undefined || !slugValid)
|
|
35
|
+
return;
|
|
36
|
+
const requestedOverride = collection?.ownership === "user" ? undefined : tenantId;
|
|
37
|
+
const written = await writeScoped(collectionHandlerName(collectionId), {
|
|
38
|
+
slug,
|
|
39
|
+
locale,
|
|
40
|
+
title,
|
|
41
|
+
content: content.length > 0 ? content : null,
|
|
42
|
+
folder,
|
|
43
|
+
createOnly: true,
|
|
44
|
+
}, requestedOverride);
|
|
45
|
+
// skip: useContentWrite already set the failure text
|
|
46
|
+
if (written === null)
|
|
47
|
+
return;
|
|
48
|
+
const { response, tenantIdOverride } = written;
|
|
49
|
+
refreshTree(collectionId);
|
|
50
|
+
dispatchTarget({
|
|
51
|
+
featureId: "template-resolver",
|
|
52
|
+
action: "edit",
|
|
53
|
+
args: {
|
|
54
|
+
slug: response.slug,
|
|
55
|
+
locale: response.locale,
|
|
56
|
+
collectionId,
|
|
57
|
+
...(tenantIdOverride !== undefined && { tenantIdOverride }),
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
return {
|
|
62
|
+
editorContext,
|
|
63
|
+
headerTitle,
|
|
64
|
+
slug,
|
|
65
|
+
setSlug,
|
|
66
|
+
title,
|
|
67
|
+
setTitle,
|
|
68
|
+
content,
|
|
69
|
+
setContent,
|
|
70
|
+
slugValid,
|
|
71
|
+
createDisabled: disabled || !slugValid || title === "",
|
|
72
|
+
slugInvalid: slug !== "" && !slugValid,
|
|
73
|
+
disabled,
|
|
74
|
+
submitting,
|
|
75
|
+
error,
|
|
76
|
+
create,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type TenantScopedWrite = {
|
|
2
|
+
readonly response: ContentWriteResponse;
|
|
3
|
+
/** The override the write was accepted with; undefined after a denied override fell back to the session tenant. */
|
|
4
|
+
readonly tenantIdOverride: string | undefined;
|
|
5
|
+
};
|
|
6
|
+
export type ContentWriteResponse = {
|
|
7
|
+
readonly slug: string;
|
|
8
|
+
readonly locale: string;
|
|
9
|
+
readonly isNew: boolean;
|
|
10
|
+
};
|
|
11
|
+
export declare function useContentWrite(): {
|
|
12
|
+
submitting: boolean;
|
|
13
|
+
error: string | null;
|
|
14
|
+
clearError: () => void;
|
|
15
|
+
write: (handler: string, payload: Readonly<Record<string, unknown>>) => Promise<ContentWriteResponse | null>;
|
|
16
|
+
writeScoped: (handler: string, payload: Readonly<Record<string, unknown>>, requestedOverride: string | undefined) => Promise<TenantScopedWrite | null>;
|
|
17
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { dispatcherErrorText, useDispatcher, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
2
|
+
import { useCallback, useState } from "react";
|
|
3
|
+
import { isTenantOverrideDeniedError } from "./tenant-override.js";
|
|
4
|
+
// A validation failure names the offending field with its own message; prefer that over the generic one.
|
|
5
|
+
function writeErrorText(error, t) {
|
|
6
|
+
const issue = error.details?.fields?.[0];
|
|
7
|
+
if (issue !== undefined) {
|
|
8
|
+
const translated = t(issue.i18nKey, issue.params);
|
|
9
|
+
if (translated !== issue.i18nKey)
|
|
10
|
+
return translated;
|
|
11
|
+
}
|
|
12
|
+
return dispatcherErrorText(error, t) || t("template-resolver.editor.saveFailed");
|
|
13
|
+
}
|
|
14
|
+
// Dispatches a content set and keeps the submit state; `write` resolves to the response,
|
|
15
|
+
// or null after the failure text has been set in `error`.
|
|
16
|
+
export function useContentWrite() {
|
|
17
|
+
const dispatcher = useDispatcher();
|
|
18
|
+
const t = useTranslation();
|
|
19
|
+
const [submitting, setSubmitting] = useState(false);
|
|
20
|
+
const [error, setError] = useState(null);
|
|
21
|
+
const writeScoped = async (handler, payload, requestedOverride) => {
|
|
22
|
+
setSubmitting(true);
|
|
23
|
+
setError(null);
|
|
24
|
+
try {
|
|
25
|
+
let tenantIdOverride = requestedOverride;
|
|
26
|
+
let result = await dispatcher.write(handler, {
|
|
27
|
+
...payload,
|
|
28
|
+
...(tenantIdOverride !== undefined && { tenantIdOverride }),
|
|
29
|
+
});
|
|
30
|
+
if (!result.isSuccess &&
|
|
31
|
+
tenantIdOverride !== undefined &&
|
|
32
|
+
isTenantOverrideDeniedError(result.error)) {
|
|
33
|
+
tenantIdOverride = undefined;
|
|
34
|
+
result = await dispatcher.write(handler, payload);
|
|
35
|
+
}
|
|
36
|
+
if (result.isSuccess)
|
|
37
|
+
return { response: result.data, tenantIdOverride };
|
|
38
|
+
setError(writeErrorText(result.error, t));
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
catch (e) {
|
|
42
|
+
// Network blip / dispatcher throw — otherwise submitting stays true and
|
|
43
|
+
// the save button is locked forever with no feedback.
|
|
44
|
+
setError(e instanceof Error ? e.message : t("template-resolver.editor.networkError"));
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
setSubmitting(false);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const write = async (handler, payload) => (await writeScoped(handler, payload, undefined))?.response ?? null;
|
|
52
|
+
const clearError = useCallback(() => setError(null), []);
|
|
53
|
+
return { submitting, error, clearError, write, writeScoped };
|
|
54
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { TargetRef } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
export declare function useTextBlockEditor(target: TargetRef): {
|
|
3
|
+
slug: string;
|
|
4
|
+
locale: string;
|
|
5
|
+
editorContext: import("./use-collection-editor-context.ts").CollectionEditorContext;
|
|
6
|
+
canWrite: boolean;
|
|
7
|
+
loading: boolean;
|
|
8
|
+
loadError: import("@cosmicdrift/kumiko-renderer/headless").DispatcherError | null;
|
|
9
|
+
submitting: boolean;
|
|
10
|
+
saveError: string | null;
|
|
11
|
+
savedMsg: string | null;
|
|
12
|
+
disabled: boolean;
|
|
13
|
+
saveLabelKey: string;
|
|
14
|
+
title: string;
|
|
15
|
+
setTitle: import("react").Dispatch<import("react").SetStateAction<string>>;
|
|
16
|
+
content: string;
|
|
17
|
+
setContent: import("react").Dispatch<import("react").SetStateAction<string>>;
|
|
18
|
+
save: () => Promise<void>;
|
|
19
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { useQuery, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { refreshTree, TEXT_BLOCK_TREE_KEY } from "./content-tree-provider.js";
|
|
4
|
+
import { convertLegacyVariableText } from "./legacy-variable-text.js";
|
|
5
|
+
import { collectionHandlerName, collectionQueryName, TemplateResolverHandlers, TemplateResolverQueries, } from "./qualified-names.js";
|
|
6
|
+
import { useCollectionEditorContext } from "./use-collection-editor-context.js";
|
|
7
|
+
import { useContentWrite } from "./use-content-write.js";
|
|
8
|
+
export function useTextBlockEditor(target) {
|
|
9
|
+
// @cast-boundary visual-tree-args — TargetRef.args is erased to
|
|
10
|
+
// Record<string, unknown>; the resolver knows the action shape (see the
|
|
11
|
+
// treeActions.edit definition in feature.ts) and de-erases per action, the
|
|
12
|
+
// same way event payloads do. Optional chaining absorbs missing fields so a
|
|
13
|
+
// hand-edited URL cannot crash the editor.
|
|
14
|
+
const args = target.args;
|
|
15
|
+
const slug = args?.slug ?? "";
|
|
16
|
+
const locale = args?.locale ?? "";
|
|
17
|
+
const tenantIdOverride = args?.tenantIdOverride;
|
|
18
|
+
// Which collection the node came from. Absent → the public text-block pair,
|
|
19
|
+
// which is what the hand-wired content tree uses.
|
|
20
|
+
const collectionId = args?.collectionId;
|
|
21
|
+
const t = useTranslation();
|
|
22
|
+
const editorContext = useCollectionEditorContext(collectionId);
|
|
23
|
+
const { contentFormat, canWrite } = editorContext;
|
|
24
|
+
const { submitting, error: saveError, clearError, write } = useContentWrite();
|
|
25
|
+
// Same split as the tree provider: a node without a collection reads through
|
|
26
|
+
// the public by-slug, a collection node through that collection's handlers.
|
|
27
|
+
const readQuery = collectionId === undefined
|
|
28
|
+
? TemplateResolverQueries.bySlug
|
|
29
|
+
: collectionQueryName(collectionId, "item");
|
|
30
|
+
const writeHandler = collectionId === undefined ? TemplateResolverHandlers.set : collectionHandlerName(collectionId);
|
|
31
|
+
const { data: loaded, loading, error: loadError, } = useQuery(readQuery, { slug, locale, ...(tenantIdOverride !== undefined && { tenantIdOverride }) }, { enabled: slug !== "" && locale !== "" });
|
|
32
|
+
const [title, setTitle] = useState("");
|
|
33
|
+
const [content, setContent] = useState("");
|
|
34
|
+
const [savedMsg, setSavedMsg] = useState(null);
|
|
35
|
+
// Sync loaded data into the form state. loaded === null means the block does
|
|
36
|
+
// not exist yet — empty form for the create flow.
|
|
37
|
+
// kumiko-lint-ignore no-raw-hooks not yet converted to the framework hook set
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
// skip: keep the form untouched until the query settles
|
|
40
|
+
if (loading)
|
|
41
|
+
return;
|
|
42
|
+
const loadedContent = loaded?.content ?? "";
|
|
43
|
+
setTitle(loaded?.title ?? "");
|
|
44
|
+
setContent(contentFormat === "document" ? convertLegacyVariableText(loadedContent) : loadedContent);
|
|
45
|
+
clearError();
|
|
46
|
+
setSavedMsg(null);
|
|
47
|
+
}, [loading, loaded, contentFormat, clearError]);
|
|
48
|
+
const save = async () => {
|
|
49
|
+
setSavedMsg(null);
|
|
50
|
+
const response = await write(writeHandler, {
|
|
51
|
+
slug,
|
|
52
|
+
locale,
|
|
53
|
+
title,
|
|
54
|
+
content: content.length > 0 ? content : null,
|
|
55
|
+
folder: loaded?.folder ?? null,
|
|
56
|
+
// A collection derives its storage format on the server; the public set keeps what was saved.
|
|
57
|
+
...(collectionId === undefined &&
|
|
58
|
+
loaded?.contentFormat !== undefined && { contentFormat: loaded.contentFormat }),
|
|
59
|
+
...(tenantIdOverride !== undefined && { tenantIdOverride }),
|
|
60
|
+
});
|
|
61
|
+
// skip: useContentWrite already set the failure text
|
|
62
|
+
if (response === null)
|
|
63
|
+
return;
|
|
64
|
+
refreshTree(collectionId ?? TEXT_BLOCK_TREE_KEY);
|
|
65
|
+
setSavedMsg(response.isNew ? t("template-resolver.editor.created") : t("template-resolver.editor.saved"));
|
|
66
|
+
};
|
|
67
|
+
return {
|
|
68
|
+
slug,
|
|
69
|
+
locale,
|
|
70
|
+
editorContext,
|
|
71
|
+
canWrite,
|
|
72
|
+
loading,
|
|
73
|
+
loadError,
|
|
74
|
+
submitting,
|
|
75
|
+
saveError,
|
|
76
|
+
savedMsg,
|
|
77
|
+
disabled: submitting || loading || !canWrite,
|
|
78
|
+
saveLabelKey: submitting ? "template-resolver.editor.saving" : "template-resolver.editor.save",
|
|
79
|
+
title,
|
|
80
|
+
setTitle,
|
|
81
|
+
content,
|
|
82
|
+
setContent,
|
|
83
|
+
save,
|
|
84
|
+
};
|
|
85
|
+
}
|