@birdinternet/wp-nested-patterns 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Doug Johnson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,235 @@
1
+ # wp-nested-patterns
2
+
3
+ Compile WordPress block patterns that reference each other into flat,
4
+ WP-ready output.
5
+
6
+ WordPress's `core/pattern` block lets one pattern insert another by slug —
7
+ but only at *render time*, and only in contexts the block editor actually
8
+ supports. Reference a pattern from inside `core/post-template` (a query
9
+ loop), for instance, and it silently fails to resolve. `wp-nested-patterns`
10
+ solves this by resolving those references at **build time** instead: it
11
+ walks your source patterns, inlines every `<!-- wp:pattern {"slug":"..."} /-->`
12
+ reference into the referencing block tree, and writes out plain block markup
13
+ that WordPress can render anywhere, including inside query loops. The
14
+ runtime never sees a `wp:pattern` reference, so the render-context
15
+ limitation goes away entirely.
16
+
17
+ - **Recursive** — patterns can reference patterns, resolved depth-first, with cycle detection.
18
+ - **Attribute overrides** — a reference can pass `srcAttributes` to override attributes on the resolved pattern's first top-level block, so one source pattern can serve multiple call sites (e.g. an inheriting query vs. a fixed-query variant).
19
+ - **Validated output** — source and compiled markup are both checked for balanced HTML; a malformed pattern fails the build instead of producing a broken block WordPress can't parse.
20
+
21
+ ## Install
22
+
23
+ ```sh
24
+ npm install @birdinternet/wp-nested-patterns
25
+ ```
26
+
27
+ ## Project layout
28
+
29
+ `wp-nested-patterns` compiles a `src/` directory into a matching directory
30
+ next to it — the same convention WordPress block themes already use:
31
+
32
+ ```
33
+ your-theme/
34
+ src/
35
+ patterns/ *.php — Slug-headed patterns, referenceable via wp:pattern
36
+ templates/ *.html
37
+ parts/ *.html
38
+ patterns/ ← compiled output (generated — don't edit)
39
+ templates/ ← compiled output (generated — don't edit)
40
+ parts/ ← compiled output (generated — don't edit)
41
+ ```
42
+
43
+ Each PHP pattern file needs a `Slug:` header, exactly like a normal WP block
44
+ pattern:
45
+
46
+ ```php
47
+ <?php
48
+ /**
49
+ * Title: Card
50
+ * Slug: my-theme/card
51
+ * Categories: text
52
+ */
53
+ ?>
54
+ <!-- wp:group -->
55
+ <div class="wp-block-group"><!-- wp:paragraph --><p>Hello</p><!-- /wp:paragraph --></div>
56
+ <!-- /wp:group -->
57
+ ```
58
+
59
+ Reference it from any other pattern, template, or template part:
60
+
61
+ ```html
62
+ <!-- wp:pattern {"slug":"my-theme/card"} /-->
63
+ ```
64
+
65
+ Optionally override attributes on the resolved pattern's first top-level
66
+ block (deep-merged, so unmentioned keys are preserved):
67
+
68
+ ```html
69
+ <!-- wp:pattern {"slug":"my-theme/query","srcAttributes":{"queryId":1,"query":{"inherit":false}}} /-->
70
+ ```
71
+
72
+ ## Usage
73
+
74
+ ### Standalone CLI
75
+
76
+ No build tool required:
77
+
78
+ ```sh
79
+ npx @birdinternet/wp-nested-patterns --src src --dest . --watch
80
+ ```
81
+
82
+ ```
83
+ Usage:
84
+ wp-nested-patterns [--src <dir>] [--dest <dir>] [--watch] [--config <file>]
85
+
86
+ Options:
87
+ --src <dir> Source directory containing patterns/templates/parts (default: "src")
88
+ --dest <dir> Destination root the compiled dirs are written into (default: ".")
89
+ --watch, -w Rebuild on every source change
90
+ --config, -c Path to a config file exporting { srcRoot, destRoot, dirs }
91
+ --help, -h Show this help
92
+ ```
93
+
94
+ If `wp-nested-patterns.config.mjs` exists in the current directory it's
95
+ loaded automatically — see [`wp-nested-patterns.config.example.mjs`](./wp-nested-patterns.config.example.mjs).
96
+
97
+ Add it to `package.json` scripts:
98
+
99
+ ```json
100
+ {
101
+ "scripts": {
102
+ "patterns:build": "wp-nested-patterns --src wp-content/themes/my-theme/src --dest wp-content/themes/my-theme",
103
+ "patterns:watch": "npm run patterns:build -- --watch"
104
+ }
105
+ }
106
+ ```
107
+
108
+ ### Vite
109
+
110
+ ```js
111
+ // vite.config.js
112
+ import { defineConfig } from 'vite';
113
+ import wpNestedPatterns from '@birdinternet/wp-nested-patterns/vite';
114
+
115
+ export default defineConfig({
116
+ plugins: [
117
+ wpNestedPatterns({
118
+ srcRoot: 'wp-content/themes/my-theme/src',
119
+ destRoot: 'wp-content/themes/my-theme',
120
+ }),
121
+ ],
122
+ });
123
+ ```
124
+
125
+ Compiles once at `buildStart` for production builds; under `vite dev`, it
126
+ also watches `srcRoot` (via Vite's own file watcher) and recompiles on every
127
+ change.
128
+
129
+ ### esbuild
130
+
131
+ ```js
132
+ import * as esbuild from 'esbuild';
133
+ import { wpNestedPatternsPlugin } from '@birdinternet/wp-nested-patterns/esbuild';
134
+
135
+ await esbuild.build({
136
+ // ...your existing esbuild config
137
+ plugins: [
138
+ wpNestedPatternsPlugin({
139
+ srcRoot: 'wp-content/themes/my-theme/src',
140
+ destRoot: 'wp-content/themes/my-theme',
141
+ watch: true, // recompile on source changes while an esbuild context stays open
142
+ }),
143
+ ],
144
+ });
145
+ ```
146
+
147
+ Pattern source files aren't part of esbuild's module graph (they're PHP/HTML,
148
+ not JS), so esbuild has no built-in way to watch them. With `watch: true` the
149
+ plugin starts its own watcher for the lifetime of the build context; leave it
150
+ `false` (the default) for one-shot builds.
151
+
152
+ ### webpack
153
+
154
+ `wp-nested-patterns` ships as ESM. If your webpack config is CommonJS
155
+ (`webpack.config.js` without `"type": "module"` in `package.json`), name the
156
+ config file `webpack.config.mjs` — webpack loads that automatically — or
157
+ `import()` the plugin dynamically. Otherwise:
158
+
159
+ ```js
160
+ // webpack.config.mjs
161
+ import { WpNestedPatternsWebpackPlugin } from '@birdinternet/wp-nested-patterns/webpack';
162
+
163
+ export default {
164
+ // ...your existing webpack config
165
+ plugins: [
166
+ new WpNestedPatternsWebpackPlugin({
167
+ srcRoot: 'wp-content/themes/my-theme/src',
168
+ destRoot: 'wp-content/themes/my-theme',
169
+ }),
170
+ ],
171
+ };
172
+ ```
173
+
174
+ Compiles before every run and every watch rebuild, and registers `srcRoot`
175
+ as a context dependency so `webpack --watch` triggers a rebuild when pattern
176
+ source files change even though nothing in the JS graph imports them.
177
+
178
+ ### Programmatic API
179
+
180
+ ```js
181
+ import { createCompiler } from '@birdinternet/wp-nested-patterns';
182
+
183
+ const compiler = createCompiler({
184
+ srcRoot: 'wp-content/themes/my-theme/src',
185
+ destRoot: 'wp-content/themes/my-theme',
186
+ });
187
+
188
+ const fileCount = await compiler.build();
189
+ ```
190
+
191
+ `compileMarkup(markup, slugMap)` is also exported for working with block
192
+ markup strings directly, without touching the filesystem — see
193
+ [`src/core.js`](./src/core.js).
194
+
195
+ ## Configuration
196
+
197
+ All three adapters and the CLI's `--config` file accept the same options:
198
+
199
+ | Option | Default | Description |
200
+ | --- | --- | --- |
201
+ | `srcRoot` | *(required)* | Directory containing the source subdirectories. |
202
+ | `destRoot` | *(required)* | Directory the compiled subdirectories are written into. |
203
+ | `dirs` | `patterns` (.php, slugged), `templates` (.html), `parts` (.html) | Which subdirectories to compile, and how. |
204
+
205
+ A custom `dirs` entry: `{ name, extensions, type: 'php' | 'html', registerSlugs }`.
206
+ `type: 'php'` preserves everything before the first `?>` as a passthrough
207
+ header. `registerSlugs: true` makes files in that directory resolvable as
208
+ `wp:pattern` targets, keyed by their `Slug:` header (defaults to `true` only
209
+ for a directory named `patterns`).
210
+
211
+ ## How it works
212
+
213
+ Each source directory is parsed with
214
+ `@wordpress/block-serialization-default-parser`. Every `wp:pattern` block is
215
+ replaced with the resolved pattern's block tree (recursively — patterns may
216
+ reference patterns), any `srcAttributes` are deep-merged into the resolved
217
+ block's attributes, and the result is re-serialized to block comment markup.
218
+ Both the source and the compiled output are checked for balanced HTML before
219
+ being written, so a pattern with mismatched tags fails the build with a
220
+ precise error rather than producing broken markup WordPress can't parse.
221
+
222
+ ## Testing
223
+
224
+ ```sh
225
+ npm test
226
+ ```
227
+
228
+ Runs the core resolution engine's unit tests plus end-to-end tests that
229
+ actually invoke `vite.build()`/`createServer()`, `esbuild.build()`/`context()`,
230
+ and `webpack()` with each adapter, asserting on the files they write to disk
231
+ (including watch-mode recompilation).
232
+
233
+ ## License
234
+
235
+ MIT
@@ -0,0 +1,43 @@
1
+ // esbuild plugin: compiles patterns once on every build (esbuild calls
2
+ // onStart on the initial build and on every rebuild). esbuild has no
3
+ // generic "watch this directory" hook for files outside the module graph,
4
+ // so when `watch: true` is passed we additionally start our own chokidar
5
+ // watcher that recompiles on source changes for the life of the build.
6
+ import { createCompiler } from '../src/compiler.js';
7
+
8
+ export function wpNestedPatternsPlugin(options = {}) {
9
+ const { watch = false, ...compilerOptions } = options;
10
+ const compiler = createCompiler(compilerOptions);
11
+
12
+ return {
13
+ name: 'wp-nested-patterns',
14
+ setup(build) {
15
+ build.onStart(async () => {
16
+ try {
17
+ await compiler.build();
18
+ } catch (e) {
19
+ return {
20
+ errors: [{ text: `wp-nested-patterns: ${e.message}` }],
21
+ };
22
+ }
23
+ });
24
+
25
+ if (!watch) return;
26
+
27
+ let watcher;
28
+ build.onStart(async () => {
29
+ if (watcher) return;
30
+ const { default: chokidar } = await import('chokidar');
31
+ watcher = chokidar.watch(compiler.options.srcRoot, { ignoreInitial: true });
32
+ watcher.on('all', () => {
33
+ compiler.build().catch((e) => {
34
+ console.error(`[wp-nested-patterns] FAILED: ${e.message}`);
35
+ });
36
+ });
37
+ });
38
+ build.onDispose(() => watcher?.close());
39
+ },
40
+ };
41
+ }
42
+
43
+ export default wpNestedPatternsPlugin;
@@ -0,0 +1,76 @@
1
+ // Vite plugin: compiles patterns once at build start, and again on every
2
+ // source change while the dev server is running (using Vite's own watcher).
3
+ import { realpathSync } from 'node:fs';
4
+ import path from 'node:path';
5
+ import { createCompiler } from '../src/compiler.js';
6
+
7
+ // Vite's watcher reports realpaths (chokidar resolves symlinks), but a
8
+ // plain path.resolve() does not — on macOS /var is itself a symlink to
9
+ // /private/var, so os.tmpdir()-based paths mismatch unless both sides are
10
+ // realpath'd before comparing. Falls back to the parent directory's
11
+ // realpath for a file that no longer exists (e.g. an unlink event).
12
+ function tryRealpath(p) {
13
+ try {
14
+ return realpathSync(p);
15
+ } catch {
16
+ try {
17
+ return path.join(realpathSync(path.dirname(p)), path.basename(p));
18
+ } catch {
19
+ return p;
20
+ }
21
+ }
22
+ }
23
+
24
+ export default function wpNestedPatterns(options = {}) {
25
+ const compiler = createCompiler(options);
26
+ let building = false;
27
+ let queued = false;
28
+ let logger = console;
29
+
30
+ async function runBuild(label) {
31
+ if (building) {
32
+ queued = true;
33
+ return;
34
+ }
35
+ building = true;
36
+ try {
37
+ const start = Date.now();
38
+ const n = await compiler.build();
39
+ const msg = `[wp-nested-patterns] compiled ${n} files in ${Date.now() - start}ms (${label})`;
40
+ if (logger.info) logger.info(msg); else logger.log(msg);
41
+ } catch (e) {
42
+ const msg = `[wp-nested-patterns] FAILED: ${e.message}`;
43
+ if (logger.error) logger.error(msg); else logger.log(msg);
44
+ throw e;
45
+ } finally {
46
+ building = false;
47
+ if (queued) {
48
+ queued = false;
49
+ await runBuild('rerun');
50
+ }
51
+ }
52
+ }
53
+
54
+ return {
55
+ name: 'wp-nested-patterns',
56
+
57
+ configResolved(config) {
58
+ logger = config.logger ?? console;
59
+ },
60
+
61
+ async buildStart() {
62
+ await runBuild('build');
63
+ },
64
+
65
+ configureServer(server) {
66
+ const { srcRoot } = compiler.options;
67
+ const watchRoot = tryRealpath(srcRoot);
68
+ server.watcher.add(srcRoot);
69
+ server.watcher.on('all', (event, file) => {
70
+ if (event === 'addDir' || event === 'unlinkDir') return;
71
+ if (!tryRealpath(file).startsWith(watchRoot)) return;
72
+ runBuild(`${event} ${file}`).catch(() => {});
73
+ });
74
+ },
75
+ };
76
+ }
@@ -0,0 +1,36 @@
1
+ // Webpack plugin: compiles patterns before every run (and every watch
2
+ // rebuild), and registers the source directory as a context dependency so
3
+ // webpack's own watcher triggers a rebuild when pattern source files change
4
+ // even though nothing in the JS module graph imports them.
5
+ import { createCompiler } from '../src/compiler.js';
6
+
7
+ export class WpNestedPatternsWebpackPlugin {
8
+ constructor(options = {}) {
9
+ this.compiler = createCompiler(options);
10
+ }
11
+
12
+ apply(compiler) {
13
+ const { webpack } = compiler;
14
+ const run = async () => {
15
+ await this.compiler.build();
16
+ };
17
+
18
+ compiler.hooks.beforeRun.tapPromise('WpNestedPatternsWebpackPlugin', run);
19
+ compiler.hooks.watchRun.tapPromise('WpNestedPatternsWebpackPlugin', run);
20
+
21
+ compiler.hooks.thisCompilation.tap('WpNestedPatternsWebpackPlugin', (compilation) => {
22
+ const addDependency = () => compilation.contextDependencies.add(this.compiler.options.srcRoot);
23
+ const Compilation = webpack?.Compilation;
24
+ if (Compilation) {
25
+ compilation.hooks.processAssets.tap(
26
+ { name: 'WpNestedPatternsWebpackPlugin', stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL },
27
+ addDependency,
28
+ );
29
+ } else {
30
+ compilation.hooks.additionalAssets.tap('WpNestedPatternsWebpackPlugin', addDependency);
31
+ }
32
+ });
33
+ }
34
+ }
35
+
36
+ export default WpNestedPatternsWebpackPlugin;
package/bin/cli.mjs ADDED
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ // Standalone CLI. Usable directly (`node bin/cli.mjs`, or `npx wp-nested-patterns`)
3
+ // with no build-tool integration required.
4
+ import path from 'node:path';
5
+ import { existsSync } from 'node:fs';
6
+ import { pathToFileURL } from 'node:url';
7
+ import { createCompiler } from '../src/compiler.js';
8
+ import { watchCompiler } from '../src/watch.js';
9
+
10
+ function parseArgs(argv) {
11
+ const args = { watch: false, src: 'src', dest: '.', config: null };
12
+ for (let i = 0; i < argv.length; i++) {
13
+ const a = argv[i];
14
+ if (a === '--watch' || a === '-w') args.watch = true;
15
+ else if (a === '--src') args.src = argv[++i];
16
+ else if (a === '--dest') args.dest = argv[++i];
17
+ else if (a === '--config' || a === '-c') args.config = argv[++i];
18
+ else if (a === '--help' || a === '-h') args.help = true;
19
+ else throw new Error(`Unknown argument: ${a}`);
20
+ }
21
+ return args;
22
+ }
23
+
24
+ function printHelp() {
25
+ console.log(`wp-nested-patterns — compile WordPress block patterns with nested wp:pattern references
26
+
27
+ Usage:
28
+ wp-nested-patterns [--src <dir>] [--dest <dir>] [--watch] [--config <file>]
29
+
30
+ Options:
31
+ --src <dir> Source directory containing patterns/templates/parts (default: "src")
32
+ --dest <dir> Destination root the compiled dirs are written into (default: ".")
33
+ --watch, -w Rebuild on every source change
34
+ --config, -c Path to a config file exporting { srcRoot, destRoot, dirs }
35
+ (overrides --src/--dest; CLI flags still take precedence over the file)
36
+ --help, -h Show this help
37
+
38
+ If no --config is given, wp-nested-patterns.config.mjs in the current directory
39
+ is loaded automatically when present.`);
40
+ }
41
+
42
+ async function loadConfigFile(file) {
43
+ const resolved = path.resolve(file);
44
+ const mod = await import(pathToFileURL(resolved).href);
45
+ return mod.default ?? mod;
46
+ }
47
+
48
+ async function main() {
49
+ const args = parseArgs(process.argv.slice(2));
50
+ if (args.help) {
51
+ printHelp();
52
+ return;
53
+ }
54
+
55
+ let config = {};
56
+ const defaultConfigPath = path.resolve('wp-nested-patterns.config.mjs');
57
+ if (args.config) {
58
+ config = await loadConfigFile(args.config);
59
+ } else if (existsSync(defaultConfigPath)) {
60
+ config = await loadConfigFile(defaultConfigPath);
61
+ }
62
+
63
+ const srcRoot = config.srcRoot ?? path.resolve(args.src);
64
+ const destRoot = config.destRoot ?? path.resolve(args.dest);
65
+ const compiler = createCompiler({ srcRoot, destRoot, dirs: config.dirs });
66
+
67
+ if (args.watch) {
68
+ console.log(`Watching ${path.relative(process.cwd(), srcRoot) || '.'}/...`);
69
+ await watchCompiler(compiler, {
70
+ onBuild: ({ label, count, ms }) => console.log(`[${label}] compiled ${count} files in ${ms}ms`),
71
+ onError: (e, label) => console.error(`[${label}] FAILED: ${e.message}`),
72
+ });
73
+ } else {
74
+ const start = Date.now();
75
+ try {
76
+ const n = await compiler.build();
77
+ console.log(`[build] compiled ${n} files in ${Date.now() - start}ms`);
78
+ } catch (e) {
79
+ console.error(`[build] FAILED: ${e.message}`);
80
+ process.exitCode = 1;
81
+ }
82
+ }
83
+ }
84
+
85
+ main().catch((e) => {
86
+ console.error(e.message);
87
+ process.exitCode = 1;
88
+ });
package/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { createCompiler } from './src/compiler.js';
2
+ export { watchCompiler } from './src/watch.js';
3
+ export { compileMarkup, resolveBlocks, serializeBlocks, assertBalanced, parseHeader, splitPhp, parse } from './src/core.js';
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "@birdinternet/wp-nested-patterns",
3
+ "version": "0.1.0",
4
+ "description": "Compile WordPress block patterns that reference each other (wp:pattern) into flat, WP-ready output — as a standalone CLI or a Vite/esbuild/webpack plugin.",
5
+ "keywords": [
6
+ "wordpress",
7
+ "block-patterns",
8
+ "gutenberg",
9
+ "vite-plugin",
10
+ "esbuild-plugin",
11
+ "webpack-plugin"
12
+ ],
13
+ "license": "MIT",
14
+ "type": "module",
15
+ "main": "./index.js",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/douglas-johnson/wp-nested-patterns.git"
19
+ },
20
+ "bugs": {
21
+ "url": "https://github.com/douglas-johnson/wp-nested-patterns/issues"
22
+ },
23
+ "homepage": "https://github.com/douglas-johnson/wp-nested-patterns#readme",
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "bin": {
28
+ "wp-nested-patterns": "bin/cli.mjs"
29
+ },
30
+ "exports": {
31
+ ".": "./index.js",
32
+ "./vite": "./adapters/vite.js",
33
+ "./esbuild": "./adapters/esbuild.js",
34
+ "./webpack": "./adapters/webpack.js",
35
+ "./package.json": "./package.json"
36
+ },
37
+ "files": [
38
+ "bin",
39
+ "src",
40
+ "adapters",
41
+ "index.js"
42
+ ],
43
+ "engines": {
44
+ "node": ">=18.17"
45
+ },
46
+ "scripts": {
47
+ "test": "node --test test/*.test.js"
48
+ },
49
+ "dependencies": {
50
+ "@wordpress/block-serialization-default-parser": "^5.45.0",
51
+ "chokidar": "^4.0.0"
52
+ },
53
+ "peerDependencies": {
54
+ "esbuild": ">=0.19.0",
55
+ "vite": ">=4.0.0",
56
+ "webpack": ">=5.0.0"
57
+ },
58
+ "peerDependenciesMeta": {
59
+ "esbuild": {
60
+ "optional": true
61
+ },
62
+ "vite": {
63
+ "optional": true
64
+ },
65
+ "webpack": {
66
+ "optional": true
67
+ }
68
+ },
69
+ "devDependencies": {
70
+ "esbuild": "^0.25.0",
71
+ "vite": "^5.4.20",
72
+ "webpack": "^5.94.0"
73
+ }
74
+ }
@@ -0,0 +1,116 @@
1
+ // Filesystem-facing directory compiler: builds a slug map from pattern
2
+ // source files, then compiles each configured source directory into its
3
+ // destination, resolving `wp:pattern` references via ./core.js.
4
+ import { readFile, writeFile, mkdir, readdir, rm } from 'node:fs/promises';
5
+ import { existsSync } from 'node:fs';
6
+ import path from 'node:path';
7
+ import { parseHeader, splitPhp, compileMarkup, parse } from './core.js';
8
+
9
+ const DEFAULT_DIRS = [
10
+ { name: 'patterns', extensions: ['.php'], type: 'php', registerSlugs: true },
11
+ { name: 'templates', extensions: ['.html'], type: 'html' },
12
+ { name: 'parts', extensions: ['.html'], type: 'html' },
13
+ ];
14
+
15
+ function normalizeDirs(dirs) {
16
+ return dirs.map((dir) => ({
17
+ type: 'html',
18
+ registerSlugs: dir.name === 'patterns',
19
+ ...dir,
20
+ }));
21
+ }
22
+
23
+ async function listFiles(dir, exts) {
24
+ if (!existsSync(dir)) return [];
25
+ const entries = await readdir(dir, { withFileTypes: true });
26
+ return entries
27
+ .filter((e) => e.isFile() && exts.some((ext) => e.name.endsWith(ext)))
28
+ .map((e) => path.join(dir, e.name));
29
+ }
30
+
31
+ async function loadSlugMap(srcRoot, dirs) {
32
+ const map = new Map();
33
+ for (const dir of dirs) {
34
+ if (!dir.registerSlugs) continue;
35
+ const files = await listFiles(path.join(srcRoot, dir.name), dir.extensions);
36
+ for (const file of files) {
37
+ const src = await readFile(file, 'utf8');
38
+ const header = parseHeader(src);
39
+ if (!header.Slug) {
40
+ throw new Error(`Pattern file ${file} has no Slug header`);
41
+ }
42
+ if (map.has(header.Slug)) {
43
+ const existing = map.get(header.Slug).file;
44
+ throw new Error(`Duplicate pattern slug "${header.Slug}" in ${file} (already defined in ${existing})`);
45
+ }
46
+ const { markup } = splitPhp(src);
47
+ map.set(header.Slug, { file, blocks: parse(markup) });
48
+ }
49
+ }
50
+ return map;
51
+ }
52
+
53
+ async function compileFile(srcFile, destFile, type, slugMap) {
54
+ const src = await readFile(srcFile, 'utf8');
55
+
56
+ let header = '';
57
+ let markup = src;
58
+ if (type === 'php') {
59
+ const split = splitPhp(src);
60
+ header = split.header;
61
+ markup = split.markup;
62
+ }
63
+
64
+ const compiled = compileMarkup(markup, slugMap, srcFile);
65
+ await writeFile(destFile, header + compiled);
66
+ }
67
+
68
+ /**
69
+ * Creates a compiler bound to a source root, destination root, and set of
70
+ * directory mappings. Call `.build()` to run one full compile pass.
71
+ *
72
+ * @param {object} options
73
+ * @param {string} options.srcRoot - directory containing the source dirs (e.g. `<theme>/src`)
74
+ * @param {string} options.destRoot - directory the compiled dirs are written into (e.g. `<theme>`)
75
+ * @param {Array<{name: string, extensions: string[], type?: 'php'|'html', registerSlugs?: boolean}>} [options.dirs]
76
+ * Which subdirectories to compile. `name` is the subdirectory name (under both
77
+ * srcRoot and destRoot). `type: 'php'` preserves everything before the first
78
+ * `?>` as a passthrough header (e.g. the WP pattern-registration docblock).
79
+ * `registerSlugs: true` makes files in that directory available as
80
+ * `wp:pattern` reference targets, keyed by their `Slug:` header. Defaults to
81
+ * the theme convention: `patterns` (.php, slugged), `templates` (.html), `parts` (.html).
82
+ */
83
+ export function createCompiler(options) {
84
+ if (!options || !options.srcRoot || !options.destRoot) {
85
+ throw new Error('createCompiler requires { srcRoot, destRoot }');
86
+ }
87
+ const srcRoot = path.resolve(options.srcRoot);
88
+ const destRoot = path.resolve(options.destRoot);
89
+ const dirs = normalizeDirs(options.dirs || DEFAULT_DIRS);
90
+
91
+ async function build() {
92
+ const slugMap = await loadSlugMap(srcRoot, dirs);
93
+ let count = 0;
94
+ for (const dir of dirs) {
95
+ const srcDir = path.join(srcRoot, dir.name);
96
+ const destDir = path.join(destRoot, dir.name);
97
+ if (!existsSync(srcDir)) continue;
98
+ await mkdir(destDir, { recursive: true });
99
+ // remove stale files in dest that no longer exist in src
100
+ const srcNames = new Set((await listFiles(srcDir, dir.extensions)).map((f) => path.basename(f)));
101
+ for (const existing of await listFiles(destDir, dir.extensions)) {
102
+ if (!srcNames.has(path.basename(existing))) {
103
+ await rm(existing);
104
+ }
105
+ }
106
+ for (const srcFile of await listFiles(srcDir, dir.extensions)) {
107
+ const destFile = path.join(destDir, path.basename(srcFile));
108
+ await compileFile(srcFile, destFile, dir.type, slugMap);
109
+ count++;
110
+ }
111
+ }
112
+ return count;
113
+ }
114
+
115
+ return { build, options: { srcRoot, destRoot, dirs } };
116
+ }
package/src/core.js ADDED
@@ -0,0 +1,202 @@
1
+ // Pure block-markup engine: no filesystem access. Given a slug map of
2
+ // available patterns, resolves `wp:pattern` references by inlining the
3
+ // referenced pattern's blocks at the reference site (recursively, with
4
+ // cycle detection).
5
+ import { parse } from '@wordpress/block-serialization-default-parser';
6
+
7
+ export { parse };
8
+
9
+ export function parseHeader(src) {
10
+ const m = src.match(/\/\*\*([\s\S]*?)\*\//);
11
+ if (!m) return {};
12
+ const out = {};
13
+ for (const line of m[1].split('\n')) {
14
+ const mm = line.match(/^\s*\*\s*([\w ]+):\s*(.*?)\s*$/);
15
+ if (mm) out[mm[1].trim()] = mm[2];
16
+ }
17
+ return out;
18
+ }
19
+
20
+ export function splitPhp(src) {
21
+ const idx = src.indexOf('?>');
22
+ if (idx === -1) return { header: '', markup: src };
23
+ return { header: src.slice(0, idx + 2), markup: src.slice(idx + 2) };
24
+ }
25
+
26
+ function shortName(name) {
27
+ return name.startsWith('core/') ? name.slice(5) : name;
28
+ }
29
+
30
+ function serializeAttrs(attrs) {
31
+ if (!attrs || Object.keys(attrs).length === 0) return '';
32
+ return ' ' + JSON.stringify(attrs);
33
+ }
34
+
35
+ function isSelfClosing(block) {
36
+ return block.innerContent.length === 0 && block.innerBlocks.length === 0;
37
+ }
38
+
39
+ function serializeBlock(block) {
40
+ if (!block.blockName) {
41
+ return block.innerHTML || '';
42
+ }
43
+ const name = shortName(block.blockName);
44
+ const attrs = serializeAttrs(block.attrs);
45
+ if (isSelfClosing(block)) {
46
+ return `<!-- wp:${name}${attrs} /-->`;
47
+ }
48
+ let out = `<!-- wp:${name}${attrs} -->`;
49
+ let bIdx = 0;
50
+ for (const piece of block.innerContent) {
51
+ if (piece === null) {
52
+ out += serializeBlock(block.innerBlocks[bIdx++]);
53
+ } else {
54
+ out += piece;
55
+ }
56
+ }
57
+ out += `<!-- /wp:${name} -->`;
58
+ return out;
59
+ }
60
+
61
+ export function serializeBlocks(blocks) {
62
+ return blocks.map(serializeBlock).join('');
63
+ }
64
+
65
+ const VOID_HTML_TAGS = new Set([
66
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img',
67
+ 'input', 'link', 'meta', 'source', 'track', 'wbr',
68
+ ]);
69
+
70
+ // Counts non-void HTML tag opens vs. closes in a fragment.
71
+ // A non-zero delta means a wrapper element opens or closes without its pair,
72
+ // which produces malformed block markup that triggers WP block-recovery prompts.
73
+ function htmlTagDelta(html) {
74
+ const delta = {};
75
+ for (const m of (html || '').matchAll(/<(\/?)([a-z][a-z0-9-]*)\b[^>]*?(\/?)>/gi)) {
76
+ const tag = m[2].toLowerCase();
77
+ if (VOID_HTML_TAGS.has(tag)) continue;
78
+ if (m[3] === '/') continue;
79
+ delta[tag] = (delta[tag] || 0) + (m[1] === '/' ? -1 : 1);
80
+ }
81
+ return delta;
82
+ }
83
+
84
+ function collectImbalances(block, trail, issues) {
85
+ if (!block.blockName) return;
86
+ const here = trail ? `${trail} > ${block.blockName}` : block.blockName;
87
+ for (const [tag, n] of Object.entries(htmlTagDelta(block.innerHTML))) {
88
+ if (n !== 0) issues.push({ path: here, tag, delta: n });
89
+ }
90
+ for (const child of block.innerBlocks) collectImbalances(child, here, issues);
91
+ }
92
+
93
+ export function assertBalanced(blocks, label) {
94
+ const issues = [];
95
+ for (const b of blocks) collectImbalances(b, '', issues);
96
+ if (issues.length === 0) return;
97
+ const lines = issues.map((i) => {
98
+ const verb = i.delta > 0
99
+ ? `opened ${i.delta} more time(s) than closed`
100
+ : `closed ${-i.delta} more time(s) than opened`;
101
+ return ` ${i.path}: <${i.tag}> ${verb}`;
102
+ });
103
+ throw new Error(`malformed block markup in ${label}\n${lines.join('\n')}`);
104
+ }
105
+
106
+ function deepMerge(target, source) {
107
+ if (Array.isArray(source)) return source.slice();
108
+ if (source && typeof source === 'object' && target && typeof target === 'object' && !Array.isArray(target)) {
109
+ const out = { ...target };
110
+ for (const k of Object.keys(source)) {
111
+ out[k] = deepMerge(target[k], source[k]);
112
+ }
113
+ return out;
114
+ }
115
+ return source;
116
+ }
117
+
118
+ function firstRealBlockIndex(blocks) {
119
+ for (let i = 0; i < blocks.length; i++) {
120
+ if (blocks[i].blockName) return i;
121
+ }
122
+ return -1;
123
+ }
124
+
125
+ function resolvePatternBlock(block, slugMap, chain) {
126
+ const slug = block.attrs?.slug;
127
+ if (!slug) {
128
+ throw new Error(`wp:pattern with no slug (chain: ${chain.join(' → ') || '<root>'})`);
129
+ }
130
+ if (chain.includes(slug)) {
131
+ throw new Error(`Cycle in patterns: ${[...chain, slug].join(' → ')}`);
132
+ }
133
+ const target = slugMap.get(slug);
134
+ if (!target) {
135
+ throw new Error(`Unresolved wp:pattern slug "${slug}" (chain: ${[...chain, slug].join(' → ')})`);
136
+ }
137
+ const resolved = resolveBlocks(target.blocks, slugMap, [...chain, slug]);
138
+ const overrides = block.attrs?.srcAttributes;
139
+ if (overrides) {
140
+ const idx = firstRealBlockIndex(resolved);
141
+ if (idx === -1) {
142
+ throw new Error(`srcAttributes targeting pattern "${slug}" with no top-level block`);
143
+ }
144
+ resolved[idx] = {
145
+ ...resolved[idx],
146
+ attrs: deepMerge(resolved[idx].attrs || {}, overrides),
147
+ };
148
+ }
149
+ return resolved;
150
+ }
151
+
152
+ function resolveInBlock(block, slugMap, chain) {
153
+ if (!block.blockName) return block;
154
+ const newInnerBlocks = [];
155
+ const newInnerContent = [];
156
+ let bIdx = 0;
157
+ for (const piece of block.innerContent) {
158
+ if (piece === null) {
159
+ const child = block.innerBlocks[bIdx++];
160
+ if (child.blockName === 'core/pattern') {
161
+ const resolved = resolvePatternBlock(child, slugMap, chain);
162
+ for (const r of resolved) {
163
+ if (r.blockName) {
164
+ newInnerBlocks.push(r);
165
+ newInnerContent.push(null);
166
+ } else {
167
+ newInnerContent.push(r.innerHTML || '');
168
+ }
169
+ }
170
+ } else {
171
+ newInnerBlocks.push(resolveInBlock(child, slugMap, chain));
172
+ newInnerContent.push(null);
173
+ }
174
+ } else {
175
+ newInnerContent.push(piece);
176
+ }
177
+ }
178
+ return { ...block, innerBlocks: newInnerBlocks, innerContent: newInnerContent };
179
+ }
180
+
181
+ export function resolveBlocks(blocks, slugMap, chain = []) {
182
+ const out = [];
183
+ for (const block of blocks) {
184
+ if (block.blockName === 'core/pattern') {
185
+ out.push(...resolvePatternBlock(block, slugMap, chain));
186
+ } else {
187
+ out.push(resolveInBlock(block, slugMap, chain));
188
+ }
189
+ }
190
+ return out;
191
+ }
192
+
193
+ // Resolves and re-serializes a single block-markup string against a slug map.
194
+ // Throws if the source or compiled output has malformed (unbalanced) HTML.
195
+ export function compileMarkup(markup, slugMap, label = 'source') {
196
+ const blocks = parse(markup);
197
+ assertBalanced(blocks, label);
198
+ const resolved = resolveBlocks(blocks, slugMap);
199
+ const compiled = serializeBlocks(resolved);
200
+ assertBalanced(parse(compiled), `compiled output of ${label}`);
201
+ return compiled;
202
+ }
package/src/watch.js ADDED
@@ -0,0 +1,43 @@
1
+ // Debounced filesystem watcher for a compiler created by ./compiler.js.
2
+ // Serializes rebuilds: a change arriving mid-build is coalesced into a
3
+ // single rerun after the in-flight build finishes, rather than piling up
4
+ // concurrent builds.
5
+ export async function watchCompiler(compiler, { onBuild, onError } = {}) {
6
+ const { default: chokidar } = await import('chokidar');
7
+
8
+ let running = false;
9
+ let queued = false;
10
+
11
+ const runOnce = async (label) => {
12
+ const start = Date.now();
13
+ try {
14
+ const n = await compiler.build();
15
+ onBuild?.({ label, count: n, ms: Date.now() - start });
16
+ } catch (e) {
17
+ onError?.(e, label);
18
+ }
19
+ };
20
+
21
+ const trigger = async (event, file) => {
22
+ if (running) {
23
+ queued = true;
24
+ return;
25
+ }
26
+ running = true;
27
+ await runOnce(file ? `${event} ${file}` : event);
28
+ running = false;
29
+ if (queued) {
30
+ queued = false;
31
+ await trigger('rerun');
32
+ }
33
+ };
34
+
35
+ await trigger('initial');
36
+
37
+ const watcher = chokidar.watch(compiler.options.srcRoot, { ignoreInitial: true });
38
+ watcher.on('all', trigger);
39
+
40
+ return {
41
+ close: () => watcher.close(),
42
+ };
43
+ }