@cosmicdrift/kumiko-bundled-features 0.182.0 → 0.183.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.182.0",
3
+ "version": "0.183.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -121,12 +121,12 @@
121
121
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
122
122
  },
123
123
  "dependencies": {
124
- "@cosmicdrift/kumiko-dispatcher-live": "0.182.0",
125
- "@cosmicdrift/kumiko-framework": "0.182.0",
126
- "@cosmicdrift/kumiko-headless": "0.182.0",
127
- "@cosmicdrift/kumiko-renderer": "0.182.0",
128
- "@cosmicdrift/kumiko-renderer-web": "0.182.0",
129
- "@cosmicdrift/kumiko-types": "0.182.0",
124
+ "@cosmicdrift/kumiko-dispatcher-live": "0.183.0",
125
+ "@cosmicdrift/kumiko-framework": "0.183.0",
126
+ "@cosmicdrift/kumiko-headless": "0.183.0",
127
+ "@cosmicdrift/kumiko-renderer": "0.183.0",
128
+ "@cosmicdrift/kumiko-renderer-web": "0.183.0",
129
+ "@cosmicdrift/kumiko-types": "0.183.0",
130
130
  "@mollie/api-client": "^4.5.0",
131
131
  "imapflow": "^1.3.3",
132
132
  "mailparser": "^3.9.8",
@@ -42,7 +42,14 @@ export function createTemplateResolverFeature(opts: TemplateResolverOptions = {}
42
42
  const hasUserOwned = collections.some((c) => c.ownership === "user");
43
43
  return defineFeature("template-resolver", (r) => {
44
44
  r.describe(
45
- "The one content store: notification and mail templates, PDF document templates, AI prompts and plain editable text blocks all live here as one entity, distinguished by `kind` (`notification`, `mail-html`, `document-pdf`, `ai-prompt`, `text-block`, `image-snapshot`). Resolution uses a 4-level fallback: tenant+locale \u2192 system+locale \u2192 tenant+fallback-locale \u2192 system+fallback-locale, so tenants can override system defaults without touching application code. Call `ctx.templateResolver.resolveTemplate({ tenantId, slug, kind, locale })` at render time; manage templates via the `upsertSystem`, `upsertTenant`, `publish` and `archive` write handlers. Apps that want an editable collection in their navigation declare it at mount: `createTemplateResolverFeature({ collections: [{ id, kind, access: { roles }, nav }] })` \u2014 `access` belongs to the mount because a bundled feature does not know the host's role vocabulary. Each collection gets its own `<id>-list` / `<id>-item` / `<id>-set` handlers carrying that collection's access rule, so the dispatcher enforces the separation. A collection is either tenant-wide (default) or `ownership: \"user\"`, where every user keeps their own entries (mail signatures) in the separate `user-content-entry` entity — that one carries `userOwned` content, so mounting it also requires the `template-resolver-user-data` feature and an app-side migration. Replaces the former `text-content` feature, whose blocks now live here as kind `text-block`.",
45
+ [
46
+ "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-html`, `document-pdf`, `ai-prompt`, `text-block`, `image-snapshot`).",
47
+ "Reading is one call — `ctx.templateResolver.resolveTemplate({ tenantId, slug, kind, locale })`. It walks four levels: tenant+locale, system+locale, tenant+fallback-locale, system+fallback-locale. A tenant overrides a system default by simply having its own record; no application code changes.",
48
+ "Text an editor should be able to change belongs in a collection, declared at mount: `createTemplateResolverFeature({ collections: [{ id, kind, access: { roles }, nav }] })`. It appears in the navigation, and `access` is part of the mount because a bundled feature cannot know the host's roles. Each collection gets its own `<id>-list` / `<id>-item` / `<id>-set` handlers, so the dispatcher enforces the separation.",
49
+ "How a collection is edited follows from `contentFormat`: `plain` gives a text area, `rich` a small WYSIWYG (bold, italic, headings, lists, links). 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 and wins over the built-in one.",
50
+ 'A collection is tenant-wide by default. With `ownership: "user"` every user keeps their own entries — mail signatures being the obvious case. Those rows live in the separate `user-content-entry` entity and count as user data, so mounting one also requires the `template-resolver-user-data` feature and a migration on the app side.',
51
+ "Replaces the former `text-content` feature; its blocks now live here as kind `text-block`.",
52
+ ].join("\n\n"),
46
53
  );
47
54
  r.uiHints({
48
55
  displayLabel: "Template Resolver",
@@ -19,6 +19,7 @@ import type {
19
19
  } from "@cosmicdrift/kumiko-framework/engine";
20
20
  import {
21
21
  CONTENT_EDITOR_ELEMENT_ID,
22
+ ContentPreview,
22
23
  type FeatureSchema,
23
24
  useAppFeatures,
24
25
  useContentEditor,
@@ -27,7 +28,11 @@ import {
27
28
  useQuery,
28
29
  useTranslation,
29
30
  } from "@cosmicdrift/kumiko-renderer";
30
- import { type ClientFeatureDefinition, PlainContentEditor } from "@cosmicdrift/kumiko-renderer-web";
31
+ import {
32
+ type ClientFeatureDefinition,
33
+ PlainContentEditor,
34
+ RichContentEditor,
35
+ } from "@cosmicdrift/kumiko-renderer-web";
31
36
  import { type FormEvent, type ReactNode, useEffect, useState } from "react";
32
37
  import {
33
38
  collectionHandlerName,
@@ -224,12 +229,21 @@ function findVariableSchema(
224
229
  features: readonly FeatureSchema[],
225
230
  collectionId?: string,
226
231
  ): readonly string[] {
227
- if (collectionId === undefined) return [];
232
+ return Object.keys(findVariableExamples(features, collectionId));
233
+ }
234
+
235
+ // Same lookup, for the example values the Preview substitutes in for
236
+ // `{{name}}` — the values half of the same variableSchema map.
237
+ function findVariableExamples(
238
+ features: readonly FeatureSchema[],
239
+ collectionId?: string,
240
+ ): Readonly<Record<string, string>> {
241
+ if (collectionId === undefined) return {};
228
242
  for (const feature of features) {
229
243
  const match = feature.contentCollections?.find((c) => c.id === collectionId);
230
- if (match !== undefined) return Object.keys(match.variableSchema ?? {});
244
+ if (match !== undefined) return match.variableSchema ?? {};
231
245
  }
232
- return [];
246
+ return {};
233
247
  }
234
248
 
235
249
  // Edit form: loads the current values via by-slug, lets TenantAdmin and
@@ -278,6 +292,7 @@ function TextBlockEditor({
278
292
  const features = useAppFeatures();
279
293
  const contentFormat = findContentFormat(features, collectionId);
280
294
  const variables = findVariableSchema(features, collectionId);
295
+ const variableExamples = findVariableExamples(features, collectionId);
281
296
  const ContentEditor = useContentEditor(contentFormat);
282
297
  const canWrite =
283
298
  user?.roles.includes("TenantAdmin") === true || user?.roles.includes("SystemAdmin") === true;
@@ -302,6 +317,7 @@ function TextBlockEditor({
302
317
 
303
318
  const [title, setTitle] = useState("");
304
319
  const [content, setContent] = useState("");
320
+ const [previewing, setPreviewing] = useState(false);
305
321
  const [submitting, setSubmitting] = useState(false);
306
322
  const [saveError, setSaveError] = useState<string | null>(null);
307
323
  const [savedMsg, setSavedMsg] = useState<string | null>(null);
@@ -390,13 +406,34 @@ function TextBlockEditor({
390
406
  required
391
407
  />
392
408
  </Field>
393
- <Field id={CONTENT_EDITOR_ELEMENT_ID} label={t("template-resolver.editor.contentLabel")}>
394
- <ContentEditor
395
- value={content}
396
- onChange={setContent}
397
- variables={variables}
398
- readOnly={disabled}
399
- />
409
+ <Field
410
+ id={CONTENT_EDITOR_ELEMENT_ID}
411
+ label={t("template-resolver.editor.contentLabel")}
412
+ labelAppendix={
413
+ <Button
414
+ type="button"
415
+ variant="secondary"
416
+ size="sm"
417
+ onClick={() => setPreviewing((p) => !p)}
418
+ >
419
+ {previewing ? t("kumiko.contentEditor.editMode") : t("kumiko.contentEditor.preview")}
420
+ </Button>
421
+ }
422
+ >
423
+ {previewing ? (
424
+ <ContentPreview
425
+ content={content}
426
+ variables={variableExamples}
427
+ contentFormat={contentFormat}
428
+ />
429
+ ) : (
430
+ <ContentEditor
431
+ value={content}
432
+ onChange={setContent}
433
+ variables={variables}
434
+ readOnly={disabled}
435
+ />
436
+ )}
400
437
  </Field>
401
438
  {saveError !== null && <Banner variant="error">{saveError}</Banner>}
402
439
  {savedMsg !== null && <Banner variant="info">{savedMsg}</Banner>}
@@ -447,6 +484,7 @@ export function textBlocksClient(opts?: {
447
484
  // another clientFeature still wins, last-wins same as columnRenderers.
448
485
  contentEditors: {
449
486
  plain: PlainContentEditor,
487
+ rich: RichContentEditor,
450
488
  // ponytail: same textarea+chips as "plain" — markdown is stored as
451
489
  // text either way. Shares PlainContentEditor's fixed-DOM-id ceiling
452
490
  // (see its own file comment); swap for a live-preview widget or a