@imwz/wp-pattern-sentinel 1.1.4 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -1
- package/package.json +3 -2
- package/src/args.js +15 -5
- package/src/editor.js +26 -1
- package/src/main.js +9 -3
- package/src/validation.js +47 -18
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/@imwz/wp-pattern-sentinel)
|
|
5
5
|
[](https://github.com/imagewize/wp-pattern-sentinel/blob/main/package.json)
|
|
6
6
|
|
|
7
|
-
Browser-based WordPress block pattern validator. Loads each pattern into the Gutenberg editor via Playwright, saves it, and checks for block validation errors and content mismatches.
|
|
7
|
+
Browser-based WordPress block pattern validator. Loads each pattern into the Gutenberg editor via Playwright, saves it, and checks for block validation errors and content mismatches. Validates `.php` pattern files and `.html` files of raw block markup. See [What it validates](#what-it-validates-php-and-html).
|
|
8
8
|
|
|
9
9
|
## Why browser-based?
|
|
10
10
|
|
|
@@ -97,6 +97,9 @@ node bin/sentinel.js \
|
|
|
97
97
|
# Validate specific files
|
|
98
98
|
node bin/sentinel.js patterns/hero.php patterns/cta.php
|
|
99
99
|
|
|
100
|
+
# Raw block markup (.html) — block fixtures or post drafts
|
|
101
|
+
node bin/sentinel.js tests/sentinel/ drafts/my-post.html
|
|
102
|
+
|
|
100
103
|
# JSON output (one result object per line)
|
|
101
104
|
node bin/sentinel.js --json --url=... path/to/patterns/
|
|
102
105
|
|
|
@@ -113,6 +116,39 @@ node bin/sentinel.js --concurrency=6 --url=... path/to/patterns/
|
|
|
113
116
|
node bin/sentinel.js --verbose --url=... path/to/patterns/
|
|
114
117
|
```
|
|
115
118
|
|
|
119
|
+
## What it validates: `.php` and `.html`
|
|
120
|
+
|
|
121
|
+
Sentinel never asks WordPress for registered patterns. It reads block markup from a file, puts it into a new draft page in the editor, saves the page and compares the result. So a file doesn't have to be a registered pattern; it only has to contain serialized blocks. A folder argument is scanned recursively for both extensions, skipping `node_modules`, `vendor` and dot-folders such as `.git`. Point Sentinel at the pattern or fixture folder rather than a block theme's root: `templates/*.html` and `parts/*.html` are block markup too, but they are site templates, not post content, and don't belong in a page round-trip.
|
|
122
|
+
|
|
123
|
+
| Extension | What it holds | What Sentinel strips before inserting |
|
|
124
|
+
|-----------|---------------|---------------------------------------|
|
|
125
|
+
| `.php` | A WordPress pattern file: PHP header, then block markup | Everything up to the first `?>` (docblock, `ABSPATH` guard), then inline PHP such as `esc_html_e()` is replaced with static text |
|
|
126
|
+
| `.html` | Raw serialized blocks, exactly as WordPress stores them in `post_content` | Only a **leading** header made of plain HTML comments (`<!-- SUGGESTED TITLE: … -->`) and Blade comments (`{{-- … --}}`) |
|
|
127
|
+
|
|
128
|
+
### When to use `.html`
|
|
129
|
+
|
|
130
|
+
- **Testing a block that no pattern uses.** A theme block that only ever appears in post content (a CTA, a callout) has no pattern file for Sentinel to pick up. Don't wrap it in a fake pattern header. Save the block's markup as it is serialized in a real post, for example copied from `wp post get <id> --field=post_content`, as `tests/sentinel/<block>.html`.
|
|
131
|
+
- **Checking post or page drafts before import.** Drafts written as `.html` block markup can be validated as they are. Header notes at the top of the file are skipped. Without that, the editor would wrap them in a Classic block and Sentinel would report a false mismatch.
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
# A theme's block fixtures
|
|
135
|
+
sentinel --trellis --site=example.com tests/sentinel/
|
|
136
|
+
|
|
137
|
+
# Blog drafts before they are imported
|
|
138
|
+
sentinel --trellis --site=example.com drafts/blog-posts/
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Rules for `.html` files
|
|
142
|
+
|
|
143
|
+
- After the header, the file must start with a block comment (`<!-- wp:… -->`). Otherwise it fails with `extraction_error`.
|
|
144
|
+
- Only the header is stripped. A non-block comment *after* the first block is left in, because in post content it is real content.
|
|
145
|
+
- There's no PHP handling, so an `.html` file containing `<?php` is inserted as is.
|
|
146
|
+
- Use markup the editor actually produced, not hand-written HTML. The test's value is that a real serialization round-trips cleanly.
|
|
147
|
+
|
|
148
|
+
### Blocks with a locked template
|
|
149
|
+
|
|
150
|
+
A block that renders `InnerBlocks` with a `template` and `templateLock: "all"` or `"contentOnly"` is re-synced to that template when the editor loads it. Gutenberg matches inner blocks by position, adds any the template has extra, and removes any it no longer has. A fixture holding the markup a post stored *before* a template change will therefore fail with `content_mismatch`, and that is correct. It is exactly the change an editor would see on opening such a post. Keep one fixture per current template, and run an old one on purpose when you want to see how existing posts will be affected.
|
|
151
|
+
|
|
116
152
|
## Options
|
|
117
153
|
|
|
118
154
|
| Flag | Default | Description |
|
|
@@ -212,6 +248,10 @@ node -e "
|
|
|
212
248
|
|
|
213
249
|
`block_validation` errors also surface Gutenberg's human-readable issue messages (e.g. `"Expected attribute 'class' of value '…' but got '…'"`), so you no longer need to open the browser console to identify what failed.
|
|
214
250
|
|
|
251
|
+
A block can also pass validation only because one of its block type's deprecations accepted the markup and migrated it. The editor reports such a block as valid, but it saves different markup, and it shows as broken the next time the page is opened. Sentinel re-validates every parsed block against its original markup and reports these as `block_validation` errors reading "Block was migrated by a deprecation and will save different markup". A common cause is an attribute the block does not support, such as `aria-hidden` on `core/paragraph`.
|
|
252
|
+
|
|
253
|
+
`--cache` entries record the Sentinel version that passed them, so upgrading Sentinel re-validates every pattern once.
|
|
254
|
+
|
|
215
255
|
## npm publish
|
|
216
256
|
|
|
217
257
|
When ready to publish:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@imwz/wp-pattern-sentinel",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Browser-based WordPress block pattern validator using Playwright",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
"sentinel": "bin/sentinel.js"
|
|
11
11
|
},
|
|
12
12
|
"scripts": {
|
|
13
|
-
"start": "node bin/sentinel.js"
|
|
13
|
+
"start": "node bin/sentinel.js",
|
|
14
|
+
"test": "node --test test/*.test.js"
|
|
14
15
|
},
|
|
15
16
|
"files": [
|
|
16
17
|
"bin/",
|
package/src/args.js
CHANGED
|
@@ -148,7 +148,7 @@ export function resolveFiles(filePaths) {
|
|
|
148
148
|
if (!filePaths || filePaths.length === 0) {
|
|
149
149
|
throw new Error(
|
|
150
150
|
'No pattern files specified.\n' +
|
|
151
|
-
'Usage: sentinel [--trellis [--site=example.com]] path/to/patterns
|
|
151
|
+
'Usage: sentinel [--trellis [--site=example.com]] path/to/patterns/ (.php and .html files)\n' +
|
|
152
152
|
'Or set WP_URL, WP_USER, WP_PASS in .env'
|
|
153
153
|
);
|
|
154
154
|
}
|
|
@@ -162,7 +162,7 @@ export function resolveFiles(filePaths) {
|
|
|
162
162
|
}
|
|
163
163
|
const stat = fs.statSync(abs);
|
|
164
164
|
if (stat.isDirectory()) {
|
|
165
|
-
resolved.push(...
|
|
165
|
+
resolved.push(...findPatternFiles(abs));
|
|
166
166
|
} else {
|
|
167
167
|
resolved.push(abs);
|
|
168
168
|
}
|
|
@@ -170,13 +170,23 @@ export function resolveFiles(filePaths) {
|
|
|
170
170
|
return [...new Set(resolved)];
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
-
|
|
173
|
+
// `.php` — WordPress pattern files. `.html` — raw serialized block markup, as
|
|
174
|
+
// stored in post_content (block fixtures, post drafts).
|
|
175
|
+
const PATTERN_EXTENSIONS = ['.php', '.html'];
|
|
176
|
+
|
|
177
|
+
// Dependency and VCS folders are never pattern sources, but they do hold
|
|
178
|
+
// stray `.php`/`.html` files (README demos, test pages) that a scan of a theme
|
|
179
|
+
// root would otherwise try to validate. Dot-folders (.git, .cache) likewise.
|
|
180
|
+
const SKIPPED_DIRS = new Set(['node_modules', 'vendor']);
|
|
181
|
+
|
|
182
|
+
function findPatternFiles(dir) {
|
|
174
183
|
const results = [];
|
|
175
184
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
176
185
|
const full = path.join(dir, entry.name);
|
|
177
186
|
if (entry.isDirectory()) {
|
|
178
|
-
|
|
179
|
-
|
|
187
|
+
if (SKIPPED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
|
|
188
|
+
results.push(...findPatternFiles(full));
|
|
189
|
+
} else if (entry.isFile() && PATTERN_EXTENSIONS.includes(path.extname(entry.name))) {
|
|
180
190
|
results.push(full);
|
|
181
191
|
}
|
|
182
192
|
}
|
package/src/editor.js
CHANGED
|
@@ -52,7 +52,9 @@ function stripPhpForValidation(content) {
|
|
|
52
52
|
* regardless of what it contains (docblock only, or docblock + guard) — so a
|
|
53
53
|
* single non-greedy strip handles both shapes in one pass.
|
|
54
54
|
*/
|
|
55
|
-
export function extractBlockContent(fileContent) {
|
|
55
|
+
export function extractBlockContent(fileContent, filePath = '') {
|
|
56
|
+
if (filePath.endsWith('.html')) return extractHtmlBlockContent(fileContent);
|
|
57
|
+
|
|
56
58
|
const stripped = fileContent
|
|
57
59
|
.replace(/^[\s\S]*?\?>\s*/, '')
|
|
58
60
|
.trim();
|
|
@@ -61,6 +63,29 @@ export function extractBlockContent(fileContent) {
|
|
|
61
63
|
return stripPhpForValidation(stripped);
|
|
62
64
|
}
|
|
63
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Return the block markup of an `.html` file: serialized blocks exactly as
|
|
68
|
+
* WordPress stores them in post_content, with no PHP header.
|
|
69
|
+
*
|
|
70
|
+
* Only a *leading* header is removed — plain HTML comments that are not block
|
|
71
|
+
* delimiters (`<!-- SUGGESTED TITLE: … -->`) and Blade comments
|
|
72
|
+
* (`{{-- … --}}`). Draft files carry these as notes for humans; left in, the
|
|
73
|
+
* editor would turn them into a Classic block and report a mismatch. Comments
|
|
74
|
+
* after the first block are content and are left alone. Returns null if no
|
|
75
|
+
* block comment follows the header.
|
|
76
|
+
*/
|
|
77
|
+
export function extractHtmlBlockContent(fileContent) {
|
|
78
|
+
const HEADER_COMMENT = /^\s*(?:<!--(?!\s*\/?wp:)[\s\S]*?-->|\{\{--[\s\S]*?--\}\})/;
|
|
79
|
+
|
|
80
|
+
let content = fileContent;
|
|
81
|
+
while (HEADER_COMMENT.test(content)) {
|
|
82
|
+
content = content.replace(HEADER_COMMENT, '');
|
|
83
|
+
}
|
|
84
|
+
content = content.trim();
|
|
85
|
+
|
|
86
|
+
return /^<!--\s*wp:/.test(content) ? content : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
64
89
|
const PAGE_CREATION_RETRY_DELAYS = [3_000, 8_000, 15_000]; // ms before retries 1, 2, 3 (plus jitter)
|
|
65
90
|
|
|
66
91
|
/**
|
package/src/main.js
CHANGED
|
@@ -17,6 +17,12 @@ import { log, formatResult, printSummary } from './format.js';
|
|
|
17
17
|
|
|
18
18
|
const CACHE_FILE = '.sentinel-cache.json';
|
|
19
19
|
|
|
20
|
+
// Cache entries record the Sentinel version that passed them, so a release
|
|
21
|
+
// that adds or tightens a check re-validates patterns that passed before it.
|
|
22
|
+
const SENTINEL_VERSION = JSON.parse(
|
|
23
|
+
fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')
|
|
24
|
+
).version;
|
|
25
|
+
|
|
20
26
|
function loadCache() {
|
|
21
27
|
try {
|
|
22
28
|
return JSON.parse(fs.readFileSync(path.join(process.cwd(), CACHE_FILE), 'utf8'));
|
|
@@ -67,7 +73,7 @@ export async function main() {
|
|
|
67
73
|
try {
|
|
68
74
|
const content = fs.readFileSync(file, 'utf8');
|
|
69
75
|
const key = path.relative(process.cwd(), file);
|
|
70
|
-
if (cache[key]?.passed && cache[key]?.hash === hashContent(content)) {
|
|
76
|
+
if (cache[key]?.passed && cache[key]?.hash === hashContent(content) && cache[key]?.version === SENTINEL_VERSION) {
|
|
71
77
|
skipped.push(file);
|
|
72
78
|
} else {
|
|
73
79
|
pending.push(file);
|
|
@@ -153,7 +159,7 @@ export async function main() {
|
|
|
153
159
|
for (const result of results) {
|
|
154
160
|
const key = path.relative(process.cwd(), result.patternPath);
|
|
155
161
|
if (result.passed) {
|
|
156
|
-
cache[key] = { hash: result.hash, passed: true, checkedAt: new Date().toISOString() };
|
|
162
|
+
cache[key] = { hash: result.hash, passed: true, version: SENTINEL_VERSION, checkedAt: new Date().toISOString() };
|
|
157
163
|
} else {
|
|
158
164
|
delete cache[key];
|
|
159
165
|
}
|
|
@@ -186,7 +192,7 @@ async function validatePatternFile(patternPath, options, context) {
|
|
|
186
192
|
return fail(patternName, patternPath, startTime, 'file_error', error.message);
|
|
187
193
|
}
|
|
188
194
|
|
|
189
|
-
const blockContent = extractBlockContent(fileContent);
|
|
195
|
+
const blockContent = extractBlockContent(fileContent, patternPath);
|
|
190
196
|
if (!blockContent) {
|
|
191
197
|
return fail(patternName, patternPath, startTime, 'extraction_error', 'Could not extract block content from file');
|
|
192
198
|
}
|
package/src/validation.js
CHANGED
|
@@ -1,30 +1,59 @@
|
|
|
1
1
|
import { log } from './format.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Walk the block tree and collect any blocks where isValid === false
|
|
4
|
+
* Walk the block tree and collect any blocks where isValid === false, plus any
|
|
5
|
+
* block a deprecation silently migrated.
|
|
6
|
+
*
|
|
7
|
+
* When a block fails validation, the parser tries the block type's
|
|
8
|
+
* deprecations; if one accepts the markup, the block is migrated and reported
|
|
9
|
+
* as isValid: true with no issues, yet its attributes have changed and it will
|
|
10
|
+
* save different markup. The editor then shows the block as broken on the next
|
|
11
|
+
* load. Example: core/paragraph with aria-hidden="true" is taken by an old
|
|
12
|
+
* paragraph deprecation that drops fontFamily and keeps the whole <p> as the
|
|
13
|
+
* block's text, so the first save writes a <p> nested inside another <p>.
|
|
14
|
+
* Re-running validateBlock() on the parsed block compares what it will save
|
|
15
|
+
* against the original markup, which catches this.
|
|
5
16
|
*/
|
|
6
17
|
export async function checkBlockValidation(page, verbose = false) {
|
|
7
18
|
const start = Date.now();
|
|
8
19
|
if (verbose) log(' → Checking block validation...', 'gray');
|
|
9
20
|
try {
|
|
10
21
|
const errors = await page.evaluate(() => {
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
22
|
+
const formatIssues = issues => (issues ?? []).map(issue => {
|
|
23
|
+
try {
|
|
24
|
+
return (issue.args ?? [])
|
|
25
|
+
.map(a => (typeof a === 'string' ? a : JSON.stringify(a)))
|
|
26
|
+
.join(' ');
|
|
27
|
+
} catch { return 'unknown issue'; }
|
|
28
|
+
});
|
|
29
|
+
const validateBlock = window.wp.blocks.validateBlock;
|
|
30
|
+
const migratedIssues = block => {
|
|
31
|
+
if (!validateBlock || block.isValid !== true || block.name === 'core/missing') return null;
|
|
32
|
+
const [isValid, issues] = validateBlock(block);
|
|
33
|
+
return isValid ? null : issues;
|
|
34
|
+
};
|
|
35
|
+
const walk = blocks => blocks.flatMap(block => {
|
|
36
|
+
const migrated = migratedIssues(block);
|
|
37
|
+
return [
|
|
38
|
+
...(block.isValid === false
|
|
39
|
+
? [{
|
|
40
|
+
blockId: block.clientId,
|
|
41
|
+
blockName: block.name,
|
|
42
|
+
error: 'Block validation failed',
|
|
43
|
+
validationIssues: formatIssues(block.validationIssues),
|
|
44
|
+
}]
|
|
45
|
+
: []),
|
|
46
|
+
...(migrated
|
|
47
|
+
? [{
|
|
48
|
+
blockId: block.clientId,
|
|
49
|
+
blockName: block.name,
|
|
50
|
+
error: 'Block was migrated by a deprecation and will save different markup',
|
|
51
|
+
validationIssues: formatIssues(migrated),
|
|
52
|
+
}]
|
|
53
|
+
: []),
|
|
54
|
+
...walk(block.innerBlocks ?? []),
|
|
55
|
+
];
|
|
56
|
+
});
|
|
28
57
|
return walk(window.wp.data.select('core/block-editor').getBlocks());
|
|
29
58
|
});
|
|
30
59
|
if (verbose) log(` → Block validation complete (${Date.now() - start}ms)`, 'gray');
|