diff --git a/.changeset/silver-bikes-live.md b/.changeset/silver-bikes-live.md new file mode 100644 index 00000000..42ed303d --- /dev/null +++ b/.changeset/silver-bikes-live.md @@ -0,0 +1,6 @@ +--- +"@snapwp/blocks": patch +"@snapwp/types": patch +--- + +feat: Add support for overloading Synced Pattern blocks. diff --git a/docs/overloading-wp-behavior.md b/docs/overloading-wp-behavior.md index bc85e6d7..fa49181e 100644 --- a/docs/overloading-wp-behavior.md +++ b/docs/overloading-wp-behavior.md @@ -307,3 +307,92 @@ export default function Page() { - **Local Styles**: Use CSS Modules (as shown with `styles.module.css`) or any other CSS-in-JS solution for component-specific styling. - **Global and Theme Styles**: Classes like `wp-block-heading`, `has-text-align-center`, and `has-x-large-font-size` (likely from your WordPress `theme.json` and/or global CSS) are automatically available. + +## Overloading Synced Patterns + +SnapWP allows you to customize **Synced Patterns** by mapping them to custom React components. This is useful for modifying the structure, design, or behavior of reusable block patterns while keeping WordPress as the content source. + +> [!TIP] +> Synced Patterns are reusable block patterns in WordPress that stay in sync across all instances. When you edit a synced pattern, all instances of that pattern are updated. + +### 1. Creating a Custom Component + +Create a new React component to modify the rendering of a specific Synced Pattern: + +```tsx +import React from 'react'; +import { BlockData, cn, getClassNamesFromString } from '@snapwp/core'; + +export default function MyCustomSyncedPattern( { + renderedHtml, + attributes, + children, +}: BlockData ) { + const safeAttributes = attributes || {}; // Ensure attributes are not undefined. + const { style } = safeAttributes; + + const classNamesFromString = renderedHtml + ? getClassNamesFromString( renderedHtml ) + : ''; + const classNames = cn( classNamesFromString ); + + return ( +
+ { /* Your custom rendering logic here */ } + { children } +
+ ); +} +``` + +### 2. Registering the Custom Component + +You can register your custom component either globally or per-route: + +#### Global Registration + +Add your custom component to the `blockDefinitions` in your `snapwp.config.ts`: + +```ts +import { defineConfig } from '@snapwp/config'; +import MyCustomSyncedPattern from './components/MyCustomSyncedPattern'; + +export default defineConfig( { + blockDefinitions: { + CoreSyncedPattern: MyCustomSyncedPattern, + }, +} ); +``` + +#### Per-Route Registration + +Override the component for specific routes: + +```tsx +import { EditorBlocksRenderer } from '@snapwp/blocks'; +import MyCustomSyncedPattern from './components/MyCustomSyncedPattern'; + +const pageBlockDefinitions = { + CoreSyncedPattern: MyCustomSyncedPattern, +}; + +export default function Page() { + return ( + + { ( editorBlocks ) => ( + + ) } + + ); +} +``` + +This allows you to apply the override only on specific routes while using the default block rendering elsewhere. + +Now, whenever a `core/synced-pattern` block is encountered, your `MyCustomSyncedPattern` component will be used to render it. Any other blocks will use the default rendering unless you provide a custom component in `blockDefinitions`. + +> [!TIP] +> If a block is overridden both globally (`snapwp.config.ts`) and per-route (`EditorBlocksRenderer` prop), the per-route override takes precedence. diff --git a/packages/blocks/README.md b/packages/blocks/README.md index 5176e9a8..9b75a271 100644 --- a/packages/blocks/README.md +++ b/packages/blocks/README.md @@ -49,37 +49,38 @@ const blockDefinitions = { These components provide developer-friendly APIs for rendering core WordPress blocks. If a WordPress block does not have a corresponding component, it will fallback to the `Default` block component, which uses `html-react-parser` under the hood. -| Type | Component | -| ------------------ | ---------------- | -| core-audio | CoreAudio | -| core-button | CoreButton | -| core-buttons | CoreButtons | -| core-code | CoreCode | -| core-column | CoreColumn | -| core-columns | CoreColumns | -| core-cover | CoreCover | -| core-details | CoreDetails | -| core-file | CoreFile | -| core-freeform | CoreFreeform | -| core-gallery | CoreGallery | -| core-group | CoreGroup | -| core-heading | CoreHeading | -| core-html | CoreHtml | -| core-image | CoreImage | -| core-list | CoreList | -| core-list-item | CoreListItem | -| core-media-text | CoreMediaText | -| core-paragraph | CoreParagraph | -| core-post-content | CorePostContent | -| core-preformatted | CorePreformatted | -| core-pullquote | CorePullquote | -| core-quote | CoreQuote | -| core-separator | CoreSeparator | -| core-spacer | CoreSpacer | -| core-template-part | CoreTemplatePart | -| core-verse | CoreVerse | -| core-video | CoreVideo | -| default | Default | +| Type | Component | +| ------------------- | ----------------- | +| core-audio | CoreAudio | +| core-button | CoreButton | +| core-buttons | CoreButtons | +| core-code | CoreCode | +| core-column | CoreColumn | +| core-columns | CoreColumns | +| core-cover | CoreCover | +| core-details | CoreDetails | +| core-file | CoreFile | +| core-freeform | CoreFreeform | +| core-gallery | CoreGallery | +| core-group | CoreGroup | +| core-heading | CoreHeading | +| core-html | CoreHtml | +| core-image | CoreImage | +| core-list | CoreList | +| core-list-item | CoreListItem | +| core-media-text | CoreMediaText | +| core-paragraph | CoreParagraph | +| core-post-content | CorePostContent | +| core-preformatted | CorePreformatted | +| core-pullquote | CorePullquote | +| core-quote | CoreQuote | +| core-separator | CoreSeparator | +| core-spacer | CoreSpacer | +| core-synced-pattern | CoreSyncedPattern | +| core-template-part | CoreTemplatePart | +| core-verse | CoreVerse | +| core-video | CoreVideo | +| default | Default | ## Known Limitations diff --git a/packages/blocks/src/blocks/core-synced-pattern.tsx b/packages/blocks/src/blocks/core-synced-pattern.tsx new file mode 100644 index 00000000..9ef0026c --- /dev/null +++ b/packages/blocks/src/blocks/core-synced-pattern.tsx @@ -0,0 +1,46 @@ +import { + cn, + getClassNamesFromString, + getStylesFromAttributes, +} from '@snapwp/core'; +import type { + CoreSyncedPattern as CoreSyncedPatternType, + CoreSyncedPatternProps, +} from '@snapwp/types'; +import type { ReactNode } from 'react'; + +/** + * Renders the core/synced-pattern block. + * + * @param {Object} props The props for the block component. + * @param {CoreSyncedPatternProps['attributes']} props.attributes Block attributes. + * @param {ReactNode} props.children The block's children. + * @param {CoreSyncedPatternProps['renderedHtml']} props.renderedHtml The block's rendered HTML. + * + * @return The rendered block. + */ +export const CoreSyncedPattern: CoreSyncedPatternType = ( { + attributes, + children, + renderedHtml, +}: CoreSyncedPatternProps ): ReactNode => { + const { style } = attributes || {}; + const styleObject = getStylesFromAttributes( { style } ); + + /** + * @todo replace with cssClassName once it's supported. + */ + const classNamesFromString = renderedHtml + ? getClassNamesFromString( renderedHtml ) + : ''; + const classNames = cn( classNamesFromString ); + + return ( +
+ { children } +
+ ); +}; diff --git a/packages/blocks/src/blocks/index.ts b/packages/blocks/src/blocks/index.ts index 20fadcb0..80d73898 100644 --- a/packages/blocks/src/blocks/index.ts +++ b/packages/blocks/src/blocks/index.ts @@ -24,6 +24,7 @@ import { CorePullquote } from './core-pullquote'; import { CoreQuote } from './core-quote'; import { CoreSeparator } from './core-separator'; import { CoreSpacer } from './core-spacer'; +import { CoreSyncedPattern } from './core-synced-pattern'; import { CoreTemplatePart } from './core-template-part'; import { CoreVerse } from './core-verse'; import { CoreVideo } from './core-video'; @@ -58,6 +59,7 @@ export const blocks: BlockDefinitions = { CoreQuote, CoreSeparator, CoreSpacer, + CoreSyncedPattern, CoreTemplatePart, CoreVerse, CoreVideo, diff --git a/packages/blocks/src/blocks/tests/core-synced-pattern.test.tsx b/packages/blocks/src/blocks/tests/core-synced-pattern.test.tsx new file mode 100644 index 00000000..7a511f48 --- /dev/null +++ b/packages/blocks/src/blocks/tests/core-synced-pattern.test.tsx @@ -0,0 +1,81 @@ +import { render } from '@testing-library/react'; + +import { CoreSyncedPattern } from '../core-synced-pattern'; + +describe( 'CoreSyncedPattern Component', () => { + it( 'renders children correctly', () => { + const { container } = render( + +
Test Child
+
+ ); + + expect( container ).toHaveTextContent( 'Test Child' ); + } ); + + it( 'applies className from renderedHtml', () => { + const renderedHtml = '
'; + const { container } = render( + +
Test Child
+
+ ); + + const wrapper = container.firstChild; + expect( wrapper ).toHaveClass( 'test-class', 'another-class' ); + } ); + + it( 'applies style from attributes', () => { + const style = JSON.stringify( { color: 'red', padding: '10px' } ); + const { container } = render( + +
Test Child
+
+ ); + + const wrapper = container.firstChild; + expect( wrapper ).toHaveStyle( { + color: 'red', + padding: '10px', + } ); + } ); + + it( 'handles empty attributes gracefully', () => { + const { container } = render( + //@ts-ignore to test undefined props + +
Test Child
+
+ ); + + expect( container ).toHaveTextContent( 'Test Child' ); + } ); + + it( 'handles empty renderedHtml gracefully', () => { + const { container } = render( + // @ts-ignore to test undefined props + +
Test Child
+
+ ); + + expect( container ).toHaveTextContent( 'Test Child' ); + } ); + + it( 'combines className and style correctly', () => { + const renderedHtml = '
'; + const style = JSON.stringify( { color: 'blue' } ); + const { container } = render( + +
Test Child
+
+ ); + + const wrapper = container.firstChild; + expect( wrapper ).toHaveClass( 'test-class' ); + expect( wrapper ).toHaveStyle( { color: 'blue' } ); + } ); +} ); diff --git a/packages/types/src/blocks/props/core-synced-pattern.ts b/packages/types/src/blocks/props/core-synced-pattern.ts new file mode 100644 index 00000000..2ad2d83a --- /dev/null +++ b/packages/types/src/blocks/props/core-synced-pattern.ts @@ -0,0 +1,10 @@ +import type { BaseProps } from '../base'; +import type { ComponentType, PropsWithChildren } from 'react'; + +export type CoreSyncedPatternProps = PropsWithChildren< + BaseProps< { + style?: string; + } > +>; + +export type CoreSyncedPattern = ComponentType< CoreSyncedPatternProps >; diff --git a/packages/types/src/blocks/props/index.ts b/packages/types/src/blocks/props/index.ts index aec869fd..fad209a9 100644 --- a/packages/types/src/blocks/props/index.ts +++ b/packages/types/src/blocks/props/index.ts @@ -27,4 +27,5 @@ export * from './core-spacer'; export * from './core-template-part'; export * from './core-verse'; export * from './core-video'; +export * from './core-synced-pattern'; export * from './default';