@cosmicdrift/kumiko-samples 0.394.0 → 0.395.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +1 -1
- package/packages/bundled-features/src/inbound-provider-imap/changes.json +6 -0
- package/packages/bundled-features/src/inbound-provider-imap/feature.ts +3 -104
- package/packages/bundled-features/src/inbound-provider-imap/watch-mailbox.ts +109 -0
- package/packages/bundled-features/src/managed-pages/changes.json +7 -0
- package/packages/bundled-features/src/managed-pages/constants.ts +7 -0
- package/packages/bundled-features/src/managed-pages/feature.ts +6 -4
- package/packages/bundled-features/src/managed-pages/handlers/by-slug.query.ts +2 -0
- package/packages/bundled-features/src/managed-pages/handlers/page-body-guard.ts +61 -0
- package/packages/bundled-features/src/managed-pages/handlers/page-body-issues.ts +26 -0
- package/packages/bundled-features/src/managed-pages/handlers/page-crud.ts +17 -10
- package/packages/bundled-features/src/managed-pages/handlers/set.write.ts +15 -4
- package/packages/bundled-features/src/managed-pages/render-page-body.ts +28 -0
- package/packages/bundled-features/src/managed-pages/screens/page-screens.ts +6 -2
- package/packages/bundled-features/src/managed-pages/seeding.ts +13 -2
- package/packages/bundled-features/src/managed-pages/table.ts +6 -2
- package/packages/bundled-features/src/managed-pages/web/client-plugin.tsx +6 -4
- package/packages/bundled-features/src/managed-pages/web/constants.ts +14 -0
- package/packages/bundled-features/src/managed-pages/web/i18n.ts +13 -0
- package/packages/bundled-features/src/managed-pages/web/lossy-markdown.ts +71 -0
- package/packages/bundled-features/src/managed-pages/web/markdown-to-document.ts +27 -0
- package/packages/bundled-features/src/managed-pages/web/page-body-section.tsx +73 -0
- package/packages/bundled-features/src/template-resolver/README.md +2 -2
- package/packages/bundled-features/src/template-resolver/changes.json +8 -0
- package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +16 -16
- package/packages/bundled-features/src/template-resolver/feature.ts +14 -1
- package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +24 -6
- package/packages/bundled-features/src/template-resolver/handlers/collection-shared.ts +7 -0
- package/packages/bundled-features/src/template-resolver/handlers/set.write.ts +1 -0
- package/packages/bundled-features/src/template-resolver/handlers/shared.ts +9 -3
- package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +8 -576
- package/packages/bundled-features/src/template-resolver/web/collection-entry-create-form.tsx +105 -0
- package/packages/bundled-features/src/template-resolver/web/collection-lookup.ts +33 -0
- package/packages/bundled-features/src/template-resolver/web/constants.ts +3 -0
- package/packages/bundled-features/src/template-resolver/web/content-field.tsx +61 -0
- package/packages/bundled-features/src/template-resolver/web/content-tree-provider.ts +256 -0
- package/packages/bundled-features/src/template-resolver/web/document-reference-name.ts +12 -0
- package/packages/bundled-features/src/template-resolver/web/document-schema.ts +3 -11
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +8 -0
- package/packages/bundled-features/src/template-resolver/web/legacy-variable-text.ts +30 -0
- package/packages/bundled-features/src/template-resolver/web/tenant-override.ts +7 -0
- package/packages/bundled-features/src/template-resolver/web/text-block-editor.tsx +92 -0
- package/packages/bundled-features/src/template-resolver/web/use-collection-editor-context.ts +42 -0
- package/packages/bundled-features/src/template-resolver/web/use-collection-entry-create.ts +92 -0
- package/packages/bundled-features/src/template-resolver/web/use-content-write.ts +81 -0
- package/packages/bundled-features/src/template-resolver/web/use-text-block-editor.ts +127 -0
- package/packages/bundled-features/src/tier-engine/changes.json +6 -0
- package/packages/bundled-features/src/tier-engine/feature.ts +9 -1
- package/samples/apps/use-all-bundled/feature-manifest.json +2 -2
- package/samples/apps/use-all-bundled/src/app/client.tsx +6 -1
- package/samples/apps/use-all-bundled/src/app/seed.ts +13 -1
- package/samples/apps/use-all-bundled/src/run-config.ts +12 -6
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { useShellUser } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/web";
|
|
2
|
+
import {
|
|
3
|
+
type ContentEditorComponent,
|
|
4
|
+
useAppFeatures,
|
|
5
|
+
useContentEditor,
|
|
6
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
7
|
+
import {
|
|
8
|
+
type ContentCollectionSchema,
|
|
9
|
+
canWriteContent,
|
|
10
|
+
findCollection,
|
|
11
|
+
} from "./collection-lookup.ts";
|
|
12
|
+
import type { DocumentToolbarBlock } from "./document-blocks.ts";
|
|
13
|
+
import { documentBlocksForKind } from "./document-toolbar-blocks.ts";
|
|
14
|
+
|
|
15
|
+
export type CollectionEditorContext = {
|
|
16
|
+
readonly collection: ContentCollectionSchema | undefined;
|
|
17
|
+
readonly contentFormat: ContentCollectionSchema["contentFormat"];
|
|
18
|
+
readonly ContentEditor: ContentEditorComponent;
|
|
19
|
+
readonly documentBlocks: readonly DocumentToolbarBlock[];
|
|
20
|
+
/** The collection's fixed variable names with the example value the preview substitutes. */
|
|
21
|
+
readonly variableExamples: Readonly<Record<string, string>>;
|
|
22
|
+
readonly variables: readonly string[];
|
|
23
|
+
readonly canWrite: boolean;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// No collection (the hand-wired text-block tree) → plain editor, no variables.
|
|
27
|
+
export function useCollectionEditorContext(collectionId?: string): CollectionEditorContext {
|
|
28
|
+
const features = useAppFeatures();
|
|
29
|
+
const user = useShellUser();
|
|
30
|
+
const collection = findCollection(features, collectionId);
|
|
31
|
+
const contentFormat = collection?.contentFormat;
|
|
32
|
+
const variableExamples = collection?.variableSchema ?? {};
|
|
33
|
+
return {
|
|
34
|
+
collection,
|
|
35
|
+
contentFormat,
|
|
36
|
+
ContentEditor: useContentEditor(contentFormat),
|
|
37
|
+
documentBlocks: documentBlocksForKind(collection?.kind),
|
|
38
|
+
variableExamples,
|
|
39
|
+
variables: Object.keys(variableExamples),
|
|
40
|
+
canWrite: canWriteContent(collection, user?.roles),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { TargetRef } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { useLocale, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
import { useDispatchTarget } from "@cosmicdrift/kumiko-renderer/web";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import { SLUG_MAX_LENGTH, SLUG_PATTERN } from "./constants.ts";
|
|
6
|
+
import { refreshTree } from "./content-tree-provider.ts";
|
|
7
|
+
import { collectionHandlerName } from "./qualified-names.ts";
|
|
8
|
+
import { useCollectionEditorContext } from "./use-collection-editor-context.ts";
|
|
9
|
+
import { useContentWrite } from "./use-content-write.ts";
|
|
10
|
+
|
|
11
|
+
type CreateTargetArgs = { collectionId?: string; folder?: string };
|
|
12
|
+
|
|
13
|
+
function readCreateTargetArgs(target: TargetRef): {
|
|
14
|
+
readonly collectionId: string | undefined;
|
|
15
|
+
readonly folder: string | null;
|
|
16
|
+
} {
|
|
17
|
+
// @cast-boundary visual-tree-args — same de-erasure as the edit resolver; a missing
|
|
18
|
+
// collectionId (hand-edited URL) leaves the form without a collection to write to.
|
|
19
|
+
const args = target.args as CreateTargetArgs | undefined;
|
|
20
|
+
const folder = args?.folder !== undefined && args.folder !== "" ? args.folder : null;
|
|
21
|
+
return { collectionId: args?.collectionId, folder };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function useCollectionEntryCreate(target: TargetRef, tenantId: string | undefined) {
|
|
25
|
+
const { collectionId, folder } = readCreateTargetArgs(target);
|
|
26
|
+
const t = useTranslation();
|
|
27
|
+
const locale = useLocale().locale();
|
|
28
|
+
const dispatchTarget = useDispatchTarget();
|
|
29
|
+
const editorContext = useCollectionEditorContext(collectionId);
|
|
30
|
+
const { collection, canWrite } = editorContext;
|
|
31
|
+
const { submitting, error, writeScoped } = useContentWrite();
|
|
32
|
+
|
|
33
|
+
const [slug, setSlug] = useState("");
|
|
34
|
+
const [title, setTitle] = useState("");
|
|
35
|
+
const [content, setContent] = useState("");
|
|
36
|
+
|
|
37
|
+
const slugValid = slug.length <= SLUG_MAX_LENGTH && SLUG_PATTERN.test(slug);
|
|
38
|
+
const disabled = submitting || !canWrite || collectionId === undefined;
|
|
39
|
+
const headerTitle = t("template-resolver.editor.newEntryTitle", {
|
|
40
|
+
label: collection !== undefined ? t(collection.nav.label) : "",
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const create = async (): Promise<void> => {
|
|
44
|
+
// skip: nothing to write to, or the slug is invalid (the field already shows the issue)
|
|
45
|
+
if (collectionId === undefined || !slugValid) return;
|
|
46
|
+
const requestedOverride = collection?.ownership === "user" ? undefined : tenantId;
|
|
47
|
+
const written = await writeScoped(
|
|
48
|
+
collectionHandlerName(collectionId),
|
|
49
|
+
{
|
|
50
|
+
slug,
|
|
51
|
+
locale,
|
|
52
|
+
title,
|
|
53
|
+
content: content.length > 0 ? content : null,
|
|
54
|
+
folder,
|
|
55
|
+
createOnly: true,
|
|
56
|
+
},
|
|
57
|
+
requestedOverride,
|
|
58
|
+
);
|
|
59
|
+
// skip: useContentWrite already set the failure text
|
|
60
|
+
if (written === null) return;
|
|
61
|
+
const { response, tenantIdOverride } = written;
|
|
62
|
+
refreshTree(collectionId);
|
|
63
|
+
dispatchTarget({
|
|
64
|
+
featureId: "template-resolver",
|
|
65
|
+
action: "edit",
|
|
66
|
+
args: {
|
|
67
|
+
slug: response.slug,
|
|
68
|
+
locale: response.locale,
|
|
69
|
+
collectionId,
|
|
70
|
+
...(tenantIdOverride !== undefined && { tenantIdOverride }),
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
editorContext,
|
|
77
|
+
headerTitle,
|
|
78
|
+
slug,
|
|
79
|
+
setSlug,
|
|
80
|
+
title,
|
|
81
|
+
setTitle,
|
|
82
|
+
content,
|
|
83
|
+
setContent,
|
|
84
|
+
slugValid,
|
|
85
|
+
createDisabled: disabled || !slugValid || title === "",
|
|
86
|
+
slugInvalid: slug !== "" && !slugValid,
|
|
87
|
+
disabled,
|
|
88
|
+
submitting,
|
|
89
|
+
error,
|
|
90
|
+
create,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { dispatcherErrorText, useDispatcher, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
2
|
+
import type { DispatcherError } from "@cosmicdrift/kumiko-renderer/headless";
|
|
3
|
+
import { useCallback, useState } from "react";
|
|
4
|
+
import { isTenantOverrideDeniedError } from "./tenant-override.ts";
|
|
5
|
+
|
|
6
|
+
export type TenantScopedWrite = {
|
|
7
|
+
readonly response: ContentWriteResponse;
|
|
8
|
+
/** The override the write was accepted with; undefined after a denied override fell back to the session tenant. */
|
|
9
|
+
readonly tenantIdOverride: string | undefined;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type ContentWriteResponse = {
|
|
13
|
+
readonly slug: string;
|
|
14
|
+
readonly locale: string;
|
|
15
|
+
readonly isNew: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type Translate = (key: string, params?: Readonly<Record<string, unknown>>) => string;
|
|
19
|
+
|
|
20
|
+
// A validation failure names the offending field with its own message; prefer that over the generic one.
|
|
21
|
+
function writeErrorText(error: DispatcherError, t: Translate): string {
|
|
22
|
+
const issue = error.details?.fields?.[0];
|
|
23
|
+
if (issue !== undefined) {
|
|
24
|
+
const translated = t(issue.i18nKey, issue.params);
|
|
25
|
+
if (translated !== issue.i18nKey) return translated;
|
|
26
|
+
}
|
|
27
|
+
return dispatcherErrorText(error, t) || t("template-resolver.editor.saveFailed");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Dispatches a content set and keeps the submit state; `write` resolves to the response,
|
|
31
|
+
// or null after the failure text has been set in `error`.
|
|
32
|
+
export function useContentWrite() {
|
|
33
|
+
const dispatcher = useDispatcher();
|
|
34
|
+
const t = useTranslation();
|
|
35
|
+
const [submitting, setSubmitting] = useState(false);
|
|
36
|
+
const [error, setError] = useState<string | null>(null);
|
|
37
|
+
|
|
38
|
+
const writeScoped = async (
|
|
39
|
+
handler: string,
|
|
40
|
+
payload: Readonly<Record<string, unknown>>,
|
|
41
|
+
requestedOverride: string | undefined,
|
|
42
|
+
): Promise<TenantScopedWrite | null> => {
|
|
43
|
+
setSubmitting(true);
|
|
44
|
+
setError(null);
|
|
45
|
+
try {
|
|
46
|
+
let tenantIdOverride = requestedOverride;
|
|
47
|
+
let result = await dispatcher.write<ContentWriteResponse>(handler, {
|
|
48
|
+
...payload,
|
|
49
|
+
...(tenantIdOverride !== undefined && { tenantIdOverride }),
|
|
50
|
+
});
|
|
51
|
+
if (
|
|
52
|
+
!result.isSuccess &&
|
|
53
|
+
tenantIdOverride !== undefined &&
|
|
54
|
+
isTenantOverrideDeniedError(result.error)
|
|
55
|
+
) {
|
|
56
|
+
tenantIdOverride = undefined;
|
|
57
|
+
result = await dispatcher.write<ContentWriteResponse>(handler, payload);
|
|
58
|
+
}
|
|
59
|
+
if (result.isSuccess) return { response: result.data, tenantIdOverride };
|
|
60
|
+
setError(writeErrorText(result.error, t));
|
|
61
|
+
return null;
|
|
62
|
+
} catch (e) {
|
|
63
|
+
// Network blip / dispatcher throw — otherwise submitting stays true and
|
|
64
|
+
// the save button is locked forever with no feedback.
|
|
65
|
+
setError(e instanceof Error ? e.message : t("template-resolver.editor.networkError"));
|
|
66
|
+
return null;
|
|
67
|
+
} finally {
|
|
68
|
+
setSubmitting(false);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const write = async (
|
|
73
|
+
handler: string,
|
|
74
|
+
payload: Readonly<Record<string, unknown>>,
|
|
75
|
+
): Promise<ContentWriteResponse | null> =>
|
|
76
|
+
(await writeScoped(handler, payload, undefined))?.response ?? null;
|
|
77
|
+
|
|
78
|
+
const clearError = useCallback(() => setError(null), []);
|
|
79
|
+
|
|
80
|
+
return { submitting, error, clearError, write, writeScoped };
|
|
81
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { TargetRef } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { useQuery, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { refreshTree, TEXT_BLOCK_TREE_KEY } from "./content-tree-provider.ts";
|
|
5
|
+
import { convertLegacyVariableText } from "./legacy-variable-text.ts";
|
|
6
|
+
import {
|
|
7
|
+
collectionHandlerName,
|
|
8
|
+
collectionQueryName,
|
|
9
|
+
TemplateResolverHandlers,
|
|
10
|
+
TemplateResolverQueries,
|
|
11
|
+
} from "./qualified-names.ts";
|
|
12
|
+
import { useCollectionEditorContext } from "./use-collection-editor-context.ts";
|
|
13
|
+
import { useContentWrite } from "./use-content-write.ts";
|
|
14
|
+
|
|
15
|
+
type TextBlock = {
|
|
16
|
+
readonly slug: string;
|
|
17
|
+
readonly locale: string;
|
|
18
|
+
readonly title: string | null;
|
|
19
|
+
readonly content: string | null;
|
|
20
|
+
readonly contentFormat: string;
|
|
21
|
+
readonly folder: string | null;
|
|
22
|
+
readonly modifiedAt: string | null;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type EditTargetArgs = {
|
|
26
|
+
slug?: string;
|
|
27
|
+
locale?: string;
|
|
28
|
+
collectionId?: string;
|
|
29
|
+
tenantIdOverride?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function useTextBlockEditor(target: TargetRef) {
|
|
33
|
+
// @cast-boundary visual-tree-args — TargetRef.args is erased to
|
|
34
|
+
// Record<string, unknown>; the resolver knows the action shape (see the
|
|
35
|
+
// treeActions.edit definition in feature.ts) and de-erases per action, the
|
|
36
|
+
// same way event payloads do. Optional chaining absorbs missing fields so a
|
|
37
|
+
// hand-edited URL cannot crash the editor.
|
|
38
|
+
const args = target.args as EditTargetArgs | undefined;
|
|
39
|
+
const slug = args?.slug ?? "";
|
|
40
|
+
const locale = args?.locale ?? "";
|
|
41
|
+
const tenantIdOverride = args?.tenantIdOverride;
|
|
42
|
+
// Which collection the node came from. Absent → the public text-block pair,
|
|
43
|
+
// which is what the hand-wired content tree uses.
|
|
44
|
+
const collectionId = args?.collectionId;
|
|
45
|
+
|
|
46
|
+
const t = useTranslation();
|
|
47
|
+
const editorContext = useCollectionEditorContext(collectionId);
|
|
48
|
+
const { contentFormat, canWrite } = editorContext;
|
|
49
|
+
const { submitting, error: saveError, clearError, write } = useContentWrite();
|
|
50
|
+
|
|
51
|
+
// Same split as the tree provider: a node without a collection reads through
|
|
52
|
+
// the public by-slug, a collection node through that collection's handlers.
|
|
53
|
+
const readQuery =
|
|
54
|
+
collectionId === undefined
|
|
55
|
+
? TemplateResolverQueries.bySlug
|
|
56
|
+
: collectionQueryName(collectionId, "item");
|
|
57
|
+
const writeHandler =
|
|
58
|
+
collectionId === undefined ? TemplateResolverHandlers.set : collectionHandlerName(collectionId);
|
|
59
|
+
const {
|
|
60
|
+
data: loaded,
|
|
61
|
+
loading,
|
|
62
|
+
error: loadError,
|
|
63
|
+
} = useQuery<TextBlock | null>(
|
|
64
|
+
readQuery,
|
|
65
|
+
{ slug, locale, ...(tenantIdOverride !== undefined && { tenantIdOverride }) },
|
|
66
|
+
{ enabled: slug !== "" && locale !== "" },
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const [title, setTitle] = useState("");
|
|
70
|
+
const [content, setContent] = useState("");
|
|
71
|
+
const [savedMsg, setSavedMsg] = useState<string | null>(null);
|
|
72
|
+
|
|
73
|
+
// Sync loaded data into the form state. loaded === null means the block does
|
|
74
|
+
// not exist yet — empty form for the create flow.
|
|
75
|
+
// kumiko-lint-ignore no-raw-hooks not yet converted to the framework hook set
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
// skip: keep the form untouched until the query settles
|
|
78
|
+
if (loading) return;
|
|
79
|
+
const loadedContent = loaded?.content ?? "";
|
|
80
|
+
setTitle(loaded?.title ?? "");
|
|
81
|
+
setContent(
|
|
82
|
+
contentFormat === "document" ? convertLegacyVariableText(loadedContent) : loadedContent,
|
|
83
|
+
);
|
|
84
|
+
clearError();
|
|
85
|
+
setSavedMsg(null);
|
|
86
|
+
}, [loading, loaded, contentFormat, clearError]);
|
|
87
|
+
|
|
88
|
+
const save = async (): Promise<void> => {
|
|
89
|
+
setSavedMsg(null);
|
|
90
|
+
const response = await write(writeHandler, {
|
|
91
|
+
slug,
|
|
92
|
+
locale,
|
|
93
|
+
title,
|
|
94
|
+
content: content.length > 0 ? content : null,
|
|
95
|
+
folder: loaded?.folder ?? null,
|
|
96
|
+
// A collection derives its storage format on the server; the public set keeps what was saved.
|
|
97
|
+
...(collectionId === undefined &&
|
|
98
|
+
loaded?.contentFormat !== undefined && { contentFormat: loaded.contentFormat }),
|
|
99
|
+
...(tenantIdOverride !== undefined && { tenantIdOverride }),
|
|
100
|
+
});
|
|
101
|
+
// skip: useContentWrite already set the failure text
|
|
102
|
+
if (response === null) return;
|
|
103
|
+
refreshTree(collectionId ?? TEXT_BLOCK_TREE_KEY);
|
|
104
|
+
setSavedMsg(
|
|
105
|
+
response.isNew ? t("template-resolver.editor.created") : t("template-resolver.editor.saved"),
|
|
106
|
+
);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
slug,
|
|
111
|
+
locale,
|
|
112
|
+
editorContext,
|
|
113
|
+
canWrite,
|
|
114
|
+
loading,
|
|
115
|
+
loadError,
|
|
116
|
+
submitting,
|
|
117
|
+
saveError,
|
|
118
|
+
savedMsg,
|
|
119
|
+
disabled: submitting || loading || !canWrite,
|
|
120
|
+
saveLabelKey: submitting ? "template-resolver.editor.saving" : "template-resolver.editor.save",
|
|
121
|
+
title,
|
|
122
|
+
setTitle,
|
|
123
|
+
content,
|
|
124
|
+
setContent,
|
|
125
|
+
save,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.395.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "The tier resolver no longer keeps tier writes made before its build",
|
|
6
|
+
"detail": "A tier write that ran before build() cached a feature set without the always-on features, and a later build() left that entry in place, so the tenant got feature_disabled instead of the default fallback. build() now starts from an empty cache and writes before it are not cached."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.392.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -337,6 +337,7 @@ export function createTierEngineFeature<
|
|
|
337
337
|
// reload result is applied only while its ticket is still the tenant's latest.
|
|
338
338
|
let ticketCounter = 0;
|
|
339
339
|
const latestTicket = new Map<TenantId, number>();
|
|
340
|
+
let isBuilt = false;
|
|
340
341
|
const takeTicket = (tenantId: TenantId): number => {
|
|
341
342
|
ticketCounter += 1;
|
|
342
343
|
latestTicket.set(tenantId, ticketCounter);
|
|
@@ -370,7 +371,9 @@ export function createTierEngineFeature<
|
|
|
370
371
|
// bus and re-read the row.
|
|
371
372
|
const applyAssignment = (tenantId: TenantId, tier: string): void => {
|
|
372
373
|
takeTicket(tenantId);
|
|
373
|
-
|
|
374
|
+
// Before build() the always-on set is empty; a cached entry would outlive into the next build.
|
|
375
|
+
if (isBuilt)
|
|
376
|
+
cache.set(tenantId, mergeAlwaysOn(alwaysOnHolder.set, featuresForTier(tierMap, tier)));
|
|
374
377
|
publishAssignmentChange(tenantId);
|
|
375
378
|
};
|
|
376
379
|
onTierAssigned.fn = applyAssignment;
|
|
@@ -516,6 +519,11 @@ export function createTierEngineFeature<
|
|
|
516
519
|
if (feature.toggleableDefault === undefined) computedAlwaysOn.add(feature.name);
|
|
517
520
|
}
|
|
518
521
|
alwaysOnHolder.set = computedAlwaysOn;
|
|
522
|
+
// One feature instance can serve several stacks in a process; rebuild state from this DB only.
|
|
523
|
+
// Safe vs. "never clear-then-refill": no request reaches this resolver before build() returns.
|
|
524
|
+
cache.clear();
|
|
525
|
+
latestTicket.clear();
|
|
526
|
+
isBuilt = true;
|
|
519
527
|
|
|
520
528
|
// Pre-load all existing assignments into the cache. SaaS apps typically
|
|
521
529
|
// have <100k tenants, so a single-pass scan is acceptable. The scaling
|
|
@@ -1793,7 +1793,7 @@
|
|
|
1793
1793
|
},
|
|
1794
1794
|
{
|
|
1795
1795
|
"name": "managed-pages",
|
|
1796
|
-
"description": "Tenant-editable, server-rendered public pages with per-tenant branding. Stores one Markdown `
|
|
1796
|
+
"description": "Tenant-editable, server-rendered public pages with per-tenant branding. Stores one `page` (Markdown or editor-document HTML body, per `bodyFormat`) per `(tenantId, slug, lang)` in the `read_pages` entity table with a `published` gate plus `description`/`ogImage` SEO meta. Registers an anonymous `GET {basePath}/:slug` route that resolves the tenant from the request Host via the app-supplied `resolveApexTenant`, serves only published pages (drafts → 404), renders Markdown through the hardened `page-render` core and document bodies through the template-resolver document sanitizer, and isolates per-tenant content with `Vary: Host`. Ships TenantAdmin/SystemAdmin admin screens (`entityList` + `entityEdit`) backed by convention CRUD handlers (`managed-pages:write:page:{create,update,delete}`, `managed-pages:query:page:{list,detail}`); the app wires nav/workspace onto `managed-pages:screen:page-list`. Also exposes `managed-pages:query:by-tenant-published` (anonymous, SQL-filtered on `published`) for sitemap/discovery consumers such as the `seo` feature. Branding (via `config`, scope tenant): `branding-{title,description,site-url,accent-color,logo-url,layout-preset,custom-css}` keys with write-time validation (hex color, https URLs), a `configEdit` self-service screen (`managed-pages:screen:branding-settings`), and a `managed-pages:query:branding` read that the render path applies as scoped `:root` CSS vars + a logo/title header. Also exposes `managed-pages:write:set` (idempotent slug-keyed upsert, SystemAdmin cross-tenant via `tenantIdOverride`) as a provisioning API. Requires `config` + `anonymousAccess` wired at app bootstrap.",
|
|
1797
1797
|
"toggleableDefault": null,
|
|
1798
1798
|
"adoptsBundledLists": true,
|
|
1799
1799
|
"requires": [
|
|
@@ -2824,7 +2824,7 @@
|
|
|
2824
2824
|
},
|
|
2825
2825
|
{
|
|
2826
2826
|
"name": "template-resolver",
|
|
2827
|
-
"description": "Every piece of editable text lives here, in one entity: mail bodies, notification texts, PDF document templates, AI prompts and plain text blocks. What a record is used for is the `kind` (`notification`, `mail`, `mail-layout`, `document-pdf`, `pdf-layout`, `ai-prompt`, `text-block`, `snippet`, `image-snapshot`).\n\nReading 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.\n\nEditing 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.\n\nText 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.\n\nHow a collection is edited follows from `contentFormat`: `plain` gives a text area, `
|
|
2827
|
+
"description": "Every piece of editable text lives here, in one entity: mail bodies, notification texts, PDF document templates, AI prompts and plain text blocks. What a record is used for is the `kind` (`notification`, `mail`, `mail-layout`, `document-pdf`, `pdf-layout`, `ai-prompt`, `text-block`, `snippet`, `image-snapshot`).\n\nReading 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.\n\nEditing 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.\n\nText 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.\n\nHow 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).\n\nA 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.\n\n`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.\n\n`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.\n\nImages used by a template (`<img data-resource=\"logo\">`) are uploaded with `resourceUpload`, which re-encodes them, stores a file and links it under the key; `resourceRemove` unlinks it. `linkedResources` is written only through these two handlers. With `purpose: \"attachment\"` and a `fileName`, `resourceUpload` stores a PDF, PNG or JPEG as a file attachment of a mail template (original bytes, at most 10) that the mail renderer attaches after the inline images. `resourceLinkPdf` instead links a `document-pdf` template under a key: every mail sent from the template then carries that PDF, rendered at send time with the mail's own variables and locale, under a file name pattern such as `invoice-{{number}}.pdf`; files and linked PDFs share the limit of 10.\n\nReplaces the former `text-content` feature; its blocks now live here as kind `text-block`.",
|
|
2828
2828
|
"toggleableDefault": null,
|
|
2829
2829
|
"requires": [],
|
|
2830
2830
|
"optionalRequires": [],
|
|
@@ -28,6 +28,7 @@ import { featureTogglesClient } from "@cosmicdrift/kumiko-bundled-features/featu
|
|
|
28
28
|
import { foldersClient } from "@cosmicdrift/kumiko-bundled-features/folders/web";
|
|
29
29
|
import { inboundMailFoundationClient } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation/web";
|
|
30
30
|
import { jobsClient } from "@cosmicdrift/kumiko-bundled-features/jobs/web";
|
|
31
|
+
import { managedPagesClient } from "@cosmicdrift/kumiko-bundled-features/managed-pages/web";
|
|
31
32
|
import { sessionsClient } from "@cosmicdrift/kumiko-bundled-features/sessions/web";
|
|
32
33
|
import { tagsClient } from "@cosmicdrift/kumiko-bundled-features/tags/web";
|
|
33
34
|
import { textBlocksClient } from "@cosmicdrift/kumiko-bundled-features/template-resolver/web";
|
|
@@ -100,6 +101,9 @@ createKumikoApp({
|
|
|
100
101
|
shell: AppShell,
|
|
101
102
|
clientFeatures: [
|
|
102
103
|
localeDeClient(),
|
|
104
|
+
userDataRightsClient({
|
|
105
|
+
publicDeletion: { requestPath: "/account/delete", confirmPath: "/account/delete/confirm" },
|
|
106
|
+
}),
|
|
103
107
|
emailPasswordClient({ mfaVerifyScreen: MfaVerifyScreen }),
|
|
104
108
|
authMfaClient(),
|
|
105
109
|
adminShellClient(),
|
|
@@ -113,7 +117,6 @@ createKumikoApp({
|
|
|
113
117
|
tierEngineClient(),
|
|
114
118
|
capOverviewClient(),
|
|
115
119
|
userProfileClient(),
|
|
116
|
-
userDataRightsClient(),
|
|
117
120
|
customFieldsClient(),
|
|
118
121
|
foldersClient(),
|
|
119
122
|
inboundMailFoundationClient(),
|
|
@@ -122,6 +125,8 @@ createKumikoApp({
|
|
|
122
125
|
// tag-list management screen (TagManager) + TagSection/TagFilter extension
|
|
123
126
|
// slots + tag i18n. Required for the dev-only notesFeature host to render.
|
|
124
127
|
tagsClient(),
|
|
128
|
+
// Document editor of the page-edit body section.
|
|
129
|
+
managedPagesClient(),
|
|
125
130
|
// template-resolver text-block tree + editor under the app-owned "content"
|
|
126
131
|
// nav node. Points at SYSTEM_TENANT because that is where the legal and
|
|
127
132
|
// marketing copy is seeded.
|
|
@@ -335,7 +335,19 @@ export const seedScreenshotData: SeedFn = async (stack) => {
|
|
|
335
335
|
slug: "about",
|
|
336
336
|
lang: "en",
|
|
337
337
|
title: "About Acme",
|
|
338
|
-
body:
|
|
338
|
+
body: [
|
|
339
|
+
"# About Acme",
|
|
340
|
+
"",
|
|
341
|
+
"We build calm software for small teams. Acme was founded in 2019 in Hamburg and now supports more than 400 companies across Europe.",
|
|
342
|
+
"",
|
|
343
|
+
"## What we stand for",
|
|
344
|
+
"",
|
|
345
|
+
"- Software that stays out of your way",
|
|
346
|
+
"- Fair, per-seat pricing without surprises",
|
|
347
|
+
"- Your data stays in the EU",
|
|
348
|
+
"",
|
|
349
|
+
"Read more about how we handle data in our [privacy policy](/legal/privacy).",
|
|
350
|
+
].join("\n"),
|
|
339
351
|
published: true,
|
|
340
352
|
});
|
|
341
353
|
await seedPage(stack.db, {
|
|
@@ -316,10 +316,10 @@ export const APP_FEATURES = [
|
|
|
316
316
|
label: "templateResolver:nav.snippets",
|
|
317
317
|
workspaces: ["admin-shell:workspace:platform"],
|
|
318
318
|
},
|
|
319
|
-
//
|
|
319
|
+
// document + variableSchema so the smoke app renders the WYSIWYG with
|
|
320
320
|
// insertable chips, not just the plain textarea — the preview
|
|
321
321
|
// substitutes these example values for `{{customerName}}` & co.
|
|
322
|
-
contentFormat: "
|
|
322
|
+
contentFormat: "document",
|
|
323
323
|
variableSchema: {
|
|
324
324
|
customerName: "Max Mustermann",
|
|
325
325
|
orderId: "A-1042",
|
|
@@ -335,7 +335,7 @@ export const APP_FEATURES = [
|
|
|
335
335
|
label: "templateResolver:nav.signatures",
|
|
336
336
|
workspaces: ["admin-shell:workspace:platform"],
|
|
337
337
|
},
|
|
338
|
-
contentFormat: "
|
|
338
|
+
contentFormat: "document",
|
|
339
339
|
},
|
|
340
340
|
],
|
|
341
341
|
}),
|
|
@@ -352,12 +352,18 @@ export const APP_FEATURES = [
|
|
|
352
352
|
// PUPPETEER_SERVICE_URL + PUPPETEER_AUTH_TOKEN and only asked when a PDF is generated.
|
|
353
353
|
createRendererPuppeteerClientFeature(),
|
|
354
354
|
|
|
355
|
-
// managed-pages: requires config (auto-bundled).
|
|
356
|
-
//
|
|
355
|
+
// managed-pages: requires config (auto-bundled). Every host resolves to the
|
|
356
|
+
// dev tenant so the document-editor e2e can read a saved page back from the
|
|
357
|
+
// public route. allowCustomCss:true boot-validates the CSS code path + emits
|
|
357
358
|
// the branding-custom-css key into the manifest. The managed-pages-css
|
|
358
359
|
// companion toggle is handler-less, has no subpath export, and is recipe-
|
|
359
360
|
// covered — not standalone-mounted here (smoke = one mount per subpath-export).
|
|
360
|
-
createManagedPagesFeature({
|
|
361
|
+
createManagedPagesFeature({
|
|
362
|
+
// Mapping every host to the dev tenant is for this sample and its e2e only; production maps
|
|
363
|
+
// its known hosts and returns null for any other.
|
|
364
|
+
resolveApexTenant: () => DEV_TENANT_ID as TenantId,
|
|
365
|
+
allowCustomCss: true,
|
|
366
|
+
}),
|
|
361
367
|
|
|
362
368
|
// Template overview, layout editor and PDF template editor; needs template-resolver and managed-pages above.
|
|
363
369
|
createDocumentDesignerFeature(),
|