/
githubmirror
/
gutenberg
Обзор
Документация
Войти
/
githubmirror
/
gutenberg
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
trunk
tools/docs/generate-block-docs.mjs
1 063 строки
32 KB
JuanMa
Docs: Auto-generate per-block API reference pages from block.json (#77612)
07 июн 2026, 09:23
Не верифицирован
07 июн 2026, 09:23
084b28e
Код
Авторство
О чём код?
/** * Per-block detail page generator. * * Generates one Markdown README.md per block inside the block's own source * directory (packages/block-library/src/{block}/README.md), mirroring how * component docs live next to their source. * * Generated content is wrapped in START/END TOKEN delimiters so that * hand-written content in existing READMEs is preserved. * * Category index pages are still written to * docs/reference-guides/core-blocks/category-{cat}.md. * * The summary page (core-blocks.md) is still generated separately * by gen-block-lib-list.js via the docs:blocks npm script. * * Reads from : packages/block-library/src/{block}/block.json * Publishes to: packages/block-library/src/{block}/README.md */ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath( import.meta.url ); const __dirname = path.dirname( __filename ); const ROOT_DIR = path.resolve( __dirname, '../..' ); const BLOCK_LIBRARY_DIR = path.resolve( ROOT_DIR, 'packages/block-library/src' ); const CATEGORY_DOCS_DIR = path.resolve( ROOT_DIR, 'docs/reference-guides/core-blocks' ); const TOKEN_START = '<!-- START TOKEN(Autogenerated block API docs) -->'; const TOKEN_END = '<!-- END TOKEN(Autogenerated block API docs) -->'; const DEPRECATED_TOKEN_START = '<!-- START TOKEN(Deprecated blocks) -->'; const DEPRECATED_TOKEN_END = '<!-- END TOKEN(Deprecated blocks) -->'; const CORE_BLOCKS_README = path.resolve( ROOT_DIR, 'docs/reference-guides/core-blocks/README.md' ); const SOURCE_URL_BASE = 'https://github.com/WordPress/gutenberg/tree/trunk/packages/block-library/src/'; const FIXTURES_DIR = path.resolve( ROOT_DIR, 'test/integration/fixtures/blocks' ); /** * Reference path and heading anchors for the Block Attributes docs page. * * Keep anchors in sync with the headings in * docs/reference-guides/block-api/block-attributes.md. * The validateAttributeAnchors() helper warns at generation time if any * anchor listed here no longer exists in that file. */ const ATTRIBUTES_REF = 'https://developer.wordpress.org/block-editor/reference-guides/block-api/block-attributes/'; // Note: `source` and `selector` intentionally share the same anchor; // both are documented together under the "Value source" heading. const ATTRIBUTE_ANCHORS = { type: 'type-validation', default: 'default-value', source: 'value-source', selector: 'value-source', attribute: 'attribute-source', enum: 'enum-validation', role: 'role', }; const ATTRIBUTES_DOC_PATH = path.join( ROOT_DIR, 'docs/reference-guides/block-api/block-attributes.md' ); const SUPPORTS_DOC_PATH = path.join( ROOT_DIR, 'docs/reference-guides/block-api/block-supports.md' ); /** * Human-readable labels for block categories. */ const CATEGORY_LABELS = { text: 'Text', media: 'Media', design: 'Design', widgets: 'Widgets', theme: 'Theme', common: 'Common', embed: 'Embed', reusable: 'Reusable', }; /** * Discover all block directories that contain a block.json. * * @return {string[]} Sorted list of directory names. */ function getBlockDirs() { return fs .readdirSync( BLOCK_LIBRARY_DIR, { withFileTypes: true } ) .filter( ( d ) => d.isDirectory() && fs.existsSync( path.join( BLOCK_LIBRARY_DIR, d.name, 'block.json' ) ) ) .map( ( d ) => d.name ) .sort(); } /** * Return true for deprecated blocks, identified by their description starting * with "This block is deprecated." — the consistent convention used across all * deprecated core blocks. * * @param {string} blockDir Directory name inside block-library/src. * @return {boolean} Whether the block is deprecated. */ function isDeprecated( blockDir ) { const { description = '' } = readBlockJson( blockDir ); return description.startsWith( 'This block is deprecated.' ); } /** * Read and parse a block.json file. * * @param {string} blockDir Directory name inside block-library/src. * @return {Object} Parsed block.json contents. */ function readBlockJson( blockDir ) { const filePath = path.join( BLOCK_LIBRARY_DIR, blockDir, 'block.json' ); return JSON.parse( fs.readFileSync( filePath, 'utf-8' ) ); } /** * Check which source files exist for a block. * * @param {string} blockDir Directory name. * @return {Object} Flags for each possible source file. */ function getBlockFiles( blockDir ) { const dir = path.join( BLOCK_LIBRARY_DIR, blockDir ); return { hasSaveJs: fs.existsSync( path.join( dir, 'save.js' ) ), hasIndexPhp: fs.existsSync( path.join( dir, 'index.php' ) ), }; } // ─── Anchor validation ────────────────────────────────────────────────────── /** * Slugify a Markdown heading into an anchor the way the WordPress handbook * renderer does: lowercase, strip markup escapes, convert dots and spaces * to dashes, remove everything else that isn't alphanumeric, a dash, or * an underscore. * * @param {string} heading Raw heading text (without the leading `#` marks). * @return {string} Slug suitable for use as an anchor. */ function slugifyHeading( heading ) { return heading .toLowerCase() .trim() .replace( /\\\\/g, '' ) // strip markdown backslash escapes .replace( /[.\s]+/g, '-' ) // dots and spaces → dashes .replace( /[^\w-]/g, '' ); // drop everything else } /** * Read a markdown doc file, extract heading anchors, and error if any * expected anchor is missing. Sets process.exitCode = 1 so CI fails when * a handbook heading is renamed and the generator's anchor map drifts. * * @param {string} docPath Absolute path to the markdown file. * @param {string[]} expectedAnchors Anchors the generator relies on. * @param {string} label Short name for error messages. */ function validateDocAnchors( docPath, expectedAnchors, label ) { if ( ! fs.existsSync( docPath ) ) { console.error( `✖ Cannot validate anchors: ${ docPath } not found.` ); process.exitCode = 1; return; } const content = fs.readFileSync( docPath, 'utf-8' ); const anchors = new Set( Array.from( content.matchAll( /^#{1,6}\s+(.+)$/gm ), ( m ) => slugifyHeading( m[ 1 ] ) ) ); for ( const anchor of expectedAnchors ) { if ( ! anchors.has( anchor ) ) { console.error( `✖ Anchor "#${ anchor }" not found in ${ label }. ` + `Update the corresponding map in generate-block-docs.mjs.` ); process.exitCode = 1; } } } // ─── Formatting helpers ───────────────────────────────────────────────────── /** * Build a markdown link to a specific anchor on the Block Attributes docs page. * * @param {string} label Display text for the link. * @param {string} anchor Key in ATTRIBUTE_ANCHORS. * @return {string} Markdown link. */ function attrLink( label, anchor ) { return `[${ label }](${ ATTRIBUTES_REF }#${ ATTRIBUTE_ANCHORS[ anchor ] })`; } /** * Format attributes as a Markdown table. * * @param {Object} attributes * @return {string} Markdown table or placeholder text. */ function formatAttributesTable( attributes ) { if ( ! attributes || Object.keys( attributes ).length === 0 ) { return '_This block has no custom attributes._'; } const rows = [ `| Attribute | ${ attrLink( 'Type', 'type' ) } | ${ attrLink( 'Default', 'default' ) } | Description |`, '|-----------|------|---------|-------------|', ]; for ( const [ attrName, attrDef ] of Object.entries( attributes ) ) { const type = Array.isArray( attrDef.type ) ? attrDef.type.join( ' \\| ' ) : attrDef.type || 'N/A'; const defaultVal = attrDef.default !== undefined ? `\`${ JSON.stringify( attrDef.default ) }\`` : '—'; const descParts = []; // Simple key-value description fields. const simpleFields = [ [ 'source', 'Source' ], [ 'selector', 'Selector' ], [ 'attribute', 'HTML attr' ], [ 'role', 'Role' ], ]; for ( const [ field, label ] of simpleFields ) { if ( attrDef[ field ] ) { descParts.push( `${ attrLink( label, field ) }: \`${ attrDef[ field ] }\`` ); } } // Enum needs special formatting (list of values). if ( attrDef.enum ) { descParts.push( `${ attrLink( 'Enum', 'enum' ) }: ${ attrDef.enum .map( ( v ) => `\`${ v }\`` ) .join( ', ' ) }` ); } const desc = descParts.length > 0 ? descParts.join( '. ' ) : '—'; rows.push( `| \`${ attrName }\` | \`${ type }\` | ${ defaultVal } | ${ desc } |` ); } return rows.join( '\n' ); } /** * Build a link to the Block Supports reference for a given property. * * Only sub-properties that have their own heading on the supports page * get a dedicated anchor. All others fall back to the parent property anchor. * * @param {string} property Top-level or dotted property name. * @return {string} Absolute handbook path with anchor. */ const SUPPORTS_BASE = 'https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/'; /** * All anchors documented in block-supports.md, derived by parsing every * heading at startup so links stay in sync as the handbook evolves. */ const SUPPORTS_ANCHORS = fs.existsSync( SUPPORTS_DOC_PATH ) ? new Set( Array.from( fs .readFileSync( SUPPORTS_DOC_PATH, 'utf-8' ) .matchAll( /^#{1,6}\s+(.+)$/gm ), ( m ) => slugifyHeading( m[ 1 ] ) ) ) : new Set(); /** * Build a link to the Block Supports reference for a given property. * * Returns null when the property has no matching anchor in block-supports.md * so that callers can render plain code instead of a broken link. * * @param {string} property Top-level or dotted sub-property name. * @return {string|null} Absolute handbook URL with anchor, or null. */ function supportsLink( property ) { const anchor = property.toLowerCase().replace( /\./g, '-' ); return SUPPORTS_ANCHORS.has( anchor ) ? `${ SUPPORTS_BASE }#${ anchor }` : null; } /** * Format supports as a readable list. * * @param {Object} supports * @return {string} Markdown list or placeholder text. */ function formatSupports( supports ) { if ( ! supports || Object.keys( supports ).length === 0 ) { return '_This block does not declare explicit supports._'; } const lines = []; for ( const [ key, value ] of Object.entries( supports ) ) { if ( key.startsWith( '__' ) ) { continue; // Skip experimental/unstable top-level keys in detail view. } const link = supportsLink( key ); const keyLink = link ? `[\`${ key }\`](${ link })` : `\`${ key }\``; if ( typeof value === 'boolean' ) { lines.push( `- ${ keyLink }: \`${ value }\`` ); } else if ( Array.isArray( value ) ) { lines.push( `- ${ keyLink }: ${ value .map( ( v ) => `\`${ JSON.stringify( v ) }\`` ) .join( ', ' ) }` ); } else if ( typeof value === 'object' && value !== null ) { const subEntries = Object.entries( value ).filter( ( [ subKey ] ) => ! subKey.startsWith( '__' ) ); if ( subEntries.length === 0 ) { lines.push( `- ${ keyLink }: \`true\`` ); } else { lines.push( `- ${ keyLink }:` ); for ( const [ subKey, subValue ] of subEntries ) { const subProp = `${ key }.${ subKey }`; const subLink = supportsLink( subProp ); const subLabel = subLink ? `[\`${ subKey }\`](${ subLink })` : `\`${ subKey }\``; if ( typeof subValue === 'object' && subValue !== null ) { lines.push( ` - ${ subLabel }: \`${ JSON.stringify( subValue ) }\`` ); } else { lines.push( ` - ${ subLabel }: \`${ subValue }\`` ); } } } } else { lines.push( `- ${ keyLink }: \`${ JSON.stringify( value ) }\`` ); } } return lines.length > 0 ? lines.join( '\n' ) : '_This block does not declare explicit supports._'; } /** * Format context information. * * @param {string[]} usesContext * @param {Object} providesContext * @return {string|null} Markdown or null if no context. */ function formatContext( usesContext, providesContext ) { const parts = []; if ( usesContext && usesContext.length > 0 ) { parts.push( '**Uses context:**' ); parts.push( '' ); for ( const ctx of usesContext ) { parts.push( `- \`${ ctx }\`` ); } } if ( providesContext && Object.keys( providesContext ).length > 0 ) { if ( parts.length > 0 ) { parts.push( '' ); } parts.push( '**Provides context:**' ); parts.push( '' ); for ( const [ key, value ] of Object.entries( providesContext ) ) { parts.push( `- \`${ key }\` → attribute \`${ value }\`` ); } } return parts.length > 0 ? parts.join( '\n' ) : null; } /** * Build a handbook link for a block name (e.g. `core/button`). * * Returns a markdown link if the block exists locally, otherwise plain code. * * @param {string} blockName Full block name like `core/button`. * @return {string} Markdown link or code span. */ function blockLink( blockName ) { const slug = blockName.replace( 'core/', '' ); const blockJsonExists = fs.existsSync( path.join( BLOCK_LIBRARY_DIR, slug, 'block.json' ) ); if ( ! blockJsonExists ) { return `\`${ blockName }\``; } const { category = 'uncategorized' } = readBlockJson( slug ); const url = `https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-${ category }/core-block-${ slug }/`; return `[\`${ blockName }\`](${ url })`; } /** * Format block relationships (parent, ancestor, allowedBlocks). * * @param {Object} blockJson * @return {string|null} Markdown or null if no relationships. */ function formatRelationships( blockJson ) { const METADATA_BASE = 'https://developer.wordpress.org/block-editor/reference-guides/block-api/block-metadata/'; const parts = []; if ( blockJson.parent && blockJson.parent.length > 0 ) { parts.push( `**[Parent](${ METADATA_BASE }#parent) blocks (direct):**` ); for ( const p of blockJson.parent ) { parts.push( `- ${ blockLink( p ) }` ); } } if ( blockJson.ancestor && blockJson.ancestor.length > 0 ) { if ( parts.length > 0 ) { parts.push( '' ); } parts.push( `**[Ancestor](${ METADATA_BASE }#ancestor) blocks:**` ); for ( const a of blockJson.ancestor ) { parts.push( `- ${ blockLink( a ) }` ); } } if ( blockJson.allowedBlocks && blockJson.allowedBlocks.length > 0 ) { if ( parts.length > 0 ) { parts.push( '' ); } parts.push( `**[Allowed](${ METADATA_BASE }#allowed-blocks) inner blocks:**` ); for ( const b of blockJson.allowedBlocks ) { parts.push( `- ${ blockLink( b ) }` ); } } return parts.length > 0 ? parts.join( '\n' ) : null; } /** * Format block styles as a table. * * @param {Array} styles * @return {string|null} Markdown table or null. */ function formatStyles( styles ) { if ( ! styles || styles.length === 0 ) { return null; } const rows = [ '| Style Name | Label | Default |', '|------------|-------|---------|', ]; for ( const style of styles ) { rows.push( `| \`${ style.name }\` | ${ style.label } | ${ style.isDefault ? 'Yes' : 'No' } |` ); } return rows.join( '\n' ); } /** * Format CSS selectors from block.json. * * @param {Object} selectors * @return {string|null} Markdown list or null. */ function formatSelectors( selectors ) { if ( ! selectors || Object.keys( selectors ).length === 0 ) { return null; } const lines = []; for ( const [ key, value ] of Object.entries( selectors ) ) { if ( typeof value === 'string' ) { lines.push( `- **${ key }**: \`${ value }\`` ); } else if ( typeof value === 'object' ) { lines.push( `- **${ key }**:` ); for ( const [ subKey, subValue ] of Object.entries( value ) ) { lines.push( ` - ${ subKey }: \`${ subValue }\`` ); } } } return lines.join( '\n' ); } /** * Determine block type (static, dynamic, hybrid) from source file presence * and block.json metadata. * * Server-side rendering is indicated by index.php (render_callback) or the * `render` property in block.json (typically `file:./render.php`). * * - save.js only → static: markup is saved in post content by the editor. * - server rendering only → dynamic: markup is rendered on the server at request time. * - save.js + server rendering → hybrid: editor saves static markup, server may enhance * it during rendering (e.g. injecting dynamic data or wrapping with extra HTML). * * @param {Object} files File existence flags from getBlockFiles(). * @param {Object} blockJson Parsed block.json contents. * @return {string} One of 'static', 'dynamic', 'hybrid', 'unknown'. */ function getBlockType( files, blockJson ) { const hasServerRender = files.hasIndexPhp || !! blockJson.render; if ( files.hasSaveJs && hasServerRender ) { return 'hybrid'; } if ( files.hasSaveJs ) { return 'static'; } if ( hasServerRender ) { return 'dynamic'; } return 'unknown'; } /** * Generate a block comment example from block.json data. * * @param {string} slug Block slug without core/ prefix. * @param {Object} attributes Block attributes definition. * @param {string} blockType 'static', 'dynamic', or 'hybrid'. * @return {string} HTML block comment example. Dynamic blocks with no * attribute defaults render a `{ \/* attributes *\/ }` * placeholder rather than a bare stub, to make the * syntactic shape visible without fabricating values. */ function generateBlockCommentExample( slug, attributes, blockType ) { const exampleAttrs = {}; if ( attributes ) { for ( const [ attrName, attrDef ] of Object.entries( attributes ) ) { if ( attrDef.default !== undefined ) { exampleAttrs[ attrName ] = attrDef.default; } } } const hasAttrs = Object.keys( exampleAttrs ).length > 0; let attrsStr = ''; if ( hasAttrs ) { attrsStr = ` ${ JSON.stringify( exampleAttrs ) }`; } else if ( blockType === 'dynamic' ) { attrsStr = ' { /* attributes */ }'; } if ( blockType === 'dynamic' ) { return `<!-- wp:${ slug }${ attrsStr } /-->`; } return `<!-- wp:${ slug }${ attrsStr } -->\n<!-- Content... -->\n<!-- /wp:${ slug } -->`; } // ─── Page generators ──────────────────────────────────────────────────────── /** * Generate the token-delimited API reference content for a block. * * @param {string} blockDir Directory name. * @return {string} Markdown content wrapped in START/END TOKEN delimiters. */ function generateBlockApiSection( blockDir ) { const blockJson = readBlockJson( blockDir ); const files = getBlockFiles( blockDir ); const blockType = getBlockType( files, blockJson ); const { name, category, description, keywords, apiVersion, attributes, supports, usesContext, providesContext, styles, selectors, __experimental: experimental, } = blockJson; const slug = name.replace( 'core/', '' ); const lines = []; // Deprecation notice replaces the description for deprecated blocks. if ( description.startsWith( 'This block is deprecated.' ) ) { const replacement = description .replace( 'This block is deprecated. ', '' ) .replace( 'This block is deprecated.', '' ) .trim(); const suffix = replacement ? ` ${ replacement }` : ''; lines.push( `<div class="callout callout-alert">This block is <strong>deprecated</strong> and should not be used in new content.${ suffix }</div>` ); lines.push( '' ); } else { // Experimental notice. if ( experimental ) { lines.push( '<div class="callout callout-warning">This block is <strong>experimental</strong> and may change or be removed without notice.</div>' ); lines.push( '' ); } if ( description ) { lines.push( description ); lines.push( '' ); } } // Metadata. lines.push( `- **Name:** \`${ name }\`` ); lines.push( `- **Category:** [${ category }](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-${ category }/)` ); if ( apiVersion ) { lines.push( `- **API Version:** [${ apiVersion }](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-api-versions/)` ); } const RENDERING_GUIDE = 'https://developer.wordpress.org/block-editor/getting-started/fundamentals/static-dynamic-rendering/'; const typeLabel = { static: `[Static](${ RENDERING_GUIDE }) (saved in post content)`, dynamic: `[Dynamic](${ RENDERING_GUIDE }) (server-rendered)`, hybrid: `[Hybrid](${ RENDERING_GUIDE }) (static save + server enhancements)`, }; lines.push( `- **Block Type:** ${ typeLabel[ blockType ] || 'Unknown' }` ); if ( keywords && keywords.length > 0 ) { lines.push( `- **Keywords:** ${ keywords .map( ( k ) => `\`${ k }\`` ) .join( ', ' ) }` ); } lines.push( '' ); // Block relationships. const relationships = formatRelationships( blockJson ); if ( relationships ) { lines.push( '## Block Relationships' ); lines.push( '' ); lines.push( relationships ); lines.push( '' ); } // Attributes. lines.push( '## Attributes' ); lines.push( '' ); lines.push( '_Defined via the [`attributes`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-attributes/) property in block.json._' ); lines.push( '' ); lines.push( formatAttributesTable( attributes ) ); lines.push( '' ); // Supports. lines.push( '## Supports' ); lines.push( '' ); lines.push( '_Defined via the [`supports`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/) property in block.json._' ); lines.push( '' ); lines.push( formatSupports( supports ) ); lines.push( '' ); // Context. const contextSection = formatContext( usesContext, providesContext ); if ( contextSection ) { lines.push( '## Context' ); lines.push( '' ); lines.push( '_Defined via the [`usesContext` and `providesContext`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-context/) properties in block.json._' ); lines.push( '' ); lines.push( contextSection ); lines.push( '' ); } // Styles. const stylesSection = formatStyles( styles ); if ( stylesSection ) { lines.push( '## Block Styles' ); lines.push( '' ); lines.push( '_Defined via the [`styles`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-styles/) property in block.json._' ); lines.push( '' ); lines.push( stylesSection ); lines.push( '' ); } // Selectors. const selectorsSection = formatSelectors( selectors ); if ( selectorsSection ) { lines.push( '## CSS Selectors' ); lines.push( '' ); lines.push( '_Defined via the [`selectors`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-selectors/) property in block.json._' ); lines.push( '' ); lines.push( selectorsSection ); lines.push( '' ); } // Block markup example. lines.push( '## Block Markup' ); lines.push( '' ); if ( blockType === 'dynamic' ) { lines.push( 'This is a [**dynamic block**](https://developer.wordpress.org/block-editor/getting-started/fundamentals/static-dynamic-rendering/). It is rendered on the server and does not save HTML in post content.' ); lines.push( '' ); lines.push( 'In post content, it is stored as a block comment:' ); } else if ( blockType === 'hybrid' ) { lines.push( 'This is a [**hybrid block**](https://developer.wordpress.org/block-editor/getting-started/fundamentals/static-dynamic-rendering/). It saves static markup that the server may enhance during rendering.' ); } else { lines.push( 'This is a [**static block**](https://developer.wordpress.org/block-editor/getting-started/fundamentals/static-dynamic-rendering/). The markup is saved directly in the post content.' ); } lines.push( '' ); const fixtureFile = `core__${ blockDir }.html`; const fixturePath = path.join( FIXTURES_DIR, fixtureFile ); if ( fs.existsSync( fixturePath ) ) { lines.push( '```html' ); lines.push( fs.readFileSync( fixturePath, 'utf-8' ).trimEnd() ); lines.push( '```' ); } else { lines.push( '```html' ); lines.push( generateBlockCommentExample( slug, attributes, blockType ) ); lines.push( '```' ); } lines.push( '' ); // Source files reference. lines.push( '## Source' ); lines.push( '' ); lines.push( `- [block.json](${ SOURCE_URL_BASE }${ blockDir }/block.json) ([reference](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-metadata/))` ); lines.push( `- [Source directory](${ SOURCE_URL_BASE }${ blockDir }/) — browse \`edit.js\`, \`save.js\`, \`index.php\`, and more.` ); lines.push( '' ); return `${ TOKEN_START }\n${ lines.join( '\n' ) }\n${ TOKEN_END }\n`; } /** * Write a block's README.md with token-delimited generated content. * * - File doesn't exist → create with H1 title + description + tokens. * - File exists, no tokens → append tokens at the end. * - File exists, has tokens → replace content between tokens. * * @param {string} blockDir Directory name inside block-library/src. * @param {string} apiSection Token-wrapped generated content. */ function writeBlockReadme( blockDir, apiSection ) { const readmePath = path.join( BLOCK_LIBRARY_DIR, blockDir, 'README.md' ); const blockJson = readBlockJson( blockDir ); const title = blockJson.title || blockDir .split( '-' ) .map( ( w ) => w[ 0 ].toUpperCase() + w.slice( 1 ) ) .join( ' ' ); if ( ! fs.existsSync( readmePath ) ) { // Case 1: No README — create full file. const header = `# ${ title }\n\n`; fs.writeFileSync( readmePath, header + apiSection, { encoding: 'utf8', } ); return; } const existing = fs.readFileSync( readmePath, 'utf8' ); if ( existing.includes( TOKEN_START ) ) { // Case 3: Tokens exist — replace content between them. const tokenPattern = new RegExp( escapeRegExp( TOKEN_START ) + '[\\s\\S]*?' + escapeRegExp( TOKEN_END ) + '\\n?', 'm' ); fs.writeFileSync( readmePath, existing.replace( tokenPattern, apiSection ), { encoding: 'utf8' } ); return; } // Case 2: File exists but no tokens — append at the end. const separator = existing.endsWith( '\n' ) ? '\n' : '\n\n'; fs.writeFileSync( readmePath, existing + separator + apiSection, { encoding: 'utf8', } ); } /** * Escape a string for use in a RegExp. * * @param {string} str * @return {string} Escaped string safe for RegExp use. */ function escapeRegExp( str ) { return str.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' ); } /** * Generate the token-delimited block list for a category index page. * * @param {string} category Category slug. * @param {string[]} blocks Block directory names in this category. * @return {string} Token-wrapped markdown block list. */ function generateCategoryPageSection( category, blocks ) { const lines = []; blocks.forEach( ( blockDir ) => { const blockJson = readBlockJson( blockDir ); const { title, name, description } = blockJson; const catSlug = `core-blocks-${ category }`; const blockSlug = `core-block-${ blockDir }`; const blockUrl = `https://developer.wordpress.org/block-editor/reference-guides/core-blocks/${ catSlug }/${ blockSlug }/`; lines.push( `- [${ title }](${ blockUrl }) — \`${ name }\`${ description ? ': ' + description : '' }` ); } ); lines.push( '' ); return `${ TOKEN_START }\n${ lines.join( '\n' ) }\n${ TOKEN_END }\n`; } /** * Write a category index page with token-delimited generated content. * * Follows the same three-case logic as writeBlockReadme: * - File doesn't exist → create with H1 title + tokens. * - File exists, has tokens → replace content between tokens. * - File exists, no tokens → append tokens at the end. * * @param {string} category Category slug. * @param {string} label Human-readable category label. * @param {string} section Token-wrapped generated content. */ function writeCategoryPage( category, label, section ) { const filePath = path.join( CATEGORY_DOCS_DIR, `category-${ category }.md` ); if ( ! fs.existsSync( filePath ) ) { fs.writeFileSync( filePath, `# ${ label } Blocks\n\n${ section }`, { encoding: 'utf8', } ); return; } const existing = fs.readFileSync( filePath, 'utf8' ); if ( existing.includes( TOKEN_START ) ) { const tokenPattern = new RegExp( escapeRegExp( TOKEN_START ) + '[\\s\\S]*?' + escapeRegExp( TOKEN_END ) + '\\n?', 'm' ); fs.writeFileSync( filePath, existing.replace( tokenPattern, section ), { encoding: 'utf8', } ); return; } const separator = existing.endsWith( '\n' ) ? '\n' : '\n\n'; fs.writeFileSync( filePath, existing + separator + section, { encoding: 'utf8', } ); } /** * Generate the deprecated-blocks notice section for the core blocks README. * * @param {string[]} dirs Deprecated block directory names. * @return {string} Token-wrapped markdown section. */ function generateDeprecatedNotice( dirs ) { const lines = []; lines.push( '**Deprecated blocks** — the following blocks are deprecated and should not be used in new content.' ); lines.push( '' ); dirs.forEach( ( blockDir ) => { const { title, name, description, category } = readBlockJson( blockDir ); const replacement = description .replace( 'This block is deprecated. ', '' ) .replace( 'This block is deprecated.', '' ) .trim(); const note = replacement ? ` — ${ replacement }` : ''; const url = `https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-${ category }/core-block-${ blockDir }/`; lines.push( `- [${ title }](${ url }) (\`${ name }\`)${ note }` ); } ); lines.push( '' ); return ( DEPRECATED_TOKEN_START + '\n' + lines.join( '\n' ) + '\n' + DEPRECATED_TOKEN_END + '\n' ); } /** * Write the deprecated-blocks notice into the core blocks README, * replacing the content between the deprecated token pair. * * @param {string[]} dirs Deprecated block directory names. */ function writeDeprecatedNotice( dirs ) { const content = fs.readFileSync( CORE_BLOCKS_README, 'utf-8' ); const tokenPattern = new RegExp( escapeRegExp( DEPRECATED_TOKEN_START ) + '[\\s\\S]*?' + escapeRegExp( DEPRECATED_TOKEN_END ) + '\\n?', 'm' ); fs.writeFileSync( CORE_BLOCKS_README, content.replace( tokenPattern, generateDeprecatedNotice( dirs ) ), { encoding: 'utf8' } ); } // ─── Main ─────────────────────────────────────────────────────────────────── validateDocAnchors( ATTRIBUTES_DOC_PATH, [ ...new Set( Object.values( ATTRIBUTE_ANCHORS ) ) ], 'block-attributes.md' ); const allBlockDirs = getBlockDirs(); const deprecatedDirs = allBlockDirs.filter( isDeprecated ); const blockDirs = allBlockDirs.filter( ( d ) => ! isDeprecated( d ) ); // Group non-deprecated blocks by category. const categories = {}; blockDirs.forEach( ( blockDir ) => { const blockJson = readBlockJson( blockDir ); const category = blockJson.category || 'uncategorized'; if ( ! categories[ category ] ) { categories[ category ] = []; } categories[ category ].push( blockDir ); } ); // Ensure category output directory exists. fs.mkdirSync( CATEGORY_DOCS_DIR, { recursive: true } ); // Generate individual block READMEs — both active and deprecated. [ ...blockDirs, ...deprecatedDirs ].forEach( ( blockDir ) => { const apiSection = generateBlockApiSection( blockDir ); writeBlockReadme( blockDir, apiSection ); } ); // Generate category index pages (prefixed to avoid conflicts with block names). const categoryNames = Object.keys( categories ).sort(); categoryNames.forEach( ( category ) => { const label = CATEGORY_LABELS[ category ] || category; const section = generateCategoryPageSection( category, categories[ category ] ); writeCategoryPage( category, label, section ); } ); // Update the deprecated-blocks notice in the core blocks README. writeDeprecatedNotice( deprecatedDirs ); console.log( `Generated ${ blockDirs.length } block READMEs + ${ deprecatedDirs.length } deprecated block READMEs + ${ categoryNames.length } category pages` );