@imwz/wp-pattern-sentinel 0.1.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 +154 -0
- package/bin/sentinel.js +7 -0
- package/package.json +39 -0
- package/src/args.js +162 -0
- package/src/editor.js +123 -0
- package/src/format.js +49 -0
- package/src/login.js +18 -0
- package/src/main.js +150 -0
- package/src/prompt.js +44 -0
- package/src/trellis.js +113 -0
- package/src/validation.js +65 -0
package/README.md
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# wp-pattern-sentinel
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
## Why browser-based?
|
|
6
|
+
|
|
7
|
+
WordPress block validation is a JavaScript concern. The editor's `save()` function can inject styles, reorder CSS classes, and drop attributes in ways that PHP cannot replicate. Only a real browser can catch these errors.
|
|
8
|
+
|
|
9
|
+
## Credentials
|
|
10
|
+
|
|
11
|
+
Credentials are resolved in this order — the first match wins:
|
|
12
|
+
|
|
13
|
+
1. **`--trellis` flag** — reads directly from Roots Trellis vault + `wordpress_sites.yml`
|
|
14
|
+
2. **CLI flags** — `--url`, `--user`, `--pass`
|
|
15
|
+
3. **Environment variables** — `WP_URL`, `WP_USER`, `WP_PASS`
|
|
16
|
+
4. **`.env` file** — placed in the directory where you run sentinel
|
|
17
|
+
5. **Interactive prompt** — sentinel asks if nothing else is set (password is masked)
|
|
18
|
+
|
|
19
|
+
`.env` is git-ignored. Never commit real credentials.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Roots Trellis integration
|
|
24
|
+
|
|
25
|
+
If your project uses [Roots Trellis](https://roots.io/trellis/), pass `--trellis` and sentinel reads everything it needs from the vault and `wordpress_sites.yml` — no manual credential setup required.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# Auto-detect site from cwd, use development env
|
|
29
|
+
sentinel --trellis path/to/patterns/
|
|
30
|
+
|
|
31
|
+
# Specify a site explicitly
|
|
32
|
+
sentinel --trellis --site=demo.imagewize.com path/to/patterns/
|
|
33
|
+
|
|
34
|
+
# Validate a multisite subsite
|
|
35
|
+
sentinel --trellis --site=demo.imagewize.com --subsite=store path/to/patterns/
|
|
36
|
+
|
|
37
|
+
# Staging or production vault
|
|
38
|
+
sentinel --trellis --env=staging --site=imagewize.com path/to/patterns/
|
|
39
|
+
|
|
40
|
+
# Explicit trellis directory (if auto-discovery fails)
|
|
41
|
+
sentinel --trellis --trellis-dir=/path/to/trellis path/to/patterns/
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
**Requirements:**
|
|
45
|
+
- `ansible-vault` installed (`brew install ansible` or `pip install ansible`)
|
|
46
|
+
- `trellis/.vault_pass` present (standard Trellis setup)
|
|
47
|
+
|
|
48
|
+
Sentinel auto-discovers the Trellis directory by walking up from the current working directory. It also auto-detects the site by matching cwd against each site's `local_path` in `wordpress_sites.yml`.
|
|
49
|
+
|
|
50
|
+
**Trellis flags:**
|
|
51
|
+
|
|
52
|
+
| Flag | Default | Description |
|
|
53
|
+
|------|---------|-------------|
|
|
54
|
+
| `--trellis` | — | Enable Trellis credential source |
|
|
55
|
+
| `--trellis-dir` | auto-discover | Path to your `trellis/` directory |
|
|
56
|
+
| `--site` | auto-detect from cwd | Site key, e.g. `demo.imagewize.com` |
|
|
57
|
+
| `--env` | `development` | Trellis environment (`development`, `staging`, `production`) |
|
|
58
|
+
| `--subsite` | — | Multisite subsite slug (appended to URL) |
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Quickstart with `.env`
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
cp .env.example .env
|
|
66
|
+
# edit .env with your site URL and admin credentials
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Install
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
npm install
|
|
75
|
+
npx playwright install chromium
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Usage
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
# Minimal — credentials come from .env
|
|
82
|
+
node bin/sentinel.js path/to/patterns/
|
|
83
|
+
|
|
84
|
+
# Validate a directory (credentials via flags)
|
|
85
|
+
node bin/sentinel.js \
|
|
86
|
+
--url=http://imagewize.test \
|
|
87
|
+
--user=admin \
|
|
88
|
+
--pass=secret \
|
|
89
|
+
path/to/patterns/
|
|
90
|
+
|
|
91
|
+
# Validate specific files
|
|
92
|
+
node bin/sentinel.js patterns/hero.php patterns/cta.php
|
|
93
|
+
|
|
94
|
+
# JSON output (one result object per line)
|
|
95
|
+
node bin/sentinel.js --json --url=... path/to/patterns/
|
|
96
|
+
|
|
97
|
+
# Keep draft pages in WordPress after validation
|
|
98
|
+
node bin/sentinel.js --keep-page --url=... path/to/patterns/
|
|
99
|
+
|
|
100
|
+
# Run headed (watch the browser)
|
|
101
|
+
node bin/sentinel.js --no-headless --url=... path/to/patterns/
|
|
102
|
+
|
|
103
|
+
# Adjust concurrency (default: 4)
|
|
104
|
+
node bin/sentinel.js --concurrency=6 --url=... path/to/patterns/
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Options
|
|
108
|
+
|
|
109
|
+
| Flag | Default | Description |
|
|
110
|
+
|------|---------|-------------|
|
|
111
|
+
| `--url` | `http://localhost` | WordPress site URL |
|
|
112
|
+
| `--user` | `admin` | Admin username |
|
|
113
|
+
| `--pass` | `password` | Admin password |
|
|
114
|
+
| `--headless` | `true` | Run browser headless |
|
|
115
|
+
| `--concurrency` | `4` | Parallel workers |
|
|
116
|
+
| `--json` | `false` | Output JSON (one result per line) |
|
|
117
|
+
| `--keep-page` | `false` | Don't delete draft pages after validation |
|
|
118
|
+
| `--width` | `1280` | Viewport width |
|
|
119
|
+
| `--height` | `800` | Viewport height |
|
|
120
|
+
|
|
121
|
+
## Architecture
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
bin/sentinel.js CLI entry point
|
|
125
|
+
src/
|
|
126
|
+
main.js Orchestration — context pool, p-queue, summary
|
|
127
|
+
login.js loginToWordPress()
|
|
128
|
+
editor.js createDraftPage, insertPatternIntoEditor, savePage, deletePage, extractBlockContent
|
|
129
|
+
validation.js checkBlockValidation, compareContent
|
|
130
|
+
args.js parseArgs, resolveFiles
|
|
131
|
+
format.js log, formatResult, printSummary
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Each worker gets its own authenticated `BrowserContext` so session failures are isolated. Login happens once per context before the queue starts.
|
|
135
|
+
|
|
136
|
+
## npm publish
|
|
137
|
+
|
|
138
|
+
When ready to publish:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
npm login
|
|
142
|
+
npm publish --access public
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Then use globally:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
npx wp-pattern-sentinel --url=http://imagewize.test --user=admin --pass=secret patterns/
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Exit codes
|
|
152
|
+
|
|
153
|
+
- `0` — all patterns passed
|
|
154
|
+
- `1` — one or more patterns failed
|
package/bin/sentinel.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@imwz/wp-pattern-sentinel",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Browser-based WordPress block pattern validator using Playwright",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=18"
|
|
8
|
+
},
|
|
9
|
+
"bin": {
|
|
10
|
+
"sentinel": "bin/sentinel.js"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"start": "node bin/sentinel.js"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"bin/",
|
|
17
|
+
"src/",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"keywords": [
|
|
21
|
+
"wordpress",
|
|
22
|
+
"gutenberg",
|
|
23
|
+
"block-patterns",
|
|
24
|
+
"playwright",
|
|
25
|
+
"validation"
|
|
26
|
+
],
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"js-yaml": "^4.1.0",
|
|
30
|
+
"p-queue": "^8.0.1",
|
|
31
|
+
"playwright": "^1.44.0"
|
|
32
|
+
},
|
|
33
|
+
"contributors": [
|
|
34
|
+
{
|
|
35
|
+
"name": "Jasper Frumau",
|
|
36
|
+
"email": "jasper@imagewize.com"
|
|
37
|
+
}
|
|
38
|
+
]
|
|
39
|
+
}
|
package/src/args.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { parseArgs as nodeParseArgs } from 'util';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import { prompt } from './prompt.js';
|
|
5
|
+
import { findTrellisDir, loadTrellisCredentials } from './trellis.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Credential resolution priority:
|
|
9
|
+
* 1. --trellis flag → reads Roots Trellis vault + wordpress_sites.yml
|
|
10
|
+
* 2. CLI flags → --url, --user, --pass
|
|
11
|
+
* 3. Env vars → WP_URL, WP_USER, WP_PASS
|
|
12
|
+
* 4. .env file → loaded from cwd automatically
|
|
13
|
+
* 5. Interactive → prompted if still missing (password is masked)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const ARG_OPTIONS = {
|
|
17
|
+
// Credentials
|
|
18
|
+
url: { type: 'string' },
|
|
19
|
+
user: { type: 'string' },
|
|
20
|
+
pass: { type: 'string' },
|
|
21
|
+
// Trellis integration
|
|
22
|
+
trellis: { type: 'boolean', default: false },
|
|
23
|
+
'trellis-dir': { type: 'string' },
|
|
24
|
+
site: { type: 'string' },
|
|
25
|
+
env: { type: 'string', default: 'development' },
|
|
26
|
+
subsite: { type: 'string' },
|
|
27
|
+
// Behaviour
|
|
28
|
+
headless: { type: 'boolean', default: true },
|
|
29
|
+
json: { type: 'boolean', default: false },
|
|
30
|
+
'keep-page': { type: 'boolean', default: false },
|
|
31
|
+
concurrency: { type: 'string', default: '4' },
|
|
32
|
+
width: { type: 'string', default: '1280' },
|
|
33
|
+
height: { type: 'string', default: '800' },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Parse a .env file and add any keys not already in process.env.
|
|
38
|
+
* Supports KEY=value, KEY="value", KEY='value', and # comments.
|
|
39
|
+
* Silently skips if no .env file exists.
|
|
40
|
+
*/
|
|
41
|
+
function loadDotEnv() {
|
|
42
|
+
const envPath = path.join(process.cwd(), '.env');
|
|
43
|
+
try {
|
|
44
|
+
const content = fs.readFileSync(envPath, 'utf8');
|
|
45
|
+
for (const line of content.split('\n')) {
|
|
46
|
+
const trimmed = line.trim();
|
|
47
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
48
|
+
const eq = trimmed.indexOf('=');
|
|
49
|
+
if (eq === -1) continue;
|
|
50
|
+
const key = trimmed.slice(0, eq).trim();
|
|
51
|
+
const val = trimmed.slice(eq + 1).trim().replace(/^["']|["']$/g, '');
|
|
52
|
+
if (!(key in process.env)) process.env[key] = val;
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// No .env — that's fine
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function parseArgs(args) {
|
|
60
|
+
loadDotEnv();
|
|
61
|
+
|
|
62
|
+
const { values, positionals } = nodeParseArgs({
|
|
63
|
+
args,
|
|
64
|
+
options: ARG_OPTIONS,
|
|
65
|
+
allowPositionals: true,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
let url, user, pass;
|
|
69
|
+
|
|
70
|
+
// --- Source 1: Trellis vault ---
|
|
71
|
+
if (values.trellis) {
|
|
72
|
+
const trellisDir = values['trellis-dir']
|
|
73
|
+
? path.resolve(values['trellis-dir'])
|
|
74
|
+
: findTrellisDir();
|
|
75
|
+
|
|
76
|
+
({ url, user, pass } = loadTrellisCredentials({
|
|
77
|
+
trellisDir,
|
|
78
|
+
site: values.site,
|
|
79
|
+
env: values.env,
|
|
80
|
+
subsite: values.subsite ?? null,
|
|
81
|
+
}));
|
|
82
|
+
|
|
83
|
+
} else {
|
|
84
|
+
// --- Source 2: CLI flags ---
|
|
85
|
+
url = values.url;
|
|
86
|
+
user = values.user;
|
|
87
|
+
pass = values.pass;
|
|
88
|
+
|
|
89
|
+
// --- Source 3+4: Env vars / .env file ---
|
|
90
|
+
url ??= process.env.WP_URL;
|
|
91
|
+
user ??= process.env.WP_USER;
|
|
92
|
+
pass ??= process.env.WP_PASS;
|
|
93
|
+
|
|
94
|
+
// --- Source 5: Interactive prompt ---
|
|
95
|
+
if (!url) {
|
|
96
|
+
url = await prompt('WordPress URL (e.g. http://site.test): ');
|
|
97
|
+
if (!url) throw new Error('WordPress URL is required.');
|
|
98
|
+
}
|
|
99
|
+
if (!user) {
|
|
100
|
+
user = await prompt('WordPress admin username: ');
|
|
101
|
+
if (!user) throw new Error('WordPress username is required.');
|
|
102
|
+
}
|
|
103
|
+
if (!pass) {
|
|
104
|
+
pass = await prompt('WordPress admin password: ', { hidden: true });
|
|
105
|
+
if (!pass) throw new Error('WordPress password is required.');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
url: url.replace(/\/$/, ''),
|
|
111
|
+
user,
|
|
112
|
+
pass,
|
|
113
|
+
headless: values.headless,
|
|
114
|
+
json: values.json,
|
|
115
|
+
keepPage: values['keep-page'],
|
|
116
|
+
concurrency: Math.max(1, parseInt(values.concurrency, 10)),
|
|
117
|
+
viewport: {
|
|
118
|
+
width: parseInt(values.width, 10),
|
|
119
|
+
height: parseInt(values.height, 10),
|
|
120
|
+
},
|
|
121
|
+
files: positionals,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function resolveFiles(filePaths) {
|
|
126
|
+
if (!filePaths || filePaths.length === 0) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
'No pattern files specified.\n' +
|
|
129
|
+
'Usage: sentinel [--trellis [--site=example.com]] path/to/patterns/\n' +
|
|
130
|
+
'Or set WP_URL, WP_USER, WP_PASS in .env'
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const resolved = [];
|
|
135
|
+
for (const filePath of filePaths) {
|
|
136
|
+
const abs = path.resolve(filePath);
|
|
137
|
+
if (!fs.existsSync(abs)) {
|
|
138
|
+
console.warn(`Warning: path not found — ${filePath}`);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const stat = fs.statSync(abs);
|
|
142
|
+
if (stat.isDirectory()) {
|
|
143
|
+
resolved.push(...findPhpFiles(abs));
|
|
144
|
+
} else {
|
|
145
|
+
resolved.push(abs);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return [...new Set(resolved)];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function findPhpFiles(dir) {
|
|
152
|
+
const results = [];
|
|
153
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
154
|
+
const full = path.join(dir, entry.name);
|
|
155
|
+
if (entry.isDirectory()) {
|
|
156
|
+
results.push(...findPhpFiles(full));
|
|
157
|
+
} else if (entry.isFile() && entry.name.endsWith('.php')) {
|
|
158
|
+
results.push(full);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return results;
|
|
162
|
+
}
|
package/src/editor.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { log } from './format.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Strip the PHP file header (opening tag + docblock) and return the raw block markup.
|
|
5
|
+
* Returns null if no block comment is found.
|
|
6
|
+
*
|
|
7
|
+
* WordPress pattern files look like:
|
|
8
|
+
* <?php
|
|
9
|
+
* /**
|
|
10
|
+
* * Title: My Pattern
|
|
11
|
+
* * ...
|
|
12
|
+
* *\/
|
|
13
|
+
* ?>
|
|
14
|
+
* <!-- wp:group -->...
|
|
15
|
+
*/
|
|
16
|
+
export function extractBlockContent(fileContent) {
|
|
17
|
+
const stripped = fileContent
|
|
18
|
+
.replace(/^<\?php\s*/m, '')
|
|
19
|
+
.replace(/\/\*\*[\s\S]*?\*\//m, '')
|
|
20
|
+
.replace(/^\s*\?>\s*/m, '')
|
|
21
|
+
.trim();
|
|
22
|
+
|
|
23
|
+
if (!stripped.startsWith('<!--')) return null;
|
|
24
|
+
return stripped;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Navigate to a new draft page and return its post ID.
|
|
29
|
+
* WordPress redirects post-new.php → post.php?post=ID&action=edit,
|
|
30
|
+
* so we can read the ID directly from the final URL.
|
|
31
|
+
*/
|
|
32
|
+
export async function createDraftPage(page, baseUrl) {
|
|
33
|
+
try {
|
|
34
|
+
await page.goto(`${baseUrl}/wp-admin/post-new.php?post_type=page`, {
|
|
35
|
+
waitUntil: 'domcontentloaded',
|
|
36
|
+
timeout: 30000,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
await page.waitForSelector('.edit-post-layout, .editor-styles-wrapper', {
|
|
40
|
+
timeout: 30000,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const url = page.url();
|
|
44
|
+
const match = url.match(/[?&]post=(\d+)/);
|
|
45
|
+
if (match) return parseInt(match[1], 10);
|
|
46
|
+
|
|
47
|
+
// Fallback: read from wp.data (editor may not have redirected yet)
|
|
48
|
+
return await page.evaluate(() =>
|
|
49
|
+
window.wp?.data?.select('core/editor')?.getCurrentPostId?.() ?? null
|
|
50
|
+
);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
log(`Failed to create draft page: ${error.message}`, 'red');
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Set the editor content via wp.data and wait for blocks to parse.
|
|
59
|
+
*/
|
|
60
|
+
export async function insertPatternIntoEditor(page, blockContent) {
|
|
61
|
+
try {
|
|
62
|
+
await page.evaluate(content => {
|
|
63
|
+
window.wp.data.dispatch('core/editor').editPost({ content });
|
|
64
|
+
}, blockContent);
|
|
65
|
+
|
|
66
|
+
// Wait until at least one block is present
|
|
67
|
+
await page.waitForFunction(
|
|
68
|
+
() => window.wp.data.select('core/block-editor').getBlocks().length > 0,
|
|
69
|
+
{ timeout: 15000 }
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
return true;
|
|
73
|
+
} catch (error) {
|
|
74
|
+
log(`Failed to insert pattern: ${error.message}`, 'red');
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Trigger savePost() and wait for the editor to finish saving.
|
|
81
|
+
*/
|
|
82
|
+
export async function savePage(page) {
|
|
83
|
+
const result = { success: false, errors: [], warnings: [] };
|
|
84
|
+
try {
|
|
85
|
+
await page.evaluate(() => window.wp.data.dispatch('core/editor').savePost());
|
|
86
|
+
|
|
87
|
+
// Wait for save to start, then finish
|
|
88
|
+
await page
|
|
89
|
+
.waitForFunction(
|
|
90
|
+
() => window.wp.data.select('core/editor').isSavingPost(),
|
|
91
|
+
{ timeout: 5000 }
|
|
92
|
+
)
|
|
93
|
+
.catch(() => {}); // save might be near-instant
|
|
94
|
+
|
|
95
|
+
await page.waitForFunction(
|
|
96
|
+
() => !window.wp.data.select('core/editor').isSavingPost(),
|
|
97
|
+
{ timeout: 30000 }
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
result.success = true;
|
|
101
|
+
} catch (error) {
|
|
102
|
+
result.errors.push({ type: 'save_error', message: error.message });
|
|
103
|
+
}
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Delete the draft page via the WP REST API (uses the active browser session's nonce).
|
|
109
|
+
* Non-fatal — a failure here does not affect validation results.
|
|
110
|
+
*/
|
|
111
|
+
export async function deletePage(page, baseUrl, pageId) {
|
|
112
|
+
try {
|
|
113
|
+
await page.evaluate(async id => {
|
|
114
|
+
const nonce = window.wpApiSettings?.nonce ?? '';
|
|
115
|
+
await fetch(`/wp-json/wp/v2/pages/${id}?force=true`, {
|
|
116
|
+
method: 'DELETE',
|
|
117
|
+
headers: { 'X-WP-Nonce': nonce },
|
|
118
|
+
});
|
|
119
|
+
}, pageId);
|
|
120
|
+
} catch {
|
|
121
|
+
// Non-fatal
|
|
122
|
+
}
|
|
123
|
+
}
|
package/src/format.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const C = {
|
|
2
|
+
reset: '\x1b[0m',
|
|
3
|
+
red: '\x1b[31m',
|
|
4
|
+
green: '\x1b[32m',
|
|
5
|
+
yellow: '\x1b[33m',
|
|
6
|
+
cyan: '\x1b[36m',
|
|
7
|
+
gray: '\x1b[90m',
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function log(message, color = 'reset') {
|
|
11
|
+
console.log(`${C[color] ?? C.reset}${message}${C.reset}`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function formatResult(result) {
|
|
15
|
+
const status = result.passed
|
|
16
|
+
? `${C.green}✓ PASS${C.reset}`
|
|
17
|
+
: `${C.red}✗ FAIL${C.reset}`;
|
|
18
|
+
|
|
19
|
+
const lines = [`${status} ${result.pattern} (${result.duration}ms)`];
|
|
20
|
+
|
|
21
|
+
for (const err of result.errors) {
|
|
22
|
+
lines.push(` ${C.red}ERROR${C.reset} [${err.type}] ${err.message}`);
|
|
23
|
+
}
|
|
24
|
+
for (const warn of result.warnings) {
|
|
25
|
+
lines.push(` ${C.yellow}WARN${C.reset} [${warn.type}] ${warn.message}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return lines.join('\n');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function printSummary(results) {
|
|
32
|
+
const passed = results.filter(r => r.passed).length;
|
|
33
|
+
const failed = results.filter(r => !r.passed).length;
|
|
34
|
+
const totalErrors = results.reduce((n, r) => n + r.errors.length, 0);
|
|
35
|
+
const totalWarns = results.reduce((n, r) => n + r.warnings.length, 0);
|
|
36
|
+
const totalMs = results.reduce((n, r) => n + r.duration, 0);
|
|
37
|
+
const avgMs = Math.round(totalMs / results.length);
|
|
38
|
+
|
|
39
|
+
log('\n' + '='.repeat(60));
|
|
40
|
+
log('Validation Summary', 'cyan');
|
|
41
|
+
log('='.repeat(60));
|
|
42
|
+
log(`Patterns : ${results.length}`);
|
|
43
|
+
log(`Passed : ${passed}`, passed > 0 ? 'green' : 'gray');
|
|
44
|
+
log(`Failed : ${failed}`, failed > 0 ? 'red' : 'gray');
|
|
45
|
+
log(`Errors : ${totalErrors}`, totalErrors > 0 ? 'red' : 'gray');
|
|
46
|
+
log(`Warnings : ${totalWarns}`, totalWarns > 0 ? 'yellow' : 'gray');
|
|
47
|
+
log(`Time : ${totalMs}ms (avg ${avgMs}ms/pattern)`);
|
|
48
|
+
console.log('');
|
|
49
|
+
}
|
package/src/login.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { log } from './format.js';
|
|
2
|
+
|
|
3
|
+
export async function loginToWordPress(page, url, user, pass) {
|
|
4
|
+
try {
|
|
5
|
+
await page.goto(`${url}/wp-login.php`, {
|
|
6
|
+
waitUntil: 'domcontentloaded',
|
|
7
|
+
timeout: 30000,
|
|
8
|
+
});
|
|
9
|
+
await page.fill('#user_login', user);
|
|
10
|
+
await page.fill('#user_pass', pass);
|
|
11
|
+
await page.click('#wp-submit');
|
|
12
|
+
await page.waitForURL(`${url}/wp-admin/**`, { timeout: 15000 });
|
|
13
|
+
return true;
|
|
14
|
+
} catch (error) {
|
|
15
|
+
log(`Login failed: ${error.message}`, 'red');
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
package/src/main.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { chromium } from 'playwright';
|
|
2
|
+
import PQueue from 'p-queue';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import { parseArgs, resolveFiles } from './args.js';
|
|
6
|
+
import { loginToWordPress } from './login.js';
|
|
7
|
+
import {
|
|
8
|
+
extractBlockContent,
|
|
9
|
+
createDraftPage,
|
|
10
|
+
insertPatternIntoEditor,
|
|
11
|
+
savePage,
|
|
12
|
+
deletePage,
|
|
13
|
+
} from './editor.js';
|
|
14
|
+
import { checkBlockValidation, compareContent } from './validation.js';
|
|
15
|
+
import { log, formatResult, printSummary } from './format.js';
|
|
16
|
+
|
|
17
|
+
export async function main() {
|
|
18
|
+
const options = await parseArgs(process.argv.slice(2));
|
|
19
|
+
const files = resolveFiles(options.files);
|
|
20
|
+
|
|
21
|
+
if (files.length === 0) {
|
|
22
|
+
log('No pattern files found.', 'red');
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
log(`\nFound ${files.length} pattern(s) — concurrency: ${options.concurrency}`, 'cyan');
|
|
27
|
+
|
|
28
|
+
const browser = await chromium.launch({
|
|
29
|
+
headless: options.headless,
|
|
30
|
+
args: ['--disable-web-security'],
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// One authenticated context per worker — sessions are fully isolated
|
|
34
|
+
let contexts;
|
|
35
|
+
try {
|
|
36
|
+
contexts = await Promise.all(
|
|
37
|
+
Array.from({ length: options.concurrency }, async () => {
|
|
38
|
+
const context = await browser.newContext({ viewport: options.viewport });
|
|
39
|
+
const page = await context.newPage();
|
|
40
|
+
page.setDefaultTimeout(60000);
|
|
41
|
+
const ok = await loginToWordPress(page, options.url, options.user, options.pass);
|
|
42
|
+
await page.close();
|
|
43
|
+
if (!ok) throw new Error('Failed to authenticate with WordPress');
|
|
44
|
+
return context;
|
|
45
|
+
})
|
|
46
|
+
);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
log(error.message, 'red');
|
|
49
|
+
await browser.close();
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const queue = new PQueue({ concurrency: options.concurrency });
|
|
54
|
+
let idx = 0;
|
|
55
|
+
|
|
56
|
+
// Push ALL tasks without awaiting — this is what makes workers run in parallel
|
|
57
|
+
const promises = files.map(file => {
|
|
58
|
+
const context = contexts[idx++ % options.concurrency];
|
|
59
|
+
return queue.add(() => validatePatternFile(file, options, context));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const results = await Promise.all(promises);
|
|
63
|
+
|
|
64
|
+
await Promise.all(contexts.map(c => c.close()));
|
|
65
|
+
await browser.close();
|
|
66
|
+
|
|
67
|
+
if (options.json) {
|
|
68
|
+
for (const r of results) console.log(JSON.stringify(r));
|
|
69
|
+
} else {
|
|
70
|
+
for (const r of results) console.log(formatResult(r));
|
|
71
|
+
printSummary(results);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
process.exit(results.some(r => !r.passed) ? 1 : 0);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function validatePatternFile(patternPath, options, context) {
|
|
78
|
+
const patternName = path.basename(patternPath);
|
|
79
|
+
const startTime = Date.now();
|
|
80
|
+
|
|
81
|
+
let fileContent;
|
|
82
|
+
try {
|
|
83
|
+
fileContent = await fs.promises.readFile(patternPath, 'utf8');
|
|
84
|
+
} catch (error) {
|
|
85
|
+
return fail(patternName, startTime, 'file_error', error.message);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const blockContent = extractBlockContent(fileContent);
|
|
89
|
+
if (!blockContent) {
|
|
90
|
+
return fail(patternName, startTime, 'extraction_error', 'Could not extract block content from file');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const page = await context.newPage();
|
|
94
|
+
page.setDefaultTimeout(60000);
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const pageId = await createDraftPage(page, options.url);
|
|
98
|
+
if (pageId === null) {
|
|
99
|
+
return fail(patternName, startTime, 'page_creation_error', 'Failed to create test page');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!(await insertPatternIntoEditor(page, blockContent))) {
|
|
103
|
+
await deletePage(page, options.url, pageId);
|
|
104
|
+
return fail(patternName, startTime, 'insertion_error', 'Failed to insert pattern into editor');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const saveResult = await savePage(page);
|
|
108
|
+
const blockErrors = await checkBlockValidation(page);
|
|
109
|
+
|
|
110
|
+
if (blockErrors.length > 0) {
|
|
111
|
+
saveResult.errors.push(...blockErrors.map(e => ({
|
|
112
|
+
type: 'block_validation',
|
|
113
|
+
message: `${e.blockName} (${e.blockId}): ${e.error}`,
|
|
114
|
+
})));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const comparison = await compareContent(page, blockContent);
|
|
118
|
+
saveResult.errors.push(...comparison.errors);
|
|
119
|
+
saveResult.warnings.push(...comparison.warnings);
|
|
120
|
+
|
|
121
|
+
if (!options.keepPage) {
|
|
122
|
+
await deletePage(page, options.url, pageId);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
pattern: patternName,
|
|
127
|
+
passed: saveResult.success && comparison.matches && blockErrors.length === 0,
|
|
128
|
+
errors: saveResult.errors,
|
|
129
|
+
warnings: saveResult.warnings,
|
|
130
|
+
duration: Date.now() - startTime,
|
|
131
|
+
savedContent: comparison.savedContent,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
} catch (error) {
|
|
135
|
+
log(`Unexpected error — ${patternName}: ${error.message}`, 'red');
|
|
136
|
+
return fail(patternName, startTime, 'validation_error', error.message);
|
|
137
|
+
} finally {
|
|
138
|
+
await page.close();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function fail(pattern, startTime, type, message) {
|
|
143
|
+
return {
|
|
144
|
+
pattern,
|
|
145
|
+
passed: false,
|
|
146
|
+
errors: [{ type, message }],
|
|
147
|
+
warnings: [],
|
|
148
|
+
duration: Date.now() - startTime,
|
|
149
|
+
};
|
|
150
|
+
}
|
package/src/prompt.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { createInterface } from 'readline';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Prompt the user for a value. Pass hidden: true for passwords
|
|
5
|
+
* (masks input with asterisks).
|
|
6
|
+
*/
|
|
7
|
+
export function prompt(question, { hidden = false } = {}) {
|
|
8
|
+
return new Promise(resolve => {
|
|
9
|
+
if (!hidden) {
|
|
10
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
11
|
+
rl.question(question, answer => { rl.close(); resolve(answer); });
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
process.stdout.write(question);
|
|
16
|
+
process.stdin.setRawMode(true);
|
|
17
|
+
process.stdin.resume();
|
|
18
|
+
process.stdin.setEncoding('utf8');
|
|
19
|
+
|
|
20
|
+
let value = '';
|
|
21
|
+
|
|
22
|
+
const onData = char => {
|
|
23
|
+
if (char === '\r' || char === '\n' || char === '') {
|
|
24
|
+
process.stdin.setRawMode(false);
|
|
25
|
+
process.stdin.pause();
|
|
26
|
+
process.stdin.removeListener('data', onData);
|
|
27
|
+
process.stdout.write('\n');
|
|
28
|
+
resolve(value);
|
|
29
|
+
} else if (char === '' || char === '') {
|
|
30
|
+
if (value.length > 0) {
|
|
31
|
+
value = value.slice(0, -1);
|
|
32
|
+
process.stdout.clearLine(0);
|
|
33
|
+
process.stdout.cursorTo(0);
|
|
34
|
+
process.stdout.write(question + '*'.repeat(value.length));
|
|
35
|
+
}
|
|
36
|
+
} else {
|
|
37
|
+
value += char;
|
|
38
|
+
process.stdout.write('*');
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
process.stdin.on('data', onData);
|
|
43
|
+
});
|
|
44
|
+
}
|
package/src/trellis.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import yaml from 'js-yaml';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Walk up from startDir looking for a directory that contains group_vars/
|
|
8
|
+
* (the Trellis signature). Checks both the dir itself and a trellis/ subdir.
|
|
9
|
+
*/
|
|
10
|
+
export function findTrellisDir(startDir = process.cwd()) {
|
|
11
|
+
let dir = path.resolve(startDir);
|
|
12
|
+
|
|
13
|
+
for (let i = 0; i < 10; i++) {
|
|
14
|
+
// Direct match (we're already inside the trellis dir)
|
|
15
|
+
if (fs.existsSync(path.join(dir, 'group_vars'))) return dir;
|
|
16
|
+
|
|
17
|
+
// Sibling trellis/ directory
|
|
18
|
+
const sibling = path.join(dir, 'trellis');
|
|
19
|
+
if (fs.existsSync(path.join(sibling, 'group_vars'))) return sibling;
|
|
20
|
+
|
|
21
|
+
const parent = path.dirname(dir);
|
|
22
|
+
if (parent === dir) break;
|
|
23
|
+
dir = parent;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
throw new Error(
|
|
27
|
+
'Could not find Trellis directory (looking for group_vars/ marker).\n' +
|
|
28
|
+
'Run sentinel from inside your project, or pass --trellis-dir=/path/to/trellis.'
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Try to match the current working directory against a site's local_path
|
|
34
|
+
* to auto-select which site to validate. Returns the site key or null.
|
|
35
|
+
*
|
|
36
|
+
* local_path in wordpress_sites.yml is relative to the trellis directory.
|
|
37
|
+
*/
|
|
38
|
+
function detectSiteFromCwd(trellisDir, sites) {
|
|
39
|
+
const cwd = process.cwd();
|
|
40
|
+
for (const [key, config] of Object.entries(sites)) {
|
|
41
|
+
if (!config.local_path) continue;
|
|
42
|
+
const abs = path.resolve(trellisDir, config.local_path);
|
|
43
|
+
if (cwd.startsWith(abs)) return key;
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Load credentials from Trellis vault and wordpress_sites.yml.
|
|
50
|
+
*
|
|
51
|
+
* Options:
|
|
52
|
+
* trellisDir — absolute path to the trellis directory
|
|
53
|
+
* site — site key, e.g. "demo.imagewize.com" (auto-detected if omitted)
|
|
54
|
+
* env — "development" | "staging" | "production" (default: "development")
|
|
55
|
+
* subsite — multisite subsite slug appended to the URL (e.g. "store")
|
|
56
|
+
*
|
|
57
|
+
* Returns { url, user, pass }
|
|
58
|
+
*/
|
|
59
|
+
export function loadTrellisCredentials({ trellisDir, site, env = 'development', subsite = null }) {
|
|
60
|
+
const vaultFile = path.join(trellisDir, 'group_vars', env, 'vault.yml');
|
|
61
|
+
const vaultPassFile = path.join(trellisDir, '.vault_pass');
|
|
62
|
+
const sitesFile = path.join(trellisDir, 'group_vars', env, 'wordpress_sites.yml');
|
|
63
|
+
|
|
64
|
+
for (const [label, p] of [['Vault password file', vaultPassFile], ['Sites file', sitesFile]]) {
|
|
65
|
+
if (!fs.existsSync(p)) throw new Error(`${label} not found: ${p}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Decrypt vault with ansible-vault
|
|
69
|
+
let vaultContent;
|
|
70
|
+
try {
|
|
71
|
+
vaultContent = execSync(
|
|
72
|
+
`ansible-vault view --vault-password-file "${vaultPassFile}" "${vaultFile}"`,
|
|
73
|
+
{ cwd: trellisDir, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
74
|
+
);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
throw new Error(`Failed to decrypt Trellis vault: ${err.message}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const vault = yaml.load(vaultContent);
|
|
80
|
+
const sites = yaml.load(fs.readFileSync(sitesFile, 'utf8'));
|
|
81
|
+
|
|
82
|
+
const vaultSites = vault.vault_wordpress_sites ?? {};
|
|
83
|
+
const wordpressSites = sites.wordpress_sites ?? {};
|
|
84
|
+
|
|
85
|
+
// Auto-detect site from cwd, then fall back to first site in the file
|
|
86
|
+
const siteKey = site
|
|
87
|
+
?? detectSiteFromCwd(trellisDir, wordpressSites)
|
|
88
|
+
?? Object.keys(wordpressSites)[0];
|
|
89
|
+
|
|
90
|
+
if (!vaultSites[siteKey]) {
|
|
91
|
+
const available = Object.keys(vaultSites).join(', ');
|
|
92
|
+
throw new Error(`Site "${siteKey}" not found in vault. Available: ${available}`);
|
|
93
|
+
}
|
|
94
|
+
if (!wordpressSites[siteKey]) {
|
|
95
|
+
throw new Error(`Site "${siteKey}" not found in wordpress_sites.yml`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const adminPassword = vaultSites[siteKey].admin_password;
|
|
99
|
+
if (!adminPassword) {
|
|
100
|
+
throw new Error(`No admin_password for "${siteKey}" in vault`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Build URL from canonical hostname + SSL setting
|
|
104
|
+
const canonical = wordpressSites[siteKey].site_hosts[0].canonical;
|
|
105
|
+
const ssl = wordpressSites[siteKey].ssl?.enabled ?? false;
|
|
106
|
+
const protocol = ssl ? 'https' : 'http';
|
|
107
|
+
const url = subsite
|
|
108
|
+
? `${protocol}://${canonical}/${subsite}`
|
|
109
|
+
: `${protocol}://${canonical}`;
|
|
110
|
+
|
|
111
|
+
// Trellis admin username is always "admin" — not stored in the vault
|
|
112
|
+
return { url, user: 'admin', pass: adminPassword };
|
|
113
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { log } from './format.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Walk the block tree and collect any blocks where isValid === false.
|
|
5
|
+
*/
|
|
6
|
+
export async function checkBlockValidation(page) {
|
|
7
|
+
try {
|
|
8
|
+
return await page.evaluate(() => {
|
|
9
|
+
const walk = blocks => blocks.flatMap(block => [
|
|
10
|
+
...(block.isValid === false
|
|
11
|
+
? [{ blockId: block.clientId, blockName: block.name, error: 'Block validation failed' }]
|
|
12
|
+
: []),
|
|
13
|
+
...walk(block.innerBlocks ?? []),
|
|
14
|
+
]);
|
|
15
|
+
return walk(window.wp.data.select('core/block-editor').getBlocks());
|
|
16
|
+
});
|
|
17
|
+
} catch (error) {
|
|
18
|
+
log(`Block validation check error: ${error.message}`, 'yellow');
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Compare the editor's serialized output against the original source.
|
|
25
|
+
* Whitespace-normalizes both sides before diffing to avoid false positives
|
|
26
|
+
* from indentation changes, then surfaces up to 5 added/removed lines.
|
|
27
|
+
*/
|
|
28
|
+
export async function compareContent(page, originalContent) {
|
|
29
|
+
const result = { matches: true, errors: [], warnings: [], savedContent: null };
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const savedContent = await page.evaluate(() =>
|
|
33
|
+
window.wp.data.select('core/editor').getEditedPostContent()
|
|
34
|
+
);
|
|
35
|
+
result.savedContent = savedContent;
|
|
36
|
+
|
|
37
|
+
const normalize = str => str.replace(/\s+/g, ' ').trim();
|
|
38
|
+
if (normalize(savedContent) === normalize(originalContent)) return result;
|
|
39
|
+
|
|
40
|
+
result.matches = false;
|
|
41
|
+
|
|
42
|
+
const origLines = originalContent.split('\n').map(l => l.trim()).filter(Boolean);
|
|
43
|
+
const savedLines = savedContent.split('\n').map(l => l.trim()).filter(Boolean);
|
|
44
|
+
|
|
45
|
+
const removed = origLines.filter(l => !savedLines.includes(l)).slice(0, 5);
|
|
46
|
+
const added = savedLines.filter(l => !origLines.includes(l)).slice(0, 5);
|
|
47
|
+
|
|
48
|
+
if (removed.length > 0) {
|
|
49
|
+
result.errors.push({
|
|
50
|
+
type: 'content_mismatch',
|
|
51
|
+
message: `Content removed by editor:\n ${removed.join('\n ')}`,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
if (added.length > 0) {
|
|
55
|
+
result.warnings.push({
|
|
56
|
+
type: 'content_injected',
|
|
57
|
+
message: `Content injected by editor:\n ${added.join('\n ')}`,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
} catch (error) {
|
|
61
|
+
result.errors.push({ type: 'comparison_error', message: error.message });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return result;
|
|
65
|
+
}
|