diff --git a/.changeset/generate-block-types-tiptap-node-type.md b/.changeset/generate-block-types-tiptap-node-type.md
deleted file mode 100644
index 766295ec9b5..00000000000
--- a/.changeset/generate-block-types-tiptap-node-type.md
+++ /dev/null
@@ -1,18 +0,0 @@
----
-"@dextinity/cli": minor
----
-
-Add `TipTapNode` type to `generate-block-types` output and use it for TipTap rich text blocks
-
-TipTap rich text blocks were typed as `unknown`, forcing consumers to cast the content before rendering it.
-They are now typed as `TipTapNode`, which is generated into `blocks.generated.ts` (together with `TipTapMark`) whenever a TipTap rich text block is used.
-
-**Example**
-
-```tsx
-// Before
-const content = data.tipTapContent as TipTapNode;
-
-// After
-const content = data.tipTapContent;
-```
diff --git a/.changeset/oneof-block-input-meta-attached-blocks.md b/.changeset/oneof-block-input-meta-attached-blocks.md
new file mode 100644
index 00000000000..8bb8b32cf88
--- /dev/null
+++ b/.changeset/oneof-block-input-meta-attached-blocks.md
@@ -0,0 +1,7 @@
+---
+"@dextinity/cms-api": patch
+---
+
+Include `attachedBlocks` in the input fields of the block meta of one-of blocks
+
+The block meta of blocks created with `createOneOfBlock` (and `createLinkBlock`) was missing the `attachedBlocks` field in `inputFields`, causing the generated block input types to only contain `activeType`.
diff --git a/.coderabbit.yml b/.coderabbit.yml
new file mode 100644
index 00000000000..65f3705b697
--- /dev/null
+++ b/.coderabbit.yml
@@ -0,0 +1,33 @@
+# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
+reviews:
+ auto_review:
+ enabled: false
+ high_level_summary_in_walkthrough: true
+ request_changes_workflow: true
+ # We don't use GitHub issues and CodeRabbit has no access to Jira, so there is never a linked issue to assess or relate.
+ assess_linked_issues: false
+ related_issues: false
+ pre_merge_checks:
+ override_requested_reviewers_only: true # author can't dismiss failing checks
+ issue_assessment:
+ mode: "off"
+ custom_checks:
+ - name: "Requires human review"
+ mode: "error"
+ instructions: |
+ FAIL if the PR touches one of the following:
+ - authentication
+ - authorization
+ - DB migrations
+ - CI workflow files
+ - secret or credential handling
+ - changes public API
+ - deletes tests
+ - coderabbit config
+
+ FAIL if the PR adds or modifies more than 300 lines of hand-written source.
+ When counting those lines, ignore deleted lines, and ignore changes to
+ tests (*.spec.*, *.test.*, __snapshots__/), docs (docs/, *.md,
+ .changeset/) and generated output (**/generated/, schema.gql,
+ block-meta.json, pnpm-lock.yaml).
+ Otherwise PASS.
diff --git a/.env b/.env
index bcff46c7c61..5f740d312c9 100644
--- a/.env
+++ b/.env
@@ -63,6 +63,11 @@ BLOB_STORAGE_DIRECTORY_PREFIX="dextinity-demo"
# mailer
MAILER_HOST=localhost
MAILER_PORT=1025
+MAILER_USER=
+MAILER_PASSWORD=
+# contact form inquiries, sent by the site (see the contact form route)
+CONTACT_FORM_FROM_EMAIL=
+CONTACT_FORM_TO_EMAIL=
# dam
DAM_SECRET=6a9e8a185b513363bc89ec0b96eed8f70c759bc86b97319f60365c4b7f8593dc
diff --git a/.gitignore b/.gitignore
index 5dd969355b3..0a4142a2b17 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,6 +19,7 @@ junit-storybook.xml
.context
.playwright
.playwright-mcp
+.playwright-cli/
# Agent files
.agents/skills/
diff --git a/.greptile/config.json b/.greptile/config.json
new file mode 100644
index 00000000000..c94d1345956
--- /dev/null
+++ b/.greptile/config.json
@@ -0,0 +1,5 @@
+{
+ "autoApprove": {
+ "enabled": true
+ }
+}
diff --git a/SECURITY.md b/SECURITY.md
index cc0c53e26bf..c0b8a9a5c29 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -5,11 +5,15 @@
The versions of the project that are currently supported with security updates.
| Version | Supported |
-|--------:|:--------------------------------------|
-| 9.x | :white_check_mark: |
+| ------: | :------------------------------------ |
+| ≥ 10.x | :white_check_mark: |
+| 9.x | :x: (use 10.x instead, see note) |
| 8.x | :white_check_mark: (until 2027-07-07) |
| < 8.x | :x: |
+> [!NOTE]
+> Version 9.x is not supported. Use 10.x instead, which is largely compatible with 9.x: every breaking change in 10.x is part of the COMET -> Dextinity rename. See the [migration guide from v9 to v10](https://cms-docs.dextinity.com/docs/migration-guide/migration-from-v9-to-v10).
+
## Reporting a vulnerability
You can report a vulnerability by contacting us via email at [security@vivid-planet.com](mailto:security@vivid-planet.com).
diff --git a/demo/admin/src/common/blocks/TipTapRichTextBlock.tsx b/demo/admin/src/common/blocks/TipTapRichTextBlock.tsx
index da4796bab5d..5372ea5512f 100644
--- a/demo/admin/src/common/blocks/TipTapRichTextBlock.tsx
+++ b/demo/admin/src/common/blocks/TipTapRichTextBlock.tsx
@@ -1,3 +1,4 @@
+import { RteHighlight, Tag } from "@dextinity/admin-icons";
import { createTipTapRichTextBlock } from "@dextinity/cms-admin";
import { ProductPriceBlock } from "@src/products/blocks/ProductPriceBlock";
import { ProductTeaserBlock } from "@src/products/blocks/ProductTeaserBlock";
@@ -66,12 +67,14 @@ export const TipTapRichTextBlock = createTipTapRichTextBlock({
{
name: "highlight",
label: ,
+ icon: RteHighlight,
element: (props: HTMLAttributes) => ,
},
{
name: "tag",
label: ,
appliesTo: ["paragraph"],
+ icon: Tag,
element: (props: HTMLAttributes) => (
),
diff --git a/demo/api/block-meta.json b/demo/api/block-meta.json
index 2c9aa2ec4a6..ce31c9df507 100644
--- a/demo/api/block-meta.json
+++ b/demo/api/block-meta.json
@@ -889,6 +889,29 @@
}
],
"inputFields": [
+ {
+ "name": "attachedBlocks",
+ "kind": "NestedObjectList",
+ "object": {
+ "fields": [
+ {
+ "name": "type",
+ "kind": "String",
+ "nullable": false
+ },
+ {
+ "name": "props",
+ "kind": "OneOfBlocks",
+ "blocks": {
+ "pixelImage": "PixelImage",
+ "svgImage": "SvgImage"
+ },
+ "nullable": false
+ }
+ ]
+ },
+ "nullable": false
+ },
{
"name": "activeType",
"kind": "String",
@@ -1651,6 +1674,33 @@
}
],
"inputFields": [
+ {
+ "name": "attachedBlocks",
+ "kind": "NestedObjectList",
+ "object": {
+ "fields": [
+ {
+ "name": "type",
+ "kind": "String",
+ "nullable": false
+ },
+ {
+ "name": "props",
+ "kind": "OneOfBlocks",
+ "blocks": {
+ "internal": "InternalLink",
+ "external": "ExternalLink",
+ "damFileDownload": "DamFileDownloadLink",
+ "email": "EmailLink",
+ "phone": "PhoneLink",
+ "news": "NewsLink"
+ },
+ "nullable": false
+ }
+ ]
+ },
+ "nullable": false
+ },
{
"name": "activeType",
"kind": "String",
@@ -1902,6 +1952,29 @@
}
],
"inputFields": [
+ {
+ "name": "attachedBlocks",
+ "kind": "NestedObjectList",
+ "object": {
+ "fields": [
+ {
+ "name": "type",
+ "kind": "String",
+ "nullable": false
+ },
+ {
+ "name": "props",
+ "kind": "OneOfBlocks",
+ "blocks": {
+ "external": "ExternalLink",
+ "phone": "PhoneLink"
+ },
+ "nullable": false
+ }
+ ]
+ },
+ "nullable": false
+ },
{
"name": "activeType",
"kind": "String",
@@ -2018,6 +2091,31 @@
}
],
"inputFields": [
+ {
+ "name": "attachedBlocks",
+ "kind": "NestedObjectList",
+ "object": {
+ "fields": [
+ {
+ "name": "type",
+ "kind": "String",
+ "nullable": false
+ },
+ {
+ "name": "props",
+ "kind": "OneOfBlocks",
+ "blocks": {
+ "image": "DamImage",
+ "damVideo": "DamVideo",
+ "youTubeVideo": "YouTubeVideo",
+ "vimeoVideo": "VimeoVideo"
+ },
+ "nullable": false
+ }
+ ]
+ },
+ "nullable": false
+ },
{
"name": "activeType",
"kind": "String",
@@ -2978,6 +3076,30 @@
}
],
"inputFields": [
+ {
+ "name": "attachedBlocks",
+ "kind": "NestedObjectList",
+ "object": {
+ "fields": [
+ {
+ "name": "type",
+ "kind": "String",
+ "nullable": false
+ },
+ {
+ "name": "props",
+ "kind": "OneOfBlocks",
+ "blocks": {
+ "internal": "InternalLink",
+ "external": "ExternalLink",
+ "news": "NewsLink"
+ },
+ "nullable": false
+ }
+ ]
+ },
+ "nullable": false
+ },
{
"name": "activeType",
"kind": "String",
diff --git a/demo/api/schema.gql b/demo/api/schema.gql
index d62c00bff63..d2bd29e103c 100644
--- a/demo/api/schema.gql
+++ b/demo/api/schema.gql
@@ -2310,12 +2310,12 @@ enum UserPermissionSource {
}
type UserPermissionsUser {
- contentScopesCount: Int!
+ contentScopesCount: Int! @deprecated(reason: "The Scopes column was removed from the users list. Will be removed in the next version.")
email: String!
id: String!
impersonationAllowed: Boolean!
name: String!
- permissionsCount: Int!
+ permissionsCount: Int! @deprecated(reason: "The Permissions column was removed from the users list. Will be removed in the next version.")
}
input UserPermissionsUserFilter {
diff --git a/demo/api/src/app.module.ts b/demo/api/src/app.module.ts
index dc0b810b62e..c9bb3995bd7 100644
--- a/demo/api/src/app.module.ts
+++ b/demo/api/src/app.module.ts
@@ -126,6 +126,12 @@ export class AppModule {
label: { domain: siteConfig.name },
})),
),
+ availableContentScopeDimensions: [
+ { name: "domain", label: "Domain (Website)" },
+ { name: "language", label: "Language" },
+ // "product" is declared here so it shows up in the admin panel although it is not part of availableContentScopes
+ { name: "product", label: "Product Category" },
+ ],
userService,
accessControlService,
systemUsers: [SYSTEM_USER_NAME],
diff --git a/demo/api/src/auth/access-control.service.spec.ts b/demo/api/src/auth/access-control.service.spec.ts
index 86e29e21988..c37d47b3cf8 100644
--- a/demo/api/src/auth/access-control.service.spec.ts
+++ b/demo/api/src/auth/access-control.service.spec.ts
@@ -61,7 +61,7 @@ describe("AccessControlService", () => {
const contentScopes = service.getContentScopesForUser(nonAdminUser);
- expect(contentScopes).toEqual([{ domain: "main", language: "en" }]);
+ expect(contentScopes).toEqual([{ domain: "main", language: "*", product: "*" }]);
});
it("should return limited content scopes for unknown non-admin user", () => {
@@ -74,7 +74,7 @@ describe("AccessControlService", () => {
const contentScopes = service.getContentScopesForUser(unknownUser);
- expect(contentScopes).toEqual([{ domain: "main", language: "en" }]);
+ expect(contentScopes).toEqual([{ domain: "main", language: "*", product: "*" }]);
});
});
});
diff --git a/demo/api/src/auth/access-control.service.ts b/demo/api/src/auth/access-control.service.ts
index 735df04582e..bc00170fd62 100644
--- a/demo/api/src/auth/access-control.service.ts
+++ b/demo/api/src/auth/access-control.service.ts
@@ -15,7 +15,8 @@ export class AccessControlService extends AbstractAccessControlService {
if (user.isAdmin) {
return UserPermissions.allContentScopes;
} else {
- return [{ domain: "main", language: "en" }];
+ // Grant access to every language and product within the "main" domain using wildcard dimensions
+ return [{ domain: "main", language: "*", product: "*" }];
}
}
}
diff --git a/demo/api/src/content-scope/content-scope.interface.ts b/demo/api/src/content-scope/content-scope.interface.ts
index c76491ee696..4ebbf77d9b3 100644
--- a/demo/api/src/content-scope/content-scope.interface.ts
+++ b/demo/api/src/content-scope/content-scope.interface.ts
@@ -3,6 +3,8 @@ import { ContentScope } from "@dextinity/cms-api";
import type { ContentScope as BaseContentScope } from "@src/site-configs";
declare module "@dextinity/cms-api" {
- // eslint-disable-next-line @typescript-eslint/no-empty-object-type
- interface ContentScope extends BaseContentScope {}
+ interface ContentScope extends BaseContentScope {
+ // Optional dimension used only by certain resolvers. Not part of `availableContentScopes` as there can be thousands of product ids.
+ product?: string;
+ }
}
diff --git a/demo/api/src/page-tree/dto/page-tree-node-scope.ts b/demo/api/src/page-tree/dto/page-tree-node-scope.ts
index 644c478a6e6..1b97d30afb0 100644
--- a/demo/api/src/page-tree/dto/page-tree-node-scope.ts
+++ b/demo/api/src/page-tree/dto/page-tree-node-scope.ts
@@ -3,9 +3,8 @@ import { Field, InputType, ObjectType } from "@nestjs/graphql";
import { IsString } from "class-validator";
@Embeddable()
-@ObjectType("PageTreeNodeScope") // name must not be changed in the app
-@InputType("PageTreeNodeScopeInput") // name must not be changed in the app
-// @TODO: disguise @ObjectType("PageTreeContentScope") and @InputType("PageTreeContentScopeInput") decorators under a custom decorator: f.i. @PageTreeNodeScope
+@ObjectType("PageTreeNodeScope")
+@InputType("PageTreeNodeScopeInput")
export class PageTreeNodeScope {
@Property({ columnType: "text" })
@Field()
diff --git a/demo/api/vitest.config.ts b/demo/api/vitest.config.ts
index 93633b4b13c..a312545ec64 100644
--- a/demo/api/vitest.config.ts
+++ b/demo/api/vitest.config.ts
@@ -6,6 +6,6 @@ export default defineConfig({
test: {
exclude: ["dist/**", "node_modules/**"],
reporters: ["default", "junit"],
- outputFile: { junit: "./junit.xml" },
+ outputFile: { junit: "./junit-unit.xml" },
},
});
diff --git a/demo/site-configs/main.ts b/demo/site-configs/main.ts
index f011a212182..6f0c4856136 100644
--- a/demo/site-configs/main.ts
+++ b/demo/site-configs/main.ts
@@ -20,6 +20,13 @@ export default ((env) => {
languages: ["en", "de"],
},
recaptchaSiteKey: process.env.RECAPTCHA_SITE_KEY ?? "",
+ organization: {
+ name: "Vivid Planet Software GmbH",
+ url: "https://www.vivid-planet.com",
+ logo: "/assets/dextinity-logo.svg",
+ sameAs: ["https://github.com/vivid-planet"],
+ description: "Vivid Planet Software GmbH develops Dextinity CMS, the open-source content management system.",
+ },
},
};
}) satisfies GetSiteConfig;
diff --git a/demo/site-configs/secondary.ts b/demo/site-configs/secondary.ts
index 9dc3ad06174..7bcc7dfe9d3 100644
--- a/demo/site-configs/secondary.ts
+++ b/demo/site-configs/secondary.ts
@@ -19,6 +19,9 @@ export default ((env) => {
languages: ["en", "de"],
},
recaptchaSiteKey: process.env.RECAPTCHA_SITE_KEY ?? "",
+ organization: {
+ name: "Dextinity Site Secondary",
+ },
},
};
}) satisfies GetSiteConfig;
diff --git a/demo/site-configs/site-configs.d.ts b/demo/site-configs/site-configs.d.ts
index ace286219f4..0f97246a296 100644
--- a/demo/site-configs/site-configs.d.ts
+++ b/demo/site-configs/site-configs.d.ts
@@ -12,6 +12,13 @@ export interface SiteConfig extends BaseSiteConfig {
languages: string[];
};
recaptchaSiteKey: string;
+ organization: {
+ name: string;
+ url?: string;
+ logo?: string;
+ sameAs?: string[];
+ description?: string;
+ };
};
}
diff --git a/demo/site/package.json b/demo/site/package.json
index 253b2b464ca..2ca3e9127d9 100644
--- a/demo/site/package.json
+++ b/demo/site/package.json
@@ -49,6 +49,7 @@
"lru-cache": "^11.5.1",
"mjml": "^4.18.0",
"next": "^16.2.10",
+ "nodemailer": "^10.0.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-focus-lock": "^2.13.7",
diff --git a/demo/site/src/app/[visibility]/[domain]/[language]/api/contact-form/route.tsx b/demo/site/src/app/[visibility]/[domain]/[language]/api/contact-form/route.tsx
index 425b155577d..3f5d478737e 100644
--- a/demo/site/src/app/[visibility]/[domain]/[language]/api/contact-form/route.tsx
+++ b/demo/site/src/app/[visibility]/[domain]/[language]/api/contact-form/route.tsx
@@ -1,16 +1,28 @@
+import { mailerTransport } from "@src/util/mailer";
import { assessRecaptchaToken } from "@src/util/recaptcha/assessRecaptchaToken";
import { getSiteConfigForDomain } from "@src/util/siteConfig";
import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod";
+const fromEmail = process.env.CONTACT_FORM_FROM_EMAIL;
+const toEmail = process.env.CONTACT_FORM_TO_EMAIL;
+
+if (!fromEmail) {
+ throw new Error("Missing CONTACT_FORM_FROM_EMAIL environment variable");
+}
+
+if (!toEmail) {
+ throw new Error("Missing CONTACT_FORM_TO_EMAIL environment variable");
+}
+
const queryValidationSchema = z.object({
name: z.string(),
company: z.string().optional(),
email: z.string().email(),
- phone: z.string().optional(),
+ phoneNumber: z.string().optional(),
subject: z.string(),
message: z.string(),
- privacyConsent: z.boolean(),
+ privacyConsent: z.literal(true),
recaptchaToken: z.string(),
attachments: z.array(z.uuid()).default([]),
});
@@ -47,7 +59,31 @@ export async function POST(request: NextRequest, context: RouteContext<"/[visibi
});
}
+ const { name, company, email, phoneNumber, subject, message, attachments } = validationResult.data;
+
+ const details = [
+ `Name: ${name}`,
+ company && `Company: ${company}`,
+ `Email: ${email}`,
+ phoneNumber && `Phone number: ${phoneNumber}`,
+ `Subject: ${subject}`,
+ attachments.length > 0 && `Attachments: ${attachments.join(", ")}`,
+ ].filter(Boolean);
+
try {
+ const { rejected } = await mailerTransport.sendMail({
+ from: fromEmail,
+ to: process.env.MAILER_SEND_ALL_MAILS_TO || toEmail,
+ replyTo: email,
+ subject: "Contact form inquiry",
+ text: `${details.join("\n")}\n\n${message}`,
+ });
+
+ if (rejected.length > 0) {
+ console.error("The mail server rejected the contact form inquiry for", rejected);
+ return NextResponse.json({ error: "Something went wrong processing the contact form" }, { status: 500 });
+ }
+
return NextResponse.json(
{ success: true },
{
diff --git a/demo/site/src/app/[visibility]/[domain]/[language]/layout.tsx b/demo/site/src/app/[visibility]/[domain]/[language]/layout.tsx
index a0c37b8576d..8a2f9e474e5 100644
--- a/demo/site/src/app/[visibility]/[domain]/[language]/layout.tsx
+++ b/demo/site/src/app/[visibility]/[domain]/[language]/layout.tsx
@@ -5,6 +5,7 @@ import { Header } from "@src/layout/header/Header";
import { headerFragment } from "@src/layout/header/Header.fragment";
import { TopNavigation } from "@src/layout/topNavigation/TopNavigation";
import { topMenuPageTreeNodeFragment } from "@src/layout/topNavigation/TopNavigation.fragment";
+import { OrganizationJsonLd } from "@src/organization/OrganizationJsonLd";
import { createGraphQLFetch } from "@src/util/graphQLClient";
import { IntlProvider } from "@src/util/IntlProvider";
import { loadMessages } from "@src/util/loadMessages";
@@ -63,6 +64,7 @@ export default async function Layout({ children, params }: LayoutProps<"/[visibi
return (
+
{children}
diff --git a/demo/site/src/organization/OrganizationJsonLd.tsx b/demo/site/src/organization/OrganizationJsonLd.tsx
new file mode 100644
index 00000000000..e7bda319812
--- /dev/null
+++ b/demo/site/src/organization/OrganizationJsonLd.tsx
@@ -0,0 +1,27 @@
+import { JsonLd } from "@dextinity/site-nextjs";
+import type { PublicSiteConfig } from "@src/site-configs";
+import type { Organization, WithContext } from "schema-dts";
+
+interface Props {
+ siteConfig: PublicSiteConfig;
+}
+
+function toAbsoluteUrl(url: string, siteUrl: string): string {
+ return new URL(url, siteUrl).toString();
+}
+
+export function OrganizationJsonLd({ siteConfig }: Props) {
+ const { organization, url: siteUrl } = siteConfig;
+
+ const data: WithContext = {
+ "@context": "https://schema.org",
+ "@type": "Organization",
+ name: organization.name,
+ url: organization.url ?? siteUrl,
+ ...(organization.logo ? { logo: toAbsoluteUrl(organization.logo, siteUrl) } : {}),
+ ...(organization.sameAs?.length ? { sameAs: organization.sameAs } : {}),
+ ...(organization.description ? { description: organization.description } : {}),
+ };
+
+ return data={data} />;
+}
diff --git a/demo/site/src/util/mailer.ts b/demo/site/src/util/mailer.ts
new file mode 100644
index 00000000000..35ef2d3b3a9
--- /dev/null
+++ b/demo/site/src/util/mailer.ts
@@ -0,0 +1,16 @@
+import { createTransport } from "nodemailer";
+
+if (!process.env.MAILER_HOST) {
+ throw new Error("Missing MAILER_HOST environment variable");
+}
+
+const port = process.env.MAILER_PORT ? parseInt(process.env.MAILER_PORT, 10) : 587;
+const user = process.env.MAILER_USER;
+
+export const mailerTransport = createTransport({
+ host: process.env.MAILER_HOST,
+ port,
+ secure: port === 465,
+ requireTLS: port !== 465 && Boolean(user),
+ auth: user ? { user, pass: process.env.MAILER_PASSWORD } : undefined,
+});
diff --git a/dev-pm.config.ts b/dev-pm.config.ts
index 39065d5cb21..e62503e23cd 100644
--- a/dev-pm.config.ts
+++ b/dev-pm.config.ts
@@ -137,7 +137,7 @@ export default defineConfig({
{
name: "mail-react-storybook",
script: "pnpm --filter @dextinity/mail-react run storybook",
- group: ["mail-react"],
+ group: ["mail-react", "storybook", "docs"],
},
//group brevo
@@ -270,7 +270,7 @@ export default defineConfig({
name: "storybook",
script: "pnpm --filter dextinity-storybook run storybook",
group: ["storybook", "docs"],
- waitOn: ["tcp:26646", "tcp:26647"], // storybook-dextinity-admin, storybook-dextinity-cms-admin
+ waitOn: ["tcp:26646", "tcp:26647", "tcp:6066"], // storybook-dextinity-admin, storybook-dextinity-cms-admin, mail-react-storybook
},
{
name: "docs",
diff --git a/docs/docs/2-core-concepts/2-blocks/4-factories.mdx b/docs/docs/2-core-concepts/2-blocks/4-factories.mdx
index c96af6152e3..d4bdcefb037 100644
--- a/docs/docs/2-core-concepts/2-blocks/4-factories.mdx
+++ b/docs/docs/2-core-concepts/2-blocks/4-factories.mdx
@@ -227,6 +227,31 @@ export const FullWidthImageBlock = createCompositeBlock({
});
```
+## DamVideoBlock (Admin only)
+
+The DamVideoBlock factory creates a block for a video from the DAM.
+Use `supports` to restrict what editors can set besides the video file itself:
+
+- `"controls"`: The playback options autoplay, loop and show controls.
+- `"previewImage"`: The poster image shown before playback.
+
+Both are supported by default.
+`@dextinity/cms-admin` exports a ready-made `DamVideoBlock` created with those defaults, use the `createDamVideoBlock` factory to change them:
+
+```tsx title="DamVideoBlock.tsx"
+import { createDamVideoBlock } from "@dextinity/cms-admin";
+
+// For a site that renders no poster image
+export const DamVideoBlock = createDamVideoBlock({ supports: ["controls"] });
+```
+
+:::note
+
+Leaving out an option only hides it from the editor.
+Values that are already stored are kept as they are.
+The preview image in particular stays part of the block's data either way, since the API's child block is non-nullable.
+:::
+
## FinalFormBlock (Admin only)
The FinalFormBlock factory can be used to convert a block's `AdminComponent` API to the Final Form `Field` API.
diff --git a/docs/docs/2-core-concepts/2-blocks/tiptap-rich-text-block.mdx b/docs/docs/2-core-concepts/2-blocks/tiptap-rich-text-block.mdx
index 4d74e511337..27d7e72e2d8 100644
--- a/docs/docs/2-core-concepts/2-blocks/tiptap-rich-text-block.mdx
+++ b/docs/docs/2-core-concepts/2-blocks/tiptap-rich-text-block.mdx
@@ -46,21 +46,21 @@ The TipTap Rich Text Block only replaces the Draft.js-based `RichTextBlock`. It
Most features of the Draft.js `RichTextBlock` have a direct equivalent in the TipTap Rich Text Block.
-| Draft.js `RichTextBlock` | TipTap Rich Text Block |
-| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
-| `createRichTextBlock` | `createTipTapRichTextBlock` |
-| `rte.supports` (`SupportedThings[]`) | `supports` (`TipTapSupports[]`) |
-| `bold`, `italic`, `strikethrough`, `sub`, `sup` | `bold`, `italic`, `strike`, `sub`, `sup` |
-| `header-one` … `header-six` | `heading` support + the [text-block-type select](#text-block-type-and-styling-selects) (Heading 1–6) |
-| `ordered-list`, `unordered-list` | `ordered-list`, `unordered-list` |
-| `history` | `history` (Admin only) |
-| `link`, `links-remove` | pass a `link` block (automatically enables `link` support) |
-| `non-breaking-space`, `soft-hyphen` | `non-breaking-space`, `soft-hyphen` |
-| `rte.blocktypeMap` (custom block types) | [`textBlockStyles`](#text-block-type-and-styling-selects) + the styling select |
-| `rte.customInlineStyles` | [`inlineStyles`](#text-block-type-and-styling-selects) + the inline style select |
-| `rte.listLevelMax` | `listLevelMax` |
-| `rte.maxBlocks` | `maxTextBlocks` |
-| Site rendering with `redraft` + `Renderers` | Site rendering with `renderTipTapRichText` + `nodeMapping`/`markMapping` |
+| Draft.js `RichTextBlock` | TipTap Rich Text Block |
+| ----------------------------------------------- | -------------------------------------------------------------------------------------------- |
+| `createRichTextBlock` | `createTipTapRichTextBlock` |
+| `rte.supports` (`SupportedThings[]`) | one option per feature ([Features](#features)) |
+| `bold`, `italic`, `strikethrough`, `sub`, `sup` | `bold`, `italic`, `strike`, `sub`, `sup` |
+| `header-one` … `header-six` | `heading` + the [text-block-type select](#text-block-type-and-styling-selects) (Heading 1–6) |
+| `ordered-list`, `unordered-list` | `orderedList`, `unorderedList` |
+| `history` | `undoRedoButtons` (Admin only) |
+| `link`, `links-remove` | `link` (pass the link block) |
+| `non-breaking-space`, `soft-hyphen` | `nonBreakingSpace`, `softHyphen` |
+| `rte.blocktypeMap` (custom block types) | [`textBlockStyles`](#text-block-type-and-styling-selects) + the styling select |
+| `rte.customInlineStyles` | [`inlineStyles`](#text-block-type-and-styling-selects) + the inline style select |
+| `rte.listLevelMax` | `listLevelMax` |
+| `rte.maxBlocks` | `maxTextBlocks` |
+| Site rendering with `redraft` + `Renderers` | Site rendering with `renderTipTapRichText` + `nodeMapping`/`markMapping` |
### Setup
@@ -111,7 +111,6 @@ import {
type PropsWithData,
renderTipTapRichText,
type TipTapMarkHandler,
- type TipTapNode,
type TipTapNodeHandler,
} from "@dextinity/site-nextjs";
import { LinkBlockData, TipTapRichTextBlockData } from "@src/blocks.generated";
@@ -131,7 +130,7 @@ const markMapping: Record = {
};
export function TipTapRichTextBlock({ data }: PropsWithData) {
- const content = data.tipTapContent as TipTapNode;
+ const content = data.tipTapContent;
return (
` blocks for your base/desktop layout. Outlook and several other major clients strip or ignore them. Always set default styles inline via MJML component props.
:::
+MJML breaks this rule for column widths: it puts them in a `min-width` media query, so a multi-column section stacks in any client that drops that query — GMX and Web.de, for example. See [Column Widths Must Be Inline](./6-layout-patterns.md#column-widths-must-be-inline).
+
Media queries, registered via [`registerStyles`](./5-customization.md#registering-responsive-styles), serve as **progressive enhancement** for mobile and responsive behavior. Clients that support media queries also support modern CSS, so properties like `flex` and CSS custom properties are safe to use inside them. Because inline styles take precedence over `