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 `