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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## [Unreleased]

### Fixed
- **Index `.jsx`, `.ts` and `.tsx` block sources.** The save, edit and variations parsers only looked for `save.js`, `edit.js` and `variations.js`. Gutenberg renamed its JSX-bearing block sources to `.jsx` in [WordPress/gutenberg#80990](https://github.com/WordPress/gutenberg/pull/80990) (August 2026), so indexing Gutenberg `trunk` silently classified all 116 core blocks as dynamic, with 0 verified examples, and dropped every inspector control. Sources are now resolved in the order `.js`, `.jsx`, `.ts`, `.tsx`. On Gutenberg `trunk` the index goes from 0 to 54 static blocks and from 0 to 256 verified examples. Sources that only ship `.js` files (WordPress 7.1 and earlier) produce the same index and the same content hashes as before, so they are not re-indexed; stale `trunk` indexes are repaired by a plain `wp-blocks index`, no `--force` needed.

## [1.1.1] - 2026-05-13

### Docs
Expand Down
22 changes: 14 additions & 8 deletions src/indexer.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@
*/
import fg from 'fast-glob';
import { readFileSync, statSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { join, dirname, basename } from 'node:path';
import { createHash } from 'node:crypto';
import { fetchSource } from './source-manager.js';
import { parseBlockJson } from './parsers/block-json-parser.js';
import { parseEditFile } from './parsers/edit-parser.js';
import { parseSaveFile } from './parsers/save-parser.js';
import { parseVariationsFile } from './parsers/variations-parser.js';
import { generateMarkupExamples, generateDynamicMarkup } from './utils/markup-generator.js';
import { resolveSourceFile } from './utils/file-utils.js';
import { validateMarkup } from './validation/index.js';
import {
listSources,
Expand Down Expand Up @@ -118,7 +119,7 @@ async function indexSource(source, localPath, force, stats) {
const fullBlockDir = join(localPath, blockDir);

try {
// Compute content hash for the block directory (block.json + save.js + edit.js + variations.js)
// Compute content hash for the block directory (block.json + save, edit and variations sources)
const dirHash = computeBlockDirHash(fullBlockDir);

// Incremental skip
Expand Down Expand Up @@ -345,20 +346,25 @@ function calculateConfidence(blockData) {

/**
* Compute a content hash for a block directory.
* Hashes the contents of key files: block.json, save.js, edit.js, variations.js
* Hashes the contents of key files: block.json, and the save, edit and
* variations sources whatever their extension (.js, .jsx, .ts, .tsx).
*/
function computeBlockDirHash(blockDir) {
const hash = createHash('sha256');
const files = ['block.json', 'save.js', 'edit.js', 'variations.js'];

for (const file of files) {
const fullPath = join(blockDir, file);
const files = [
join(blockDir, 'block.json'),
...['save', 'edit', 'variations'].map(
(base) => resolveSourceFile(blockDir, base) ?? join(blockDir, `${base}.js`)
),
];

for (const fullPath of files) {
try {
const content = readFileSync(fullPath, 'utf-8');
hash.update(content);
} catch {
// File doesn't exist — that's fine, hash the absence
hash.update(`__missing__${file}`);
hash.update(`__missing__${basename(fullPath)}`);
}
}

Expand Down
6 changes: 3 additions & 3 deletions src/parsers/edit-parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import { parse } from '@babel/parser';
import traverse from '@babel/traverse';
import * as t from '@babel/types';
import { readFile, fileExists } from '../utils/file-utils.js';
import { readFile, resolveSourceFile } from '../utils/file-utils.js';

const CONTROL_PATTERNS = {
AlignmentControl: { attribute: 'align', possibleValues: ['left', 'center', 'right', 'justify'] },
Expand All @@ -31,9 +31,9 @@ const CONTROL_PATTERNS = {
* @returns {{ controls: object[], imports: object[] }}
*/
export function parseEditFile(blockPath) {
const editPath = `${blockPath}/edit.js`;
const editPath = resolveSourceFile(blockPath, 'edit');

if (!fileExists(editPath)) {
if (!editPath) {
return { controls: [], imports: [] };
}

Expand Down
8 changes: 4 additions & 4 deletions src/parsers/save-parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,19 @@
import { parse } from '@babel/parser';
import traverse from '@babel/traverse';
import * as t from '@babel/types';
import { readFile, fileExists } from '../utils/file-utils.js';
import { readFile, fileExists, resolveSourceFile } from '../utils/file-utils.js';

/**
* Parse a save.js file and return HTML output patterns.
* @param {string} blockPath - Directory containing the block
* @returns {object}
*/
export function parseSaveFile(blockPath) {
const savePath = `${blockPath}/save.js`;
const savePath = resolveSourceFile(blockPath, 'save');
const hasRenderPhp = fileExists(`${blockPath}/render.php`);

if (!fileExists(savePath)) {
// No save.js — dynamic block if render.php exists, otherwise unknown
if (!savePath) {
// No save file — dynamic block if render.php exists, otherwise unknown
return {
hasCustomSave: false,
returnsNull: true,
Expand Down
6 changes: 3 additions & 3 deletions src/parsers/variations-parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,17 @@
import { parse } from '@babel/parser';
import traverse from '@babel/traverse';
import * as t from '@babel/types';
import { readFile, fileExists } from '../utils/file-utils.js';
import { readFile, resolveSourceFile } from '../utils/file-utils.js';

/**
* Parse a variations.js file and return extracted variations.
* @param {string} blockPath - Directory containing the block
* @returns {object[]}
*/
export function parseVariationsFile(blockPath) {
const variationsPath = `${blockPath}/variations.js`;
const variationsPath = resolveSourceFile(blockPath, 'variations');

if (!fileExists(variationsPath)) {
if (!variationsPath) {
return [];
}

Expand Down
23 changes: 23 additions & 0 deletions src/utils/file-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,26 @@ export function readJSON(filePath) {
export function readFile(filePath) {
return fs.readFileSync(filePath, 'utf-8');
}

/**
* Extensions a block source file can use, in lookup order.
* Gutenberg moved JSX-bearing files from .js to .jsx in August 2026
* (WordPress/gutenberg#80990), and many third-party blocks ship .tsx.
*/
export const SOURCE_EXTENSIONS = ['.js', '.jsx', '.ts', '.tsx'];

/**
* Find a block source file by base name, whatever its extension.
* @param {string} blockPath - Directory containing the block
* @param {string} baseName - File name without extension, e.g. 'save'
* @returns {string|null} Full path of the first match, or null
*/
export function resolveSourceFile(blockPath, baseName) {
for (const ext of SOURCE_EXTENSIONS) {
const candidate = `${blockPath}/${baseName}${ext}`;
if (fs.existsSync(candidate)) {
return candidate;
}
}
return null;
}
113 changes: 113 additions & 0 deletions tests/test-source-resolution.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { resolveSourceFile } from '../src/utils/file-utils.js';
import { parseSaveFile } from '../src/parsers/save-parser.js';
import { parseEditFile } from '../src/parsers/edit-parser.js';
import { parseVariationsFile } from '../src/parsers/variations-parser.js';

// Gutenberg renamed its JSX-bearing block sources from .js to .jsx in
// WordPress/gutenberg#80990 (August 2026). A block that only ships .jsx
// files must be parsed exactly like one that ships .js files.

let root;

const SAVE = `
import { useBlockProps, RichText } from '@wordpress/block-editor';
export default function save( { attributes } ) {
return (
<p { ...useBlockProps.save() }>
<RichText.Content value={ attributes.content } />
</p>
);
}
`;

const EDIT = `
import { InspectorControls } from '@wordpress/block-editor';
import { ToggleControl } from '@wordpress/components';
export default function Edit( { attributes, setAttributes } ) {
return (
<InspectorControls>
<ToggleControl
label="Drop cap"
checked={ attributes.dropCap }
onChange={ ( dropCap ) => setAttributes( { dropCap } ) }
/>
</InspectorControls>
);
}
`;

const VARIATIONS = `
const variations = [
{ name: 'plain', title: 'Plain', icon: <svg />, attributes: { dropCap: false } },
];
export default variations;
`;

function writeBlock(dir, ext) {
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'block.json'), JSON.stringify({ name: 'test/demo' }));
writeFileSync(join(dir, `save${ext}`), SAVE);
writeFileSync(join(dir, `edit${ext}`), EDIT);
writeFileSync(join(dir, `variations${ext}`), VARIATIONS);
}

before(() => {
root = mkdtempSync(join(tmpdir(), 'wp-blockmarkup-src-'));
writeBlock(join(root, 'js'), '.js');
writeBlock(join(root, 'jsx'), '.jsx');
writeBlock(join(root, 'tsx'), '.tsx');
mkdirSync(join(root, 'empty'));
});

after(() => {
rmSync(root, { recursive: true, force: true });
});

test('resolveSourceFile: finds .js, .jsx and .tsx sources', () => {
assert.equal(resolveSourceFile(join(root, 'js'), 'save'), `${join(root, 'js')}/save.js`);
assert.equal(resolveSourceFile(join(root, 'jsx'), 'save'), `${join(root, 'jsx')}/save.jsx`);
assert.equal(resolveSourceFile(join(root, 'tsx'), 'save'), `${join(root, 'tsx')}/save.tsx`);
});

test('resolveSourceFile: returns null when no source exists', () => {
assert.equal(resolveSourceFile(join(root, 'empty'), 'save'), null);
});

test('resolveSourceFile: prefers .js when several extensions exist', () => {
const dir = join(root, 'both');
mkdirSync(dir);
writeFileSync(join(dir, 'save.js'), SAVE);
writeFileSync(join(dir, 'save.jsx'), SAVE);
assert.equal(resolveSourceFile(dir, 'save'), `${dir}/save.js`);
});

test('parseSaveFile: a .jsx-only block is static, like its .js twin', () => {
const fromJs = parseSaveFile(join(root, 'js'));
const fromJsx = parseSaveFile(join(root, 'jsx'));
assert.equal(fromJsx.blockType, 'static');
assert.equal(fromJsx.hasCustomSave, true);
assert.deepEqual(fromJsx, fromJs);
});

test('parseSaveFile: a block without any save source stays dynamic', () => {
assert.equal(parseSaveFile(join(root, 'empty')).blockType, 'dynamic');
});

test('parseEditFile: controls are extracted from edit.jsx', () => {
const fromJs = parseEditFile(join(root, 'js'));
const fromJsx = parseEditFile(join(root, 'jsx'));
assert.ok(fromJsx.controls.length > 0);
assert.deepEqual(fromJsx, fromJs);
});

test('parseVariationsFile: variations are extracted from variations.jsx', () => {
const fromJs = parseVariationsFile(join(root, 'js'));
const fromJsx = parseVariationsFile(join(root, 'jsx'));
assert.equal(fromJsx.length, 1);
assert.deepEqual(fromJsx, fromJs);
});