@cosmicdrift/kumiko-samples 0.374.0 → 0.377.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 +6 -4
- package/packages/bundled-features/src/account-security/changes.json +6 -0
- package/packages/bundled-features/src/account-security/constants.ts +1 -0
- package/packages/bundled-features/src/account-security/index.ts +1 -0
- package/packages/bundled-features/src/admin-shell/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/audit/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/auth-email-password/mail-notifications.ts +1 -1
- package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +1 -1
- package/packages/bundled-features/src/auth-email-password/web/auth-form-primitives.tsx +1 -1
- package/packages/bundled-features/src/auth-email-password/web/client-plugin.ts +3 -3
- package/packages/bundled-features/src/auth-email-password/web/default-topbar-actions.tsx +1 -1
- package/packages/bundled-features/src/auth-email-password/web/index.ts +1 -1
- package/packages/bundled-features/src/auth-email-password/web/session.tsx +1 -1
- package/packages/bundled-features/src/auth-email-password/web/tenant-menu-items.tsx +1 -1
- package/packages/bundled-features/src/auth-email-password/web/tenant-switcher.tsx +1 -1
- package/packages/bundled-features/src/auth-email-password/web/user-menu.tsx +1 -1
- package/packages/bundled-features/src/auth-foundation/changes.json +6 -0
- package/packages/bundled-features/src/auth-foundation/env.ts +27 -0
- package/packages/bundled-features/src/auth-foundation/index.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +1 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +1 -1
- package/packages/bundled-features/src/billing-foundation/contact-card.ts +14 -0
- package/packages/bundled-features/src/billing-foundation/handlers/billing-plans.query.ts +1 -1
- package/packages/bundled-features/src/billing-foundation/index.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/types.ts +12 -0
- package/packages/bundled-features/src/billing-foundation/validate-options.ts +16 -0
- package/packages/bundled-features/src/billing-foundation/web/billing-contact-plan.tsx +32 -0
- package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +5 -15
- package/packages/bundled-features/src/billing-foundation/web/cancel-contract-dialog.tsx +2 -2
- package/packages/bundled-features/src/billing-foundation/web/checkout-consent-dialog.tsx +1 -1
- package/packages/bundled-features/src/billing-foundation/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/billing-foundation/web/localized-params.ts +14 -0
- package/packages/bundled-features/src/cap-overview/web/cap-cards-panel.tsx +3 -3
- package/packages/bundled-features/src/cap-overview/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/channel-email/changes.json +6 -0
- package/packages/bundled-features/src/channel-email/email-channel.ts +2 -1
- package/packages/bundled-features/src/compliance-profiles/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/config/feature.ts +2 -2
- package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
- package/packages/bundled-features/src/custom-fields/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/delivery/unsubscribe-page.ts +1 -1
- package/packages/bundled-features/src/delivery/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/delivery/web/delivery-status-cell.tsx +1 -1
- package/packages/bundled-features/src/feature-toggles/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/file-provider-s3/changes.json +6 -0
- package/packages/bundled-features/src/file-provider-s3/env-named-feature.ts +45 -0
- package/packages/bundled-features/src/file-provider-s3/env-schema.ts +20 -13
- package/packages/bundled-features/src/file-provider-s3/index.ts +4 -0
- package/packages/bundled-features/src/folders/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/folders/web/folder-section.tsx +3 -4
- package/packages/bundled-features/src/host-routing/README.md +7 -0
- package/packages/bundled-features/src/host-routing/changes.json +6 -0
- package/packages/bundled-features/src/host-routing/constants.ts +4 -0
- package/packages/bundled-features/src/host-routing/dispatch.ts +34 -26
- package/packages/bundled-features/src/host-routing/feature.ts +122 -41
- package/packages/bundled-features/src/host-routing/host.ts +28 -1
- package/packages/bundled-features/src/host-routing/index.ts +3 -0
- package/packages/bundled-features/src/host-routing/options.ts +45 -2
- package/packages/bundled-features/src/host-routing/redirects.ts +33 -0
- package/packages/bundled-features/src/host-routing/tenant-lookup.ts +65 -0
- package/packages/bundled-features/src/host-routing/validate-options.ts +48 -1
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +7 -0
- package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +8 -0
- package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +42 -0
- package/packages/bundled-features/src/inbound-mail-foundation/index.ts +4 -0
- package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +100 -0
- package/packages/bundled-features/src/inbound-mail-foundation/types.ts +2 -2
- package/packages/bundled-features/src/jobs/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/legal-pages/changes.json +6 -0
- package/packages/bundled-features/src/legal-pages/feature.ts +14 -1
- package/packages/bundled-features/src/legal-pages/layout.ts +6 -6
- package/packages/bundled-features/src/legal-pages/web/client-plugin.ts +1 -1
- package/packages/bundled-features/src/mail-foundation/feature.ts +21 -22
- package/packages/bundled-features/src/managed-pages/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/notes-history/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/page-render/branding.ts +1 -1
- package/packages/bundled-features/src/page-render/layout.ts +2 -2
- package/packages/bundled-features/src/page-render/markdown.ts +1 -1
- package/packages/bundled-features/src/personal-access-tokens/i18n.ts +1 -1
- package/packages/bundled-features/src/personal-access-tokens/screens.ts +1 -1
- package/packages/bundled-features/src/secrets/feature.ts +4 -4
- package/packages/bundled-features/src/seo/sitemap.ts +1 -1
- package/packages/bundled-features/src/shared/mail-template-body.ts +1 -1
- package/packages/bundled-features/src/shared/purge-files-reaching-subject.ts +127 -2
- package/packages/bundled-features/src/tags/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/template-resolver/changes.json +18 -0
- package/packages/bundled-features/src/template-resolver/document-blocks.ts +34 -0
- package/packages/bundled-features/src/template-resolver/document-ops.ts +515 -0
- package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +9 -0
- package/packages/bundled-features/src/template-resolver/feature.ts +4 -0
- package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +1 -0
- package/packages/bundled-features/src/template-resolver/handlers/document-base.ts +86 -0
- package/packages/bundled-features/src/template-resolver/handlers/document-blocks.query.ts +48 -0
- package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +11 -3
- package/packages/bundled-features/src/template-resolver/handlers/patch-draft.write.ts +125 -0
- package/packages/bundled-features/src/template-resolver/handlers/preview.query.ts +2 -1
- package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +2 -1
- package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +22 -1
- package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +24 -9
- package/packages/bundled-features/src/template-resolver/{web/placeholder-text.ts → placeholder-text.ts} +1 -1
- package/packages/bundled-features/src/template-resolver/qualified-names.ts +2 -0
- package/packages/bundled-features/src/template-resolver/seeding.ts +3 -1
- package/packages/bundled-features/src/template-resolver/template-bundle.ts +6 -1
- package/packages/bundled-features/src/template-resolver/template-preview.ts +24 -8
- package/packages/bundled-features/src/template-resolver/template-validation.ts +1 -1
- package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +3 -3
- package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +1 -1
- package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +7 -33
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +24 -0
- package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +2 -2
- package/packages/bundled-features/src/tenant/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/tenant/web/member-status-cell.tsx +1 -1
- package/packages/bundled-features/src/tier-engine/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
- package/packages/bundled-features/src/user-data-rights/env.ts +15 -0
- package/packages/bundled-features/src/user-data-rights/feature.ts +4 -2
- package/packages/bundled-features/src/user-data-rights/index.ts +1 -0
- package/packages/bundled-features/src/user-data-rights/lib/mail-transport-resolver.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/lib/storage-provider-resolver.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/mail-notifications.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/user-data-rights/web/privacy-center-screen.tsx +2 -2
- package/packages/bundled-features/src/waitlist/changes.json +6 -0
- package/packages/bundled-features/src/waitlist/legacy-migrate.ts +51 -2
- package/samples/apps/admin-console/package.json +0 -3
- package/samples/apps/admin-console/src/app/client.tsx +1 -1
- package/samples/apps/admin-console/src/app/shell.tsx +1 -1
- package/samples/apps/config-demo/package.json +1 -3
- package/samples/apps/config-demo/src/app/client.tsx +1 -1
- package/samples/apps/config-demo/src/app/shell.tsx +2 -2
- package/samples/apps/config-demo/src/features/demo/web/index.ts +1 -1
- package/samples/apps/config-demo/tsconfig.json +0 -12
- package/samples/apps/marketing-demo/package.json +1 -3
- package/samples/apps/marketing-demo/src/app/client.tsx +1 -1
- package/samples/apps/marketing-demo/src/app/shell.tsx +1 -1
- package/samples/apps/marketing-demo/src/features/assets/web/index.ts +1 -1
- package/samples/apps/marketing-demo/src/features/helpdesk/web/index.ts +1 -1
- package/samples/apps/showcase/package.json +1 -3
- package/samples/apps/showcase/src/app/client.tsx +1 -1
- package/samples/apps/showcase/src/app/shell.tsx +1 -1
- package/samples/apps/showcase/src/features/demos/web/index.ts +1 -1
- package/samples/apps/showcase/src/features/demos/web/pages/sidebar-panel.tsx +1 -1
- package/samples/apps/showcase/src/features/demos/web/pages/toast.tsx +1 -1
- package/samples/apps/showcase/src/features/items/web/index.ts +1 -1
- package/samples/apps/styleguide/package.json +1 -3
- package/samples/apps/styleguide/src/app/client.tsx +1 -1
- package/samples/apps/styleguide/src/app/shell.tsx +2 -2
- package/samples/apps/styleguide/src/features/ai-settings/web.ts +1 -1
- package/samples/apps/styleguide/src/features/content/web.tsx +2 -2
- package/samples/apps/styleguide/src/features/demo/web.ts +1 -1
- package/samples/apps/styleguide/src/features/examples/web.ts +1 -1
- package/samples/apps/styleguide/src/features/gallery/web/Gallery.tsx +1 -1
- package/samples/apps/styleguide/src/features/gallery/web/index.ts +1 -1
- package/samples/apps/styleguide/src/features/widgets/demo-locale.ts +1 -1
- package/samples/apps/styleguide/src/features/widgets/lib/format.ts +1 -1
- package/samples/apps/styleguide/src/features/widgets/web/DashboardFilterEcho.tsx +1 -1
- package/samples/apps/styleguide/src/features/widgets/web/FormExamples.tsx +1 -1
- package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +1 -1
- package/samples/apps/styleguide/src/features/widgets/web/index.ts +1 -1
- package/samples/apps/styleguide/tsconfig.json +0 -12
- package/samples/apps/ui-walkthrough/package.json +1 -3
- package/samples/apps/ui-walkthrough/src/app/client.tsx +1 -1
- package/samples/apps/ui-walkthrough/src/app/shell.tsx +1 -1
- package/samples/apps/ui-walkthrough/src/features/billing/web/index.ts +1 -1
- package/samples/apps/ui-walkthrough/src/features/rental/web/index.ts +1 -1
- package/samples/apps/ui-walkthrough/src/features/tasks/web/index.ts +1 -1
- package/samples/apps/ui-walkthrough/src/features/vehicles/web/index.ts +1 -1
- package/samples/apps/use-all-bundled/bin/main.ts +3 -2
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +12 -0
- package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +1 -1
- package/samples/apps/use-all-bundled/feature-manifest.json +1 -0
- package/samples/apps/use-all-bundled/package.json +1 -3
- package/samples/apps/use-all-bundled/src/app/client.tsx +1 -1
- package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +5 -4
- package/samples/apps/use-all-bundled/src/app/shell.tsx +1 -1
- package/samples/apps/use-all-bundled/src/run-config.ts +10 -1
- package/samples/apps/workspaces/package.json +1 -3
- package/samples/apps/workspaces/src/app/client.tsx +1 -1
- package/samples/apps/workspaces/src/app/shell.tsx +1 -1
- package/samples/apps/workspaces/src/features/demo/web/index.ts +1 -1
- package/samples/apps/workspaces/src/features/demo-driver/web/index.ts +1 -1
- package/samples/recipes/apex-landing/package.json +1 -1
- package/samples/recipes/apex-landing/src/feature.ts +1 -1
- package/samples/recipes/apex-surface-auth/README.md +1 -1
- package/samples/recipes/apex-surface-auth/src/feature.ts +2 -2
- package/samples/recipes/compose-stacks/README.md +7 -4
- package/samples/recipes/embedded-entity-form/package.json +0 -1
- package/samples/recipes/embedded-entity-form/src/web/accept-suggestion-drawer.tsx +1 -1
- package/samples/recipes/notes-history-basic/README.md +1 -1
- package/samples/recipes/record-detail-layout/package.json +0 -1
- package/samples/recipes/tags-basic/README.md +1 -1
- package/samples/recipes/user-profile/README.md +1 -1
- package/samples/recipes/wizard-form/package.json +0 -1
- package/samples/recipes/wizard-form/src/web/index.ts +2 -2
- package/samples/recipes/wizard-form/src/web/listing-review-section.tsx +2 -2
- package/samples/recipes/writeform-section/package.json +0 -1
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
import type { FieldIssue } from "@cosmicdrift/kumiko-framework/errors";
|
|
2
|
+
import { getSchema, type JSONContent } from "@tiptap/core";
|
|
3
|
+
import * as z from "zod";
|
|
4
|
+
import type { DocumentHtmlKind } from "./constants.ts";
|
|
5
|
+
import { documentBlocksForKind } from "./document-blocks.ts";
|
|
6
|
+
import {
|
|
7
|
+
DOCUMENT_TEXT_ALIGNMENTS,
|
|
8
|
+
documentExtensions,
|
|
9
|
+
isDocumentPlaceholderHref,
|
|
10
|
+
isDocumentReferenceName,
|
|
11
|
+
isDocumentResourceKey,
|
|
12
|
+
} from "./document-schema.ts";
|
|
13
|
+
import { contentToLine, lineToContent, singleLine } from "./placeholder-text.ts";
|
|
14
|
+
import { toVariableDef } from "./template-validation.ts";
|
|
15
|
+
|
|
16
|
+
export const MAX_DOCUMENT_OPS = 50;
|
|
17
|
+
|
|
18
|
+
// Assigning these keys would reach the prototype of the meta object.
|
|
19
|
+
const FORBIDDEN_META_KEYS: ReadonlySet<string> = new Set(["__proto__", "prototype", "constructor"]);
|
|
20
|
+
|
|
21
|
+
export type DocumentBlockType =
|
|
22
|
+
| "paragraph"
|
|
23
|
+
| "heading"
|
|
24
|
+
| "list"
|
|
25
|
+
| "table"
|
|
26
|
+
| "repeat"
|
|
27
|
+
| "image"
|
|
28
|
+
| "button"
|
|
29
|
+
| "divider"
|
|
30
|
+
| "pageBreak"
|
|
31
|
+
| "steps";
|
|
32
|
+
|
|
33
|
+
export type DocumentBlock = {
|
|
34
|
+
readonly id: string;
|
|
35
|
+
readonly type: DocumentBlockType;
|
|
36
|
+
/** Inline content with `{{name}}` for placeholders; containers list one line per text block. */
|
|
37
|
+
readonly text?: string;
|
|
38
|
+
readonly level?: number;
|
|
39
|
+
readonly align?: string;
|
|
40
|
+
readonly showIf?: string;
|
|
41
|
+
readonly resource?: string;
|
|
42
|
+
readonly alt?: string;
|
|
43
|
+
readonly href?: string;
|
|
44
|
+
readonly each?: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const TEXT_BLOCK_TYPES: ReadonlySet<string> = new Set(["paragraph", "heading", "button"]);
|
|
48
|
+
|
|
49
|
+
function blockTypeOf(node: JSONContent): DocumentBlockType | null {
|
|
50
|
+
switch (node.type) {
|
|
51
|
+
case "paragraph":
|
|
52
|
+
case "heading":
|
|
53
|
+
case "button":
|
|
54
|
+
case "divider":
|
|
55
|
+
case "pageBreak":
|
|
56
|
+
case "steps":
|
|
57
|
+
return node.type;
|
|
58
|
+
case "bulletList":
|
|
59
|
+
case "orderedList":
|
|
60
|
+
return "list";
|
|
61
|
+
case "table":
|
|
62
|
+
return eachOf(node) === undefined ? "table" : "repeat";
|
|
63
|
+
case "resource":
|
|
64
|
+
return "image";
|
|
65
|
+
default:
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function stringAttr(node: JSONContent, name: string): string | undefined {
|
|
71
|
+
const value: unknown = node.attrs?.[name];
|
|
72
|
+
return typeof value === "string" && value !== "" ? value : undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function eachOf(table: JSONContent): string | undefined {
|
|
76
|
+
for (const row of table.content ?? []) {
|
|
77
|
+
const each = stringAttr(row, "each");
|
|
78
|
+
if (each !== undefined) return each;
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function textLinesOf(node: JSONContent): string[] {
|
|
84
|
+
if (TEXT_BLOCK_TYPES.has(node.type ?? "")) return [contentToLine(node.content)];
|
|
85
|
+
return (node.content ?? []).flatMap(textLinesOf);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
type MutableBlock = { -readonly [K in keyof DocumentBlock]: DocumentBlock[K] };
|
|
89
|
+
|
|
90
|
+
const NON_TEXT_BLOCK_TYPES: ReadonlySet<DocumentBlockType> = new Set([
|
|
91
|
+
"image",
|
|
92
|
+
"divider",
|
|
93
|
+
"pageBreak",
|
|
94
|
+
"steps",
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
function setOptionalAttrs(block: MutableBlock, node: JSONContent): void {
|
|
98
|
+
const align = stringAttr(node, "textAlign");
|
|
99
|
+
if (align !== undefined) block.align = align;
|
|
100
|
+
const showIf = stringAttr(node, "showIf");
|
|
101
|
+
if (showIf !== undefined) block.showIf = showIf;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function setTypeSpecificAttrs(block: MutableBlock, node: JSONContent): void {
|
|
105
|
+
const level: unknown = node.attrs?.["level"];
|
|
106
|
+
if (block.type === "heading" && typeof level === "number") block.level = level;
|
|
107
|
+
const resource = stringAttr(node, "resource");
|
|
108
|
+
if (block.type === "image" && resource !== undefined) block.resource = resource;
|
|
109
|
+
const alt = stringAttr(node, "alt");
|
|
110
|
+
if (block.type === "image" && alt !== undefined) block.alt = alt;
|
|
111
|
+
const href = stringAttr(node, "href");
|
|
112
|
+
if (block.type === "button" && href !== undefined) block.href = href;
|
|
113
|
+
const each = eachOf(node);
|
|
114
|
+
if (block.type === "repeat" && each !== undefined) block.each = each;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function blockOf(node: JSONContent, id: string): DocumentBlock | null {
|
|
118
|
+
const type = blockTypeOf(node);
|
|
119
|
+
if (type === null) return null;
|
|
120
|
+
const block: MutableBlock = { id, type };
|
|
121
|
+
if (!NON_TEXT_BLOCK_TYPES.has(type)) block.text = textLinesOf(node).join("\n");
|
|
122
|
+
setOptionalAttrs(block, node);
|
|
123
|
+
setTypeSpecificAttrs(block, node);
|
|
124
|
+
return block;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function documentBlockId(index: number): string {
|
|
128
|
+
return `b${index}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function documentBlocksOf(doc: JSONContent): DocumentBlock[] {
|
|
132
|
+
return (doc.content ?? []).flatMap((node, index) => blockOf(node, documentBlockId(index)) ?? []);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const blockIdSchema = z.string().min(1).max(20);
|
|
136
|
+
const singleLineText = z.string().max(2000);
|
|
137
|
+
|
|
138
|
+
const blockInputSchema = z.discriminatedUnion("type", [
|
|
139
|
+
z.object({
|
|
140
|
+
type: z.literal("paragraph"),
|
|
141
|
+
text: singleLineText,
|
|
142
|
+
align: z.enum(DOCUMENT_TEXT_ALIGNMENTS).optional(),
|
|
143
|
+
}),
|
|
144
|
+
z.object({
|
|
145
|
+
type: z.literal("heading"),
|
|
146
|
+
level: z.number().int().min(1).max(3),
|
|
147
|
+
text: singleLineText,
|
|
148
|
+
}),
|
|
149
|
+
z.object({
|
|
150
|
+
type: z.literal("image"),
|
|
151
|
+
resource: z.string().min(1).max(200),
|
|
152
|
+
alt: z.string().max(500).optional(),
|
|
153
|
+
}),
|
|
154
|
+
z.object({ type: z.literal("button"), text: singleLineText, href: z.string().max(300) }),
|
|
155
|
+
z.object({ type: z.literal("divider") }),
|
|
156
|
+
z.object({ type: z.literal("pageBreak") }),
|
|
157
|
+
]);
|
|
158
|
+
|
|
159
|
+
export type BlockInput = z.infer<typeof blockInputSchema>;
|
|
160
|
+
|
|
161
|
+
export const documentOpSchema = z.discriminatedUnion("op", [
|
|
162
|
+
z.object({
|
|
163
|
+
op: z.literal("insertBlock"),
|
|
164
|
+
after: blockIdSchema.nullable(),
|
|
165
|
+
block: blockInputSchema,
|
|
166
|
+
}),
|
|
167
|
+
z.object({
|
|
168
|
+
op: z.literal("replaceText"),
|
|
169
|
+
blockId: blockIdSchema,
|
|
170
|
+
find: z.string().min(1).max(2000),
|
|
171
|
+
replace: z.string().max(2000),
|
|
172
|
+
}),
|
|
173
|
+
z.object({ op: z.literal("removeBlock"), blockId: blockIdSchema }),
|
|
174
|
+
z.object({
|
|
175
|
+
op: z.literal("setResource"),
|
|
176
|
+
blockId: blockIdSchema,
|
|
177
|
+
resource: z.string().min(1).max(200),
|
|
178
|
+
alt: z.string().max(500).nullable().optional(),
|
|
179
|
+
}),
|
|
180
|
+
z.object({ op: z.literal("setMeta"), meta: z.record(z.string(), z.unknown()) }),
|
|
181
|
+
z.object({
|
|
182
|
+
op: z.literal("setVariable"),
|
|
183
|
+
name: z.string().min(1).max(100),
|
|
184
|
+
definition: z.unknown().nullable(),
|
|
185
|
+
}),
|
|
186
|
+
]);
|
|
187
|
+
|
|
188
|
+
export const documentOpsSchema = z.array(documentOpSchema).min(1).max(MAX_DOCUMENT_OPS);
|
|
189
|
+
|
|
190
|
+
export type DocumentOp = z.infer<typeof documentOpSchema>;
|
|
191
|
+
|
|
192
|
+
export type DocumentOpsInput = {
|
|
193
|
+
readonly doc: JSONContent;
|
|
194
|
+
readonly meta: Readonly<Record<string, unknown>>;
|
|
195
|
+
readonly variableSchema: Readonly<Record<string, unknown>>;
|
|
196
|
+
readonly kind: DocumentHtmlKind;
|
|
197
|
+
readonly ops: readonly DocumentOp[];
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
export type DocumentOpsResult =
|
|
201
|
+
| {
|
|
202
|
+
readonly ok: true;
|
|
203
|
+
readonly doc: JSONContent;
|
|
204
|
+
readonly meta: Record<string, unknown>;
|
|
205
|
+
readonly variableSchema: Record<string, unknown>;
|
|
206
|
+
}
|
|
207
|
+
| { readonly ok: false; readonly issues: FieldIssue[] };
|
|
208
|
+
|
|
209
|
+
type Entry = { node: JSONContent; removed: boolean; readonly insertedAfter: JSONContent[] };
|
|
210
|
+
|
|
211
|
+
function patchIssue(
|
|
212
|
+
path: string,
|
|
213
|
+
code: string,
|
|
214
|
+
params?: Readonly<Record<string, unknown>>,
|
|
215
|
+
): FieldIssue {
|
|
216
|
+
const camel = code.replace(/_([a-z])/g, (_match, letter: string) => letter.toUpperCase());
|
|
217
|
+
return {
|
|
218
|
+
path,
|
|
219
|
+
code,
|
|
220
|
+
i18nKey: `templateResolver.errors.patch.${camel}`,
|
|
221
|
+
...(params === undefined ? {} : { params }),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function textNodesIn(nodes: readonly JSONContent[]): JSONContent[] {
|
|
226
|
+
return nodes.flatMap((node) => (node.type === "text" ? [node] : textNodesIn(node.content ?? [])));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function occurrencesOf(text: string, find: string): number {
|
|
230
|
+
return text.split(find).length - 1;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function replacementNodes(textNode: JSONContent, find: string, replace: string): JSONContent[] {
|
|
234
|
+
const text = textNode.text ?? "";
|
|
235
|
+
const at = text.indexOf(find);
|
|
236
|
+
const withMarks = (node: JSONContent): JSONContent =>
|
|
237
|
+
node.type === "text" && textNode.marks !== undefined
|
|
238
|
+
? { ...node, marks: textNode.marks }
|
|
239
|
+
: node;
|
|
240
|
+
const before = text.slice(0, at);
|
|
241
|
+
const after = text.slice(at + find.length);
|
|
242
|
+
return [
|
|
243
|
+
...(before === "" ? [] : [{ ...textNode, text: before }]),
|
|
244
|
+
...lineToContent(singleLine(replace)).map(withMarks),
|
|
245
|
+
...(after === "" ? [] : [{ ...textNode, text: after }]),
|
|
246
|
+
];
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function replaceInContent(
|
|
250
|
+
content: readonly JSONContent[],
|
|
251
|
+
find: string,
|
|
252
|
+
replace: string,
|
|
253
|
+
): JSONContent[] {
|
|
254
|
+
return content.flatMap((node) => {
|
|
255
|
+
if (node.type === "text") {
|
|
256
|
+
return (node.text ?? "").includes(find) ? replacementNodes(node, find, replace) : [node];
|
|
257
|
+
}
|
|
258
|
+
return [
|
|
259
|
+
node.content === undefined
|
|
260
|
+
? node
|
|
261
|
+
: { ...node, content: replaceInContent(node.content, find, replace) },
|
|
262
|
+
];
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function nodeOfInput(input: BlockInput): JSONContent {
|
|
267
|
+
switch (input.type) {
|
|
268
|
+
case "paragraph":
|
|
269
|
+
return {
|
|
270
|
+
type: "paragraph",
|
|
271
|
+
...(input.align === undefined ? {} : { attrs: { textAlign: input.align } }),
|
|
272
|
+
content: lineToContent(singleLine(input.text)),
|
|
273
|
+
};
|
|
274
|
+
case "heading":
|
|
275
|
+
return {
|
|
276
|
+
type: "heading",
|
|
277
|
+
attrs: { level: input.level },
|
|
278
|
+
content: lineToContent(singleLine(input.text)),
|
|
279
|
+
};
|
|
280
|
+
case "image":
|
|
281
|
+
return { type: "resource", attrs: { resource: input.resource, alt: input.alt ?? null } };
|
|
282
|
+
case "button":
|
|
283
|
+
return {
|
|
284
|
+
type: "button",
|
|
285
|
+
attrs: { href: input.href },
|
|
286
|
+
content: lineToContent(singleLine(input.text)),
|
|
287
|
+
};
|
|
288
|
+
case "divider":
|
|
289
|
+
case "pageBreak":
|
|
290
|
+
return { type: input.type };
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const TOOLBAR_BLOCK_OF_INPUT = {
|
|
295
|
+
image: "image",
|
|
296
|
+
button: "button",
|
|
297
|
+
divider: "divider",
|
|
298
|
+
pageBreak: "pageBreak",
|
|
299
|
+
} as const;
|
|
300
|
+
|
|
301
|
+
function isBlockAllowed(input: BlockInput, kind: DocumentHtmlKind): boolean {
|
|
302
|
+
if (input.type === "paragraph" || input.type === "heading") return true;
|
|
303
|
+
return documentBlocksForKind(kind).includes(TOOLBAR_BLOCK_OF_INPUT[input.type]);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function inputIssue(input: BlockInput, path: string): FieldIssue | null {
|
|
307
|
+
if (input.type === "image" && !isDocumentResourceKey(input.resource)) {
|
|
308
|
+
return patchIssue(`${path}.block.resource`, "invalid_resource_key", {
|
|
309
|
+
resource: input.resource,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
if (input.type === "button" && !isDocumentPlaceholderHref(input.href)) {
|
|
313
|
+
return patchIssue(`${path}.block.href`, "invalid_button_href", { href: input.href });
|
|
314
|
+
}
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
let cachedSchema: ReturnType<typeof getSchema> | null = null;
|
|
319
|
+
|
|
320
|
+
function documentSchema(): ReturnType<typeof getSchema> {
|
|
321
|
+
cachedSchema ??= getSchema(documentExtensions);
|
|
322
|
+
return cachedSchema;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function isValidDocument(doc: JSONContent): boolean {
|
|
326
|
+
try {
|
|
327
|
+
documentSchema().nodeFromJSON(doc).check();
|
|
328
|
+
return true;
|
|
329
|
+
} catch {
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
type OpState = {
|
|
335
|
+
readonly kind: DocumentHtmlKind;
|
|
336
|
+
readonly entries: Map<string, Entry>;
|
|
337
|
+
readonly insertedAtStart: JSONContent[];
|
|
338
|
+
readonly meta: Record<string, unknown>;
|
|
339
|
+
readonly variableSchema: Record<string, unknown>;
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
type EntryLookup = { readonly entry: Entry } | { readonly issue: FieldIssue };
|
|
343
|
+
|
|
344
|
+
function lookupEntry(state: OpState, blockId: string, fieldPath: string): EntryLookup {
|
|
345
|
+
const entry = state.entries.get(blockId);
|
|
346
|
+
if (entry === undefined) return { issue: patchIssue(fieldPath, "unknown_block", { blockId }) };
|
|
347
|
+
if (entry.removed) return { issue: patchIssue(fieldPath, "block_removed", { blockId }) };
|
|
348
|
+
return { entry };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
type InsertBlockOp = Extract<DocumentOp, { op: "insertBlock" }>;
|
|
352
|
+
|
|
353
|
+
function blockInputIssue(
|
|
354
|
+
op: InsertBlockOp,
|
|
355
|
+
path: string,
|
|
356
|
+
kind: DocumentHtmlKind,
|
|
357
|
+
): FieldIssue | null {
|
|
358
|
+
if (!isBlockAllowed(op.block, kind)) {
|
|
359
|
+
return patchIssue(`${path}.block.type`, "block_not_allowed", { type: op.block.type, kind });
|
|
360
|
+
}
|
|
361
|
+
if (
|
|
362
|
+
op.block.type === "paragraph" &&
|
|
363
|
+
op.block.align !== undefined &&
|
|
364
|
+
!documentBlocksForKind(kind).includes("align")
|
|
365
|
+
) {
|
|
366
|
+
return patchIssue(`${path}.block.align`, "block_not_allowed", { type: "align", kind });
|
|
367
|
+
}
|
|
368
|
+
return inputIssue(op.block, path);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function insertBlockOp(state: OpState, op: InsertBlockOp, path: string): FieldIssue | null {
|
|
372
|
+
const anchor = op.after === null ? null : lookupEntry(state, op.after, `${path}.after`);
|
|
373
|
+
if (anchor !== null && "issue" in anchor) return anchor.issue;
|
|
374
|
+
const invalid = blockInputIssue(op, path, state.kind);
|
|
375
|
+
if (invalid !== null) return invalid;
|
|
376
|
+
const target = anchor === null ? state.insertedAtStart : anchor.entry.insertedAfter;
|
|
377
|
+
target.push(nodeOfInput(op.block));
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function removeBlockOp(
|
|
382
|
+
state: OpState,
|
|
383
|
+
op: Extract<DocumentOp, { op: "removeBlock" }>,
|
|
384
|
+
path: string,
|
|
385
|
+
): FieldIssue | null {
|
|
386
|
+
const found = lookupEntry(state, op.blockId, `${path}.blockId`);
|
|
387
|
+
if ("issue" in found) return found.issue;
|
|
388
|
+
found.entry.removed = true;
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function replaceTextOp(
|
|
393
|
+
state: OpState,
|
|
394
|
+
op: Extract<DocumentOp, { op: "replaceText" }>,
|
|
395
|
+
path: string,
|
|
396
|
+
): FieldIssue | null {
|
|
397
|
+
const found = lookupEntry(state, op.blockId, `${path}.blockId`);
|
|
398
|
+
if ("issue" in found) return found.issue;
|
|
399
|
+
const { entry } = found;
|
|
400
|
+
const count = textNodesIn([entry.node]).reduce(
|
|
401
|
+
(sum, node) => sum + occurrencesOf(node.text ?? "", op.find),
|
|
402
|
+
0,
|
|
403
|
+
);
|
|
404
|
+
if (count === 0) return patchIssue(`${path}.find`, "text_not_found", { find: op.find });
|
|
405
|
+
if (count > 1) return patchIssue(`${path}.find`, "text_ambiguous", { find: op.find, count });
|
|
406
|
+
entry.node = replaceInContent([entry.node], op.find, op.replace)[0] ?? entry.node;
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function setResourceOp(
|
|
411
|
+
state: OpState,
|
|
412
|
+
op: Extract<DocumentOp, { op: "setResource" }>,
|
|
413
|
+
path: string,
|
|
414
|
+
): FieldIssue | null {
|
|
415
|
+
const found = lookupEntry(state, op.blockId, `${path}.blockId`);
|
|
416
|
+
if ("issue" in found) return found.issue;
|
|
417
|
+
const { entry } = found;
|
|
418
|
+
if (entry.node.type !== "resource") {
|
|
419
|
+
return patchIssue(`${path}.blockId`, "block_type_mismatch", {
|
|
420
|
+
expected: "image",
|
|
421
|
+
actual: entry.node.type ?? "unknown",
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
if (!isDocumentResourceKey(op.resource)) {
|
|
425
|
+
return patchIssue(`${path}.resource`, "invalid_resource_key", { resource: op.resource });
|
|
426
|
+
}
|
|
427
|
+
const alt = op.alt === undefined ? (entry.node.attrs?.["alt"] ?? null) : op.alt;
|
|
428
|
+
entry.node = { ...entry.node, attrs: { resource: op.resource, alt } };
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function setMetaOp(
|
|
433
|
+
state: OpState,
|
|
434
|
+
op: Extract<DocumentOp, { op: "setMeta" }>,
|
|
435
|
+
path: string,
|
|
436
|
+
): FieldIssue | null {
|
|
437
|
+
const forbidden = Object.keys(op.meta).find((key) => FORBIDDEN_META_KEYS.has(key));
|
|
438
|
+
if (forbidden !== undefined) {
|
|
439
|
+
return patchIssue(`${path}.meta`, "invalid_meta_key", { key: forbidden });
|
|
440
|
+
}
|
|
441
|
+
for (const [key, value] of Object.entries(op.meta)) {
|
|
442
|
+
if (value === null) delete state.meta[key];
|
|
443
|
+
else state.meta[key] = value;
|
|
444
|
+
}
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function setVariableOp(
|
|
449
|
+
state: OpState,
|
|
450
|
+
op: Extract<DocumentOp, { op: "setVariable" }>,
|
|
451
|
+
path: string,
|
|
452
|
+
): FieldIssue | null {
|
|
453
|
+
if (!isDocumentReferenceName(op.name)) {
|
|
454
|
+
return patchIssue(`${path}.name`, "invalid_variable_name", { name: op.name });
|
|
455
|
+
}
|
|
456
|
+
if (op.definition === null) {
|
|
457
|
+
delete state.variableSchema[op.name];
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
const definition = toVariableDef(op.definition);
|
|
461
|
+
if (definition === null) {
|
|
462
|
+
return patchIssue(`${path}.definition`, "invalid_variable_definition", { name: op.name });
|
|
463
|
+
}
|
|
464
|
+
state.variableSchema[op.name] = definition;
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function applyOp(state: OpState, op: DocumentOp, path: string): FieldIssue | null {
|
|
469
|
+
switch (op.op) {
|
|
470
|
+
case "insertBlock":
|
|
471
|
+
return insertBlockOp(state, op, path);
|
|
472
|
+
case "removeBlock":
|
|
473
|
+
return removeBlockOp(state, op, path);
|
|
474
|
+
case "replaceText":
|
|
475
|
+
return replaceTextOp(state, op, path);
|
|
476
|
+
case "setResource":
|
|
477
|
+
return setResourceOp(state, op, path);
|
|
478
|
+
case "setMeta":
|
|
479
|
+
return setMetaOp(state, op, path);
|
|
480
|
+
case "setVariable":
|
|
481
|
+
return setVariableOp(state, op, path);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Ops address blocks by the ids of the document as it was read, so inserted blocks are
|
|
486
|
+
// collected per anchor and the list is assembled once at the end.
|
|
487
|
+
export function applyDocumentOps(input: DocumentOpsInput): DocumentOpsResult {
|
|
488
|
+
const entries = new Map<string, Entry>();
|
|
489
|
+
(input.doc.content ?? []).forEach((node, index) => {
|
|
490
|
+
entries.set(documentBlockId(index), {
|
|
491
|
+
node: structuredClone(node),
|
|
492
|
+
removed: false,
|
|
493
|
+
insertedAfter: [],
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
const state: OpState = {
|
|
497
|
+
kind: input.kind,
|
|
498
|
+
entries,
|
|
499
|
+
insertedAtStart: [],
|
|
500
|
+
meta: { ...input.meta },
|
|
501
|
+
variableSchema: { ...input.variableSchema },
|
|
502
|
+
};
|
|
503
|
+
const issues = input.ops.flatMap((op, index) => applyOp(state, op, `ops.${index}`) ?? []);
|
|
504
|
+
if (issues.length > 0) return { ok: false, issues };
|
|
505
|
+
|
|
506
|
+
const content = [
|
|
507
|
+
...state.insertedAtStart,
|
|
508
|
+
...[...entries.values()].flatMap((entry) =>
|
|
509
|
+
entry.removed ? entry.insertedAfter : [entry.node, ...entry.insertedAfter],
|
|
510
|
+
),
|
|
511
|
+
];
|
|
512
|
+
const doc: JSONContent = { ...input.doc, content };
|
|
513
|
+
if (!isValidDocument(doc)) return { ok: false, issues: [patchIssue("ops", "invalid_document")] };
|
|
514
|
+
return { ok: true, doc, meta: state.meta, variableSchema: state.variableSchema };
|
|
515
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// of client barrels and the document-schema subpath export.
|
|
3
3
|
import type { FieldIssue } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
4
|
import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
5
|
+
import type { JSONContent } from "@tiptap/core";
|
|
5
6
|
import { generateHTML, generateJSON } from "@tiptap/html/server";
|
|
6
7
|
import { type Element as ScanElement, Window } from "happy-dom";
|
|
7
8
|
import type { ContentFormat } from "./constants.ts";
|
|
@@ -272,6 +273,14 @@ export function sanitizeDocumentHtml(html: string): SanitizeHtmlResult {
|
|
|
272
273
|
};
|
|
273
274
|
}
|
|
274
275
|
|
|
276
|
+
export function parseDocumentJson(html: string): JSONContent {
|
|
277
|
+
return generateJSON(html, documentExtensions);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function serializeDocumentJson(doc: JSONContent): string {
|
|
281
|
+
return generateHTML(doc, documentExtensions);
|
|
282
|
+
}
|
|
283
|
+
|
|
275
284
|
// For a sanitized value placed inside a line of text: paragraphs become line
|
|
276
285
|
// breaks. Other blocks (lists, headings) cannot be made inline and stay as they are.
|
|
277
286
|
export function flattenParagraphsToLineBreaks(sanitizedHtml: string): string {
|
|
@@ -12,6 +12,7 @@ import { makeCollectionSetWrite } from "./handlers/collection-set.write.ts";
|
|
|
12
12
|
import { copyLocaleWrite } from "./handlers/copy-locale.write.ts";
|
|
13
13
|
import { diffToSystemQuery } from "./handlers/diff-to-system.query.ts";
|
|
14
14
|
import { discardDraftSystemWrite, discardDraftWrite } from "./handlers/discard-draft.write.ts";
|
|
15
|
+
import { documentBlocksQuery } from "./handlers/document-blocks.query.ts";
|
|
15
16
|
import { exportBundleQuery } from "./handlers/export-bundle.query.ts";
|
|
16
17
|
import { findByIdQuery } from "./handlers/find-by-id.query.ts";
|
|
17
18
|
import { importBundleWrite } from "./handlers/import-bundle.write.ts";
|
|
@@ -19,6 +20,7 @@ import { importBundleSystemWrite } from "./handlers/import-bundle-system.write.t
|
|
|
19
20
|
import { layoutSourceQuery } from "./handlers/layout-source.query.ts";
|
|
20
21
|
import { listQuery } from "./handlers/list.query.ts";
|
|
21
22
|
import { overviewQuery } from "./handlers/overview.query.ts";
|
|
23
|
+
import { patchDraftWrite } from "./handlers/patch-draft.write.ts";
|
|
22
24
|
import { previewQuery } from "./handlers/preview.query.ts";
|
|
23
25
|
import { resetToSystemWrite } from "./handlers/reset-to-system.write.ts";
|
|
24
26
|
import { resourceLinkPdfWrite } from "./handlers/resource-link-pdf.write.ts";
|
|
@@ -124,6 +126,7 @@ export function createTemplateResolverFeature(opts: TemplateResolverOptions = {}
|
|
|
124
126
|
resourceRemove: r.writeHandler(resourceRemoveWrite),
|
|
125
127
|
resourceLinkPdf: r.writeHandler(resourceLinkPdfWrite),
|
|
126
128
|
sendTest: r.writeHandler(sendTestWrite),
|
|
129
|
+
patchDraft: r.writeHandler(patchDraftWrite),
|
|
127
130
|
};
|
|
128
131
|
|
|
129
132
|
const queries = {
|
|
@@ -138,6 +141,7 @@ export function createTemplateResolverFeature(opts: TemplateResolverOptions = {}
|
|
|
138
141
|
diffToSystem: r.queryHandler(diffToSystemQuery),
|
|
139
142
|
validate: r.queryHandler(validateQuery),
|
|
140
143
|
preview: r.queryHandler(previewQuery),
|
|
144
|
+
documentBlocks: r.queryHandler(documentBlocksQuery),
|
|
141
145
|
exportBundle: r.queryHandler(exportBundleQuery),
|
|
142
146
|
};
|
|
143
147
|
|
|
@@ -214,6 +214,7 @@ export async function importTemplateBundle(
|
|
|
214
214
|
contentFormat: args.bundle.contentFormat,
|
|
215
215
|
variableSchema,
|
|
216
216
|
meta: entry.meta,
|
|
217
|
+
...(entry.title !== undefined && { title: entry.title }),
|
|
217
218
|
};
|
|
218
219
|
const written = await writeTemplateRow({
|
|
219
220
|
ctx: args.ctx,
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import {
|
|
3
|
+
type HandlerContext,
|
|
4
|
+
type SessionUser,
|
|
5
|
+
SYSTEM_TENANT_ID,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
8
|
+
import type { DocumentHtmlKind } from "../constants.ts";
|
|
9
|
+
import { type TemplateResourceRow, templateResourcesTable } from "../table.ts";
|
|
10
|
+
import { isHiddenSystemDraft, toWorkingRow } from "../template-draft.ts";
|
|
11
|
+
import { isSystemOnlyTemplateHiddenFrom } from "../template-system-only.ts";
|
|
12
|
+
import { parseStoredJson } from "./template-gates.ts";
|
|
13
|
+
|
|
14
|
+
export type DocumentBase = {
|
|
15
|
+
readonly scope: "tenant" | "system";
|
|
16
|
+
/** The stored row's own version, which counts every draft save. */
|
|
17
|
+
readonly version: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type LoadedDocument = {
|
|
21
|
+
readonly rowId: string;
|
|
22
|
+
readonly base: DocumentBase;
|
|
23
|
+
readonly status: string;
|
|
24
|
+
readonly content: string;
|
|
25
|
+
readonly meta: Readonly<Record<string, unknown>>;
|
|
26
|
+
readonly variableSchema: Readonly<Record<string, unknown>>;
|
|
27
|
+
readonly title: string | null;
|
|
28
|
+
readonly folder: string | null;
|
|
29
|
+
readonly parentTemplateId: string | null;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function recordOrEmpty(raw: string | null): Readonly<Record<string, unknown>> {
|
|
33
|
+
const parsed = parseStoredJson(raw);
|
|
34
|
+
return isPlainObject(parsed) ? parsed : {};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The version an editor works on: the caller's own row (its open draft if there is one),
|
|
38
|
+
// else the served system default. A system default as base is what a patch copies into a tenant draft.
|
|
39
|
+
export async function loadDocumentBase(
|
|
40
|
+
ctx: HandlerContext,
|
|
41
|
+
user: SessionUser,
|
|
42
|
+
where: { readonly slug: string; readonly kind: DocumentHtmlKind; readonly locale: string },
|
|
43
|
+
): Promise<LoadedDocument | null> {
|
|
44
|
+
const own = await fetchOne<TemplateResourceRow>(ctx.db, templateResourcesTable, {
|
|
45
|
+
tenantId: user.tenantId,
|
|
46
|
+
...where,
|
|
47
|
+
});
|
|
48
|
+
if (own !== undefined && !isSystemOnlyTemplateHiddenFrom(user, ctx.registry, own)) {
|
|
49
|
+
const working = toWorkingRow(own, user.tenantId);
|
|
50
|
+
return {
|
|
51
|
+
rowId: String(own.id),
|
|
52
|
+
base: { scope: own.scope === "system" ? "system" : "tenant", version: own.version },
|
|
53
|
+
status: working.status,
|
|
54
|
+
content: working.content ?? "",
|
|
55
|
+
meta: recordOrEmpty(working.meta),
|
|
56
|
+
variableSchema: recordOrEmpty(working.variableSchema),
|
|
57
|
+
title: working.title,
|
|
58
|
+
folder: working.folder,
|
|
59
|
+
parentTemplateId: working.parentTemplateId,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const system = await fetchOne<TemplateResourceRow>(ctx.db, templateResourcesTable, {
|
|
63
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
64
|
+
scope: "system",
|
|
65
|
+
status: "active",
|
|
66
|
+
...where,
|
|
67
|
+
});
|
|
68
|
+
if (
|
|
69
|
+
system === undefined ||
|
|
70
|
+
isHiddenSystemDraft(user, system) ||
|
|
71
|
+
isSystemOnlyTemplateHiddenFrom(user, ctx.registry, system)
|
|
72
|
+
) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
rowId: String(system.id),
|
|
77
|
+
base: { scope: "system", version: system.version },
|
|
78
|
+
status: system.status,
|
|
79
|
+
content: system.content ?? "",
|
|
80
|
+
meta: recordOrEmpty(system.meta),
|
|
81
|
+
variableSchema: recordOrEmpty(system.variableSchema),
|
|
82
|
+
title: system.title,
|
|
83
|
+
folder: system.folder,
|
|
84
|
+
parentTemplateId: String(system.id),
|
|
85
|
+
};
|
|
86
|
+
}
|