Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/silver-bikes-live.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@snapwp/blocks": patch
"@snapwp/types": patch
---

feat: Add support for overloading Synced Pattern blocks.
89 changes: 89 additions & 0 deletions docs/overloading-wp-behavior.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className={ classNames } style={ style }>
{ /* Your custom rendering logic here */ }
{ children }
</div>
);
}
```

### 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 (
<TemplateRenderer>
{ ( editorBlocks ) => (
<EditorBlocksRenderer
editorBlocks={ editorBlocks }
blockDefinitions={ pageBlockDefinitions }
/>
) }
</TemplateRenderer>
);
}
```

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.
63 changes: 32 additions & 31 deletions packages/blocks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
46 changes: 46 additions & 0 deletions packages/blocks/src/blocks/core-synced-pattern.tsx
Original file line number Diff line number Diff line change
@@ -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 } );
Copy link

Copilot AI May 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 'style' attribute is defined as a string in the type definitions but later processed via getStylesFromAttributes. Consider clarifying the expected format and ensuring consistency with usage examples and documentation.

Suggested change
const styleObject = getStylesFromAttributes( { style } );
// Ensure the style attribute is a valid CSS string or object
const validatedStyle = typeof style === 'string' ? style : '';
const styleObject = getStylesFromAttributes( { style: validatedStyle } );

Copilot uses AI. Check for mistakes.

/**
* @todo replace with cssClassName once it's supported.
*/
const classNamesFromString = renderedHtml
? getClassNamesFromString( renderedHtml )
: '';
const classNames = cn( classNamesFromString );

return (
<div
className={ classNames }
{ ...( styleObject && { style: styleObject } ) }
>
{ children }
</div>
);
};
2 changes: 2 additions & 0 deletions packages/blocks/src/blocks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -58,6 +59,7 @@ export const blocks: BlockDefinitions = {
CoreQuote,
CoreSeparator,
CoreSpacer,
CoreSyncedPattern,
CoreTemplatePart,
CoreVerse,
CoreVideo,
Expand Down
81 changes: 81 additions & 0 deletions packages/blocks/src/blocks/tests/core-synced-pattern.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<CoreSyncedPattern>
<div>Test Child</div>
</CoreSyncedPattern>
);

expect( container ).toHaveTextContent( 'Test Child' );
} );

it( 'applies className from renderedHtml', () => {
const renderedHtml = '<div class="test-class another-class"></div>';
const { container } = render(
<CoreSyncedPattern renderedHtml={ renderedHtml }>
<div>Test Child</div>
</CoreSyncedPattern>
);

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(
<CoreSyncedPattern attributes={ { style } }>
<div>Test Child</div>
</CoreSyncedPattern>
);

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
<CoreSyncedPattern attributes={ undefined }>
<div>Test Child</div>
</CoreSyncedPattern>
);

expect( container ).toHaveTextContent( 'Test Child' );
} );

it( 'handles empty renderedHtml gracefully', () => {
const { container } = render(
// @ts-ignore to test undefined props
<CoreSyncedPattern renderedHtml="">
<div>Test Child</div>
</CoreSyncedPattern>
);

expect( container ).toHaveTextContent( 'Test Child' );
} );

it( 'combines className and style correctly', () => {
const renderedHtml = '<div class="test-class"></div>';
const style = JSON.stringify( { color: 'blue' } );
const { container } = render(
<CoreSyncedPattern
renderedHtml={ renderedHtml }
attributes={ { style } }
>
<div>Test Child</div>
</CoreSyncedPattern>
);

const wrapper = container.firstChild;
expect( wrapper ).toHaveClass( 'test-class' );
expect( wrapper ).toHaveStyle( { color: 'blue' } );
} );
} );
10 changes: 10 additions & 0 deletions packages/types/src/blocks/props/core-synced-pattern.ts
Original file line number Diff line number Diff line change
@@ -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 >;
1 change: 1 addition & 0 deletions packages/types/src/blocks/props/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';