@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 CHANGED
@@ -4,7 +4,7 @@
4
4
  [![npm downloads](https://img.shields.io/npm/dt/@imwz/wp-pattern-sentinel.svg)](https://www.npmjs.com/package/@imwz/wp-pattern-sentinel)
5
5
  [![License](https://img.shields.io/npm/l/@imwz/wp-pattern-sentinel.svg)](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.1.4",
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/\n' +
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(...findPhpFiles(abs));
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
- function findPhpFiles(dir) {
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
- results.push(...findPhpFiles(full));
179
- } else if (entry.isFile() && entry.name.endsWith('.php')) {
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 walk = blocks => blocks.flatMap(block => [
12
- ...(block.isValid === false
13
- ? [{
14
- blockId: block.clientId,
15
- blockName: block.name,
16
- error: 'Block validation failed',
17
- validationIssues: (block.validationIssues ?? []).map(issue => {
18
- try {
19
- return (issue.args ?? [])
20
- .map(a => (typeof a === 'string' ? a : JSON.stringify(a)))
21
- .join(' ');
22
- } catch { return 'unknown issue'; }
23
- }),
24
- }]
25
- : []),
26
- ...walk(block.innerBlocks ?? []),
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');