Dynamiczny blok Gutenberga z block.json i render.php

Management Summary
Dynamiczny blok Gutenberga to wielokrotnie używany element treści WordPressa, którego wygląd powstaje z zapisanych ustawień. W przykładzie wybór kategorii i liczby tworzy listę najnowszych opublikowanych wpisów. Nowe artykuły mogą pojawiać się bez ręcznej edycji każdej strony z listą, ograniczając pracę redakcji. Lista zależy jednak od aktywnej wtyczki, a zapisane kopie stron mogą opóźniać widoczne zmiany. Automatyczna treść nadal wymaga właściwej prezentacji i obsługi języka.
Spis treści
Dlaczego listę tworzy PHP
Lista najnowszych wpisów może się zmienić bez edycji wpisu zawierającego listę. Blok dynamiczny tworzy HTML na serwerze. Ten przykład zapisuje tylko kategorię i liczbę wpisów; PHP wyszukuje pasujące opublikowane wpisy i tworzy odnośniki. Bloki dynamiczne mogą też zapisywać zastępczy HTML. Tutaj celowo go nie ma: Po wyłączeniu wtyczki wynik pozostaje pusty.
1. Szkielet i kompilacja
Przykład korzysta z ustalonych wersji narzędzi: create-block 4.99.0 i @wordpress/scripts 37.0.0. Odpowiednia jest na przykład wersja Node.js 22.23.3; scripts 37 wymaga co najmniej 22.22.2 w gałęzi 22.x. Polecenie node –version pokazuje zainstalowaną wersję. Poniższe polecenia tworzą szkielet. Po zastąpieniu plików źródłowych konieczne jest ponowne npm run build.
npx @wordpress/create-block@4.99.0 category-posts --variant dynamic --namespace lw --title "Category Posts"
cd category-posts
npm install --save-dev --save-exact @wordpress/scripts@37.0.0
npm run buildPliki źródłowe znajdują się w src/category-posts/. Poniższe przykłady zastępują block.json, index.js, edit.js, render.php i główny plik wtyczki. Wygenerowane pliki SCSS pozostają. view.js nie jest ładowany, ponieważ block.json nie zawiera wpisu viewScript.
category-posts/ (abridged)
├── category-posts.php plugin header and block registration
├── package.json build/start scripts with --blocks-manifest
├── readme.txt
├── src/
│ └── category-posts/
│ ├── block.json
│ ├── index.js
│ ├── edit.js
│ ├── render.php
│ ├── view.js not loaded by this example
│ ├── style.scss
│ └── editor.scss
└── build/ written by npm start / npm run build
├── blocks-manifest.php
└── category-posts/
├── block.json
├── index.js
├── index.asset.php
├── index.css
├── style-index.css
└── render.phpOba skrypty należą do istniejącej sekcji scripts w package.json; pozostałe ustawienia pakietu pozostają. –webpack-copy-php kopiuje pliki PHP, a –blocks-manifest tworzy wspólny plik metadanych. npm start ponawia kompilację po zmianach; npm run build tworzy wersję do publikacji.
{
"scripts": {
"build": "wp-scripts build --webpack-copy-php --blocks-manifest",
"start": "wp-scripts start --webpack-copy-php --blocks-manifest"
}
}2. Jeden opis dla edytora i serwera
block.json opisuje nazwę, atrybuty i pliki. Atrybuty to zapisane ustawienia: categoryId zawiera identyfikator kategorii, a count żądaną liczbę wpisów. Schemat sprawdza typ danych; PHP dodatkowo sprawdza dozwolone zakresy liczb. usesContext udostępnia identyfikator wpisu zawierającego blok, aby lista mogła pominąć ten wpis.
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "lw/category-posts",
"version": "0.1.0",
"title": "Category Posts",
"category": "widgets",
"icon": "list-view",
"description": "Lists the latest posts of one category, rendered on the server.",
"attributes": {
"categoryId": {
"type": "integer",
"default": 0
},
"count": {
"type": "integer",
"default": 5
}
},
"usesContext": [ "postId" ],
"supports": {
"html": false,
"align": [ "wide", "full" ],
"color": {
"background": true,
"text": true,
"link": true
},
"spacing": {
"margin": true,
"padding": true
},
"typography": {
"fontSize": true
}
},
"textdomain": "category-posts",
"editorScript": "file:./index.js",
"editorStyle": "file:./index.css",
"style": "file:./style-index.css",
"render": "file:./render.php"
}apiVersion 3 oznacza wersję API bloków. editorScript i editorStyle dotyczą edytora, a style — edytora i witryny. supports włącza opcje wyglądu, takie jak kolory i odstępy. Odpowiednie atrybuty HTML dostarczają useBlockProps() i get_block_wrapper_attributes().
3. Rejestracja bloku
Główny plik rejestruje bloki opisane w manifeście kompilacji. Manifest to wygenerowany plik PHP z metadanymi; eliminuje osobne odczytywanie każdego block.json. Ten przykład wymaga WordPressa 6.9 lub nowszego.
/**
* Plugin Name: Category Posts
* Description: Server-rendered block that lists the latest posts of one category.
* Version: 0.1.0
* Requires at least: 6.9
* Requires PHP: 7.4
* Author: Lukas Wojcik
* License: GPL-2.0-or-later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain: category-posts
*
* @package Lw
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Registers every block listed in build/blocks-manifest.php.
*
* The manifest is generated by wp-scripts (--blocks-manifest), so WordPress
* does not have to read and decode each block.json on every request.
*/
function lw_category_posts_block_init() {
wp_register_block_types_from_metadata_collection(
__DIR__ . '/build',
__DIR__ . '/build/blocks-manifest.php'
);
}
add_action( 'init', 'lw_category_posts_block_init' );Dla pojedynczego bloku alternatywą jest rejestracja przez katalog. Ta ścieżka również wskazuje build: Kompilacja nadal jest potrzebna. Alternatywa zastępuje rejestrację w obsłudze init, zamiast działać dodatkowo.
// Single block, without a manifest: reads build/category-posts/block.json.
register_block_type( __DIR__ . '/build/category-posts' );Zmiany w src zaczynają działać po kolejnej kompilacji. Dotyczy to również render.php i block.json. Na instalację docelową trafiają główny plik wtyczki i wygenerowany katalog build.
4. Lista wpisów na podstawie ustawień
WordPress udostępnia w render.php trzy zmienne: $attributes zawiera ustawienia, $content zapisany wewnętrzny HTML, a $block kontekst bloku. W tym przykładzie $content jest pusty. Plik tworzy następujący wynik:
/**
* Server-side rendering of the lw/category-posts block.
*
* Variables provided by WordPress:
*
* @var array $attributes Block attributes, defaults from block.json applied.
* @var string $content Inner content (always empty for this block).
* @var WP_Block $block Block instance: name, context, parsed block.
*
* @package Lw
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
$lwcp_category_id = isset( $attributes['categoryId'] ) && is_int( $attributes['categoryId'] )
? $attributes['categoryId'] : 0;
$lwcp_count = isset( $attributes['count'] ) && is_int( $attributes['count'] )
? $attributes['count'] : 5;
if ( $lwcp_category_id < 1 ) {
return;
}
if ( $lwcp_count < 1 || $lwcp_count > 10 ) {
$lwcp_count = 5;
}
$lwcp_term = $lwcp_category_id ? get_term( $lwcp_category_id, 'category' ) : null;
// Render nothing until a valid category has been chosen.
if ( ! $lwcp_term instanceof WP_Term ) {
return;
}
// Do not list the post that contains the block (context from "usesContext").
$lwcp_current_id = isset( $block->context['postId'] ) ? absint( $block->context['postId'] ) : 0;
$lwcp_posts = get_posts(
array(
'category__in' => array( $lwcp_term->term_id ),
'suppress_filters' => false,
'numberposts' => $lwcp_count,
'post_status' => 'publish',
'has_password' => false,
'exclude' => $lwcp_current_id ? array( $lwcp_current_id ) : array(),
)
);
<div // Core escapes the returned attribute values.
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Core-generated wrapper attributes.
echo get_block_wrapper_attributes(); >
if ( empty( $lwcp_posts ) ) :
<p> esc_html_e( 'No posts in this category yet.', 'category-posts' ); </p>
else :
<ul class="wp-block-lw-category-posts__list">
foreach ( $lwcp_posts as $lwcp_post ) :
<li>
<a href="<?php echo esc_url( get_permalink( $lwcp_post ) ); ?>"> echo esc_html( get_the_title( $lwcp_post ) ); </a>
<time datetime="<?php echo esc_attr( get_the_date( 'c', $lwcp_post ) ); ?>"> echo esc_html( get_the_date( '', $lwcp_post ) ); </time>
</li>
endforeach;
</ul>
endif;
</div>Nieprawidłowa lub brakująca kategoria oznacza brak wyniku. Liczba poza zakresem 1–10 powoduje użycie pięciu wpisów. category__in ogranicza zapytanie do wybranej kategorii; podkategorie nie są automatycznie dołączane. Wpisy chronione hasłem i nieopublikowane pozostają wykluczone.
suppress_filters ma jawnie wartość false, aby mogły działać filtry, na przykład z wtyczek językowych. Nie gwarantuje to jeszcze właściwego języka: Konkretna wtyczka wielojęzyczna i działanie podglądu wymagają osobnego sprawdzenia.
Odnośniki, tytuły i daty korzystają z funkcji zabezpieczających właściwych dla miejsca wyświetlenia. Kodowanie znaków uniemożliwia potraktowanie treści jako HTML lub atrybutów. get_block_wrapper_attributes() zwraca wartości atrybutów zabezpieczone już przez WordPressa. Uzasadniony phpcs:ignore dotyczy wyłącznie tego wyniku.
WordPress ponownie ładuje render.php dla każdego wystąpienia bloku. Bezwarunkowe globalne deklaracje funkcji lub klas kolidowałyby przy drugim załadowaniu. Takie definicje pomocnicze należą do głównego pliku wtyczki, ładowanego jednokrotnie.
5. Ustawienia i podgląd w edytorze
index.js łączy blok z edit.js. save jawnie zwraca null: Zapisywane są wyłącznie komentarz i atrybuty, bez gotowego HTML listy.
import { registerBlockType } from '@wordpress/blocks';
import './style.scss';
import Edit from './edit';
import metadata from './block.json';
// No fallback HTML: only the block comment and attributes are stored.
registerBlockType( metadata.name, {
edit: Edit,
save: () => null,
} );edit.js pobiera nazwy kategorii, wyświetla wybór i ogranicza suwak do zakresu 1–10. Błąd ładowania wywołuje komunikat zamiast niekończącego się wskaźnika ładowania. Zapytanie pobiera wszystkie kategorie; bardzo duże zbiory wymagają wyszukiwania z ograniczoną liczbą wyników na stronie.
import { __ } from '@wordpress/i18n';
import { InspectorControls, useBlockProps } from '@wordpress/block-editor';
import {
Notice,
PanelBody,
Placeholder,
RangeControl,
SelectControl,
Spinner,
} from '@wordpress/components';
import { useSelect } from '@wordpress/data';
import { store as coreStore } from '@wordpress/core-data';
import { decodeEntities } from '@wordpress/html-entities';
import { ServerSideRender } from '@wordpress/server-side-render';
import metadata from './block.json';
import './editor.scss';
// Same query shape as the core Latest Posts block uses for its category list.
const CATEGORIES_QUERY = { per_page: -1, _fields: 'id,name', context: 'view' };
export default function Edit( { attributes, setAttributes } ) {
const { categoryId, count } = attributes;
const blockProps = useBlockProps();
const { categories, categoryError } = useSelect( ( select ) => {
const store = select( coreStore );
const args = [ 'taxonomy', 'category', CATEGORIES_QUERY ];
return {
categories: store.getEntityRecords( ...args ),
categoryError: store.getResolutionError( 'getEntityRecords', args ),
};
}, [] );
const options = [
{ value: '0', label: __( '— Select a category —', 'category-posts' ) },
...( categories ?? [] ).map( ( term ) => ( {
value: String( term.id ),
label: decodeEntities( term.name ),
} ) ),
];
return (
<>
<InspectorControls>
<PanelBody title={ __( 'Settings', 'category-posts' ) }>
{ categoryError ? (
<Notice status="error" isDismissible={ false }>
{ __( 'Categories could not be loaded.', 'category-posts' ) }
</Notice>
) : categories ? (
<SelectControl
__next40pxDefaultSize
__nextHasNoMarginBottom
label={ __( 'Category', 'category-posts' ) }
value={ String( categoryId ) }
options={ options }
onChange={ ( value ) =>
setAttributes( { categoryId: Number( value ) } )
}
/>
) : (
<Spinner />
) }
<RangeControl
__next40pxDefaultSize
__nextHasNoMarginBottom
label={ __( 'Number of posts', 'category-posts' ) }
value={ count }
min={ 1 }
max={ 10 }
onChange={ ( value ) =>
setAttributes( { count: value ?? 5 } )
}
/>
</PanelBody>
</InspectorControls>
<div { ...blockProps }>
{ categoryId ? (
<ServerSideRender
block={ metadata.name }
attributes={ attributes }
skipBlockSupportAttributes
/>
) : (
<Placeholder
icon="list-view"
label={ __( 'Category Posts', 'category-posts' ) }
instructions={ __(
'A category selection in the block settings enables the preview.',
'category-posts'
) }
/>
) }
</div>
</>
);
}ServerSideRender wysyła ustawienia do interfejsu REST WordPressa i wyświetla wynik render.php. REST oznacza tutaj dostęp przez HTTP do danych i funkcji WordPressa. Komponent może łączyć żądania, dlatego jedna zmiana nie musi oznaczać dokładnie jednego żądania.
Oficjalna dokumentacja opisuje ServerSideRender jako rozwiązanie zastępcze dla istniejącego kodu PHP. W nowych rozbudowanych interfejsach często lepiej sprawdza się podgląd tworzony w JavaScript z danych REST. Ten przykład edukacyjny celowo korzysta z tego samego wyniku PHP w podglądzie i witrynie.
skipBlockSupportAttributes usuwa atrybuty wyglądu z żądania podglądu, ponieważ zewnętrzny element edytora już je otrzymuje. Wewnętrzny element PHP pozostaje. Kolory, odstępy i zagnieżdżone elementy wymagają więc osobnych kontroli w edytorze i witrynie.
6. Zapis, pamięć podręczna i weryfikacja
Zapisany komentarz bloku dla kategorii 7 i trzech wpisów wygląda następująco:
<!-- wp:lw/category-posts {"categoryId":7,"count":3} /-->Bloki statyczne zwykle zapisują HTML. Zmiany w save mogą unieważnić istniejący HTML, jeśli nowy wynik nie pasuje do zapisanej struktury. Nie każda zmiana kodu powoduje ten błąd. Definicja deprecated może zachować obsługę starszych prawidłowych struktur.
W tym bloku dynamicznym nie ma walidacji zapisanego HTML listy. Zmiana render.php działa po kompilacji i wdrożeniu; pamięć podręczna stron może jednak nadal zwracać starszy wynik. Wykonanie PHP nie musi też oznaczać nowego zapytania do bazy: Pamięć podręczna obiektów może dostarczyć wynik zapytania.
Poprawiony kod został pomyślnie skompilowany 26.10.2026 z Node.js 22.23.3 i scripts 37.0.0. Udana kompilacja nie potwierdza wygody obsługi edytora ani zgodności z każdym motywem czy wtyczką językową. Audyt wykazał znane podatności w zależnościach środowiska programistycznego; użycie produkcyjne wymaga aktualizacji zależności i ponownych kontroli. node_modules nie należy do wdrażanej wtyczki.

Pytania i odpowiedzi
Dlaczego zmiana nie działa? Rejestracja i ładowanie korzystają z plików w build. Nowa kompilacja przenosi zmiany z src; później może być potrzebne odświeżenie pamięci podręcznej stron.
Czy render.php może zawierać funkcje pomocnicze? Bezwarunkowe globalne deklaracje są niewłaściwe, ponieważ plik jest ładowany wielokrotnie. Właściwym miejscem są pliki wtyczki ładowane jednokrotnie.
Dlaczego brakuje wpisów z podkategorii? category__in celowo wybiera tutaj wskazaną kategorię. Włączenie podkategorii wymaga innych argumentów zapytania i zgodnego z nimi opisu.