@cloudcatch/wp-esbuild 1.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.
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Resolve SCSS output paths for the scss handler.
3
+ */
4
+
5
+ import path from 'path';
6
+
7
+ /**
8
+ * @param {string} relativeEntryPath Path relative to the entry src dir.
9
+ * @param {object} options
10
+ * @param {string} [options.join]
11
+ * @param {number|string} [options.tail]
12
+ * @return {string} CSS filename including extension.
13
+ */
14
+ export function joinScssSegments( relativeEntryPath, { join = '-', tail } ) {
15
+ const withoutExt = relativeEntryPath.replace( /\.(scss|sass)$/, '' );
16
+ const segments = withoutExt.split( path.sep ).filter( Boolean );
17
+
18
+ if ( segments.length === 0 ) {
19
+ return 'style.css';
20
+ }
21
+
22
+ let selected = segments;
23
+
24
+ if ( tail !== undefined && tail !== 'all' ) {
25
+ const count = Math.max( 1, Number( tail ) );
26
+ selected = segments.slice( -Math.min( count, segments.length ) );
27
+ }
28
+
29
+ return `${ selected.join( join ) }.css`;
30
+ }
31
+
32
+ /**
33
+ * @param {object} options
34
+ * @param {string} options.srcDir Absolute source directory.
35
+ * @param {string} options.outDir Absolute output directory.
36
+ * @param {string} options.entry Absolute path to the SCSS entry file.
37
+ * @param {object} options.entryConfig
38
+ * @return {string} Absolute path for the compiled CSS file.
39
+ */
40
+ export function resolveScssOutfile( { srcDir, outDir, entry, entryConfig } ) {
41
+ const relativeEntryPath = path.relative( srcDir, entry );
42
+ const baseName = path.basename( relativeEntryPath, path.extname( relativeEntryPath ) );
43
+ const outRelativePath = relativeEntryPath.replace( /\.(scss|sass)$/, '.css' );
44
+ const globPattern = entryConfig.glob || '*.{scss,sass}';
45
+ const preserveStructure = globPattern.includes( '**' );
46
+ const outName = entryConfig.outName ?? ( preserveStructure ? 'preserve' : 'flat' );
47
+
48
+ if ( outName === 'flat' ) {
49
+ return path.join( outDir, `${ baseName }.css` );
50
+ }
51
+
52
+ if ( outName === 'style-entry' ) {
53
+ const parentDir = path.dirname( relativeEntryPath );
54
+ const cssName =
55
+ parentDir === '.'
56
+ ? `${ baseName }.css`
57
+ : `style-${ parentDir.split( path.sep ).join( '-' ) }.css`;
58
+ return path.join( outDir, cssName );
59
+ }
60
+
61
+ if ( outName === 'preserve' ) {
62
+ return path.join( outDir, outRelativePath );
63
+ }
64
+
65
+ if ( typeof outName === 'object' && outName !== null ) {
66
+ const cssName = joinScssSegments( relativeEntryPath, outName );
67
+ return path.join( outDir, cssName );
68
+ }
69
+
70
+ if ( preserveStructure ) {
71
+ return path.join( outDir, outRelativePath );
72
+ }
73
+
74
+ return path.join( outDir, `${ baseName }.css` );
75
+ }
package/lib/utils.mjs ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Shared filesystem utilities.
3
+ */
4
+
5
+ import { access } from 'fs/promises';
6
+
7
+ /**
8
+ * @param {string} filePath
9
+ * @return {Promise<boolean>}
10
+ */
11
+ export async function fileExists( filePath ) {
12
+ try {
13
+ await access( filePath );
14
+ return true;
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+
20
+ /**
21
+ * @param {string} filePath
22
+ * @param {string} projectRoot
23
+ * @return {boolean}
24
+ */
25
+ export function isPathInside( filePath, projectRoot ) {
26
+ const normalizedFile = filePath.replace( /\\/g, '/' );
27
+ const normalizedRoot = projectRoot.replace( /\\/g, '/' ).replace( /\/$/, '' );
28
+ return normalizedFile === normalizedRoot || normalizedFile.startsWith( `${ normalizedRoot }/` );
29
+ }
@@ -0,0 +1,320 @@
1
+ /**
2
+ * esbuild plugin: externalize WordPress packages and vendors; emit .asset.php files.
3
+ */
4
+
5
+ import { writeFile, readFile } from 'fs/promises';
6
+ import { createHash } from 'crypto';
7
+ import { camelCase } from 'change-case';
8
+ import { defaultWordpressBundledPackages } from './config.mjs';
9
+
10
+ const defaultVendorExternals = {
11
+ react: { global: 'React', handle: 'react' },
12
+ 'react-dom': { global: 'ReactDOM', handle: 'react-dom' },
13
+ 'react-dom/client': { global: 'ReactDOM', handle: 'react-dom' },
14
+ 'react/jsx-runtime': { global: 'ReactJSXRuntime', handle: 'react-jsx-runtime' },
15
+ 'react/jsx-dev-runtime': { global: 'ReactJSXRuntime', handle: 'react-jsx-runtime' },
16
+ moment: { global: 'moment', handle: 'moment' },
17
+ lodash: { global: 'lodash', handle: 'lodash' },
18
+ 'lodash-es': { global: 'lodash', handle: 'lodash' },
19
+ jquery: { global: 'jQuery', handle: 'jquery' },
20
+ };
21
+
22
+ const wordpressPackages = [
23
+ { pattern: /^@wordpress\/a11y$/, global: 'wp.a11y', handle: 'wp-a11y' },
24
+ { pattern: /^@wordpress\/api-fetch$/, global: 'wp.apiFetch', handle: 'wp-api-fetch' },
25
+ { pattern: /^@wordpress\/autop$/, global: 'wp.autop', handle: 'wp-autop' },
26
+ { pattern: /^@wordpress\/blob$/, global: 'wp.blob', handle: 'wp-blob' },
27
+ { pattern: /^@wordpress\/block-editor$/, global: 'wp.blockEditor', handle: 'wp-block-editor' },
28
+ { pattern: /^@wordpress\/blocks$/, global: 'wp.blocks', handle: 'wp-blocks' },
29
+ { pattern: /^@wordpress\/components$/, global: 'wp.components', handle: 'wp-components' },
30
+ { pattern: /^@wordpress\/compose$/, global: 'wp.compose', handle: 'wp-compose' },
31
+ { pattern: /^@wordpress\/core-data$/, global: 'wp.coreData', handle: 'wp-core-data' },
32
+ { pattern: /^@wordpress\/data$/, global: 'wp.data', handle: 'wp-data' },
33
+ { pattern: /^@wordpress\/date$/, global: 'wp.date', handle: 'wp-date' },
34
+ { pattern: /^@wordpress\/deprecated$/, global: 'wp.deprecated', handle: 'wp-deprecated' },
35
+ { pattern: /^@wordpress\/dom$/, global: 'wp.dom', handle: 'wp-dom' },
36
+ { pattern: /^@wordpress\/dom-ready$/, global: 'wp.domReady', handle: 'wp-dom-ready' },
37
+ { pattern: /^@wordpress\/element$/, global: 'wp.element', handle: 'wp-element' },
38
+ { pattern: /^@wordpress\/escape-html$/, global: 'wp.escapeHtml', handle: 'wp-escape-html' },
39
+ { pattern: /^@wordpress\/hooks$/, global: 'wp.hooks', handle: 'wp-hooks' },
40
+ { pattern: /^@wordpress\/html-entities$/, global: 'wp.htmlEntities', handle: 'wp-html-entities' },
41
+ { pattern: /^@wordpress\/i18n$/, global: 'wp.i18n', handle: 'wp-i18n' },
42
+ { pattern: /^@wordpress\/notices$/, global: 'wp.notices', handle: 'wp-notices' },
43
+ { pattern: /^@wordpress\/primitives$/, global: 'wp.primitives', handle: 'wp-primitives' },
44
+ { pattern: /^@wordpress\/rich-text$/, global: 'wp.richText', handle: 'wp-rich-text' },
45
+ { pattern: /^@wordpress\/server-side-render$/, global: 'wp.serverSideRender', handle: 'wp-server-side-render' },
46
+ { pattern: /^@wordpress\/url$/, global: 'wp.url', handle: 'wp-url' },
47
+ ];
48
+
49
+ /**
50
+ * @param {object} [externalsConfig]
51
+ * @return {Set<string>}
52
+ */
53
+ function resolveBundledPackages( externalsConfig = {} ) {
54
+ return new Set( [
55
+ ...defaultWordpressBundledPackages,
56
+ ...( externalsConfig.bundle || [] ),
57
+ ] );
58
+ }
59
+
60
+ /**
61
+ * @param {object} [externalsConfig]
62
+ * @return {Record<string, object>}
63
+ */
64
+ function resolveVendorExternals( externalsConfig = {} ) {
65
+ return {
66
+ ...defaultVendorExternals,
67
+ ...( externalsConfig.vendors || {} ),
68
+ };
69
+ }
70
+
71
+ /**
72
+ * @param {string} assetBaseName e.g. "index" or "view"
73
+ * @param {string[]} extraDependencies
74
+ * @param {object} [externalsConfig]
75
+ * @return {import('esbuild').Plugin}
76
+ */
77
+ export function wordpressExternalsPlugin(
78
+ assetBaseName = 'index',
79
+ extraDependencies = [],
80
+ externalsConfig = {}
81
+ ) {
82
+ return {
83
+ name: 'wordpress-externals',
84
+ setup( build ) {
85
+ const dependencies = new Set( extraDependencies );
86
+ const bundledPackages = resolveBundledPackages( externalsConfig );
87
+ const vendorExternals = resolveVendorExternals( externalsConfig );
88
+ const forcedExternal = new Set( externalsConfig.external || [] );
89
+
90
+ for ( const [ packageName, config ] of Object.entries( vendorExternals ) ) {
91
+ build.onResolve(
92
+ { filter: new RegExp( `^${ packageName.replace( '/', '\\/' ) }$` ) },
93
+ () => {
94
+ dependencies.add( config.handle );
95
+ return {
96
+ path: packageName,
97
+ namespace: 'vendor-external',
98
+ pluginData: { global: config.global },
99
+ };
100
+ }
101
+ );
102
+ }
103
+
104
+ for ( const wpPackage of wordpressPackages ) {
105
+ build.onResolve( { filter: wpPackage.pattern }, ( args ) => {
106
+ if ( forcedExternal.has( args.path ) ) {
107
+ return null;
108
+ }
109
+
110
+ dependencies.add( wpPackage.handle );
111
+ return {
112
+ path: args.path,
113
+ namespace: 'wordpress-external',
114
+ pluginData: { global: wpPackage.global },
115
+ };
116
+ } );
117
+ }
118
+
119
+ build.onResolve( { filter: /^@wordpress\// }, ( args ) => {
120
+ if ( bundledPackages.has( args.path ) ) {
121
+ return null;
122
+ }
123
+
124
+ if ( forcedExternal.has( args.path ) ) {
125
+ return null;
126
+ }
127
+
128
+ const shortName = args.path.split( '/' )[ 1 ];
129
+ const handle = `wp-${ shortName }`;
130
+ const global = `wp.${ camelCase( shortName ) }`;
131
+ dependencies.add( handle );
132
+ return {
133
+ path: args.path,
134
+ namespace: 'wordpress-external',
135
+ pluginData: { global },
136
+ };
137
+ } );
138
+
139
+ build.onLoad( { filter: /.*/, namespace: 'vendor-external' }, ( args ) => ( {
140
+ contents: `module.exports = window.${ args.pluginData.global };`,
141
+ loader: 'js',
142
+ } ) );
143
+
144
+ build.onLoad( { filter: /.*/, namespace: 'wordpress-external' }, ( args ) => ( {
145
+ contents: `module.exports = window.${ args.pluginData.global };`,
146
+ loader: 'js',
147
+ } ) );
148
+
149
+ build.onEnd( async ( result ) => {
150
+ if ( result.errors.length > 0 || ! build.initialOptions.outfile ) {
151
+ return;
152
+ }
153
+
154
+ const outputFile = build.initialOptions.outfile;
155
+ const content = await readFile( outputFile );
156
+ const version = createHash( 'sha256' )
157
+ .update( content )
158
+ .digest( 'hex' )
159
+ .slice( 0, 20 );
160
+
161
+ const depsString = Array.from( dependencies )
162
+ .sort()
163
+ .map( ( dep ) => `'${ dep }'` )
164
+ .join( ', ' );
165
+
166
+ const assetPath = outputFile.replace( /\.m?js$/, '.asset.php' );
167
+ const assetContent = `<?php return array(
168
+ 'dependencies' => array( ${ depsString } ),
169
+ 'version' => '${ version }',
170
+ );
171
+ `;
172
+
173
+ await writeFile( assetPath, assetContent );
174
+ } );
175
+ },
176
+ };
177
+ }
178
+
179
+ /**
180
+ * Externalize WordPress script module imports and emit view.asset.php matching
181
+ * @wordpress/dependency-extraction-webpack-plugin module output.
182
+ *
183
+ * @param {string} assetBaseName
184
+ * @param {object} [externalsConfig]
185
+ * @return {import('esbuild').Plugin}
186
+ */
187
+ export function wordpressModuleExternalsPlugin( assetBaseName = 'view', externalsConfig = {} ) {
188
+ return {
189
+ name: 'wordpress-module-externals',
190
+ setup( build ) {
191
+ /** @type {Set<string>} */
192
+ const staticDependencies = new Set();
193
+ /** @type {Set<string>} */
194
+ const dynamicDependencies = new Set();
195
+ const bundledPackages = resolveBundledPackages( externalsConfig );
196
+
197
+ build.onResolve( { filter: /^@wordpress\// }, ( args ) => {
198
+ if ( bundledPackages.has( args.path ) ) {
199
+ return null;
200
+ }
201
+
202
+ if ( args.kind === 'dynamic-import' ) {
203
+ dynamicDependencies.add( args.path );
204
+ } else {
205
+ staticDependencies.add( args.path );
206
+ }
207
+
208
+ return {
209
+ path: args.path,
210
+ external: true,
211
+ };
212
+ } );
213
+
214
+ build.onEnd( async ( result ) => {
215
+ if ( result.errors.length > 0 || ! build.initialOptions.outfile ) {
216
+ return;
217
+ }
218
+
219
+ const outputFile = build.initialOptions.outfile;
220
+ const content = await readFile( outputFile );
221
+ const version = createHash( 'sha256' )
222
+ .update( content )
223
+ .digest( 'hex' )
224
+ .slice( 0, 20 );
225
+
226
+ const dependencyEntries = [
227
+ ...Array.from( staticDependencies ).sort().map( ( id ) => ( {
228
+ type: 'static',
229
+ id,
230
+ } ) ),
231
+ ...Array.from( dynamicDependencies ).sort().map( ( id ) => ( {
232
+ type: 'dynamic',
233
+ id,
234
+ } ) ),
235
+ ];
236
+
237
+ const depsString = dependencyEntries
238
+ .map( ( dep ) =>
239
+ dep.type === 'dynamic'
240
+ ? `array( 'id' => '${ dep.id }', 'import' => 'dynamic' )`
241
+ : `'${ dep.id }'`
242
+ )
243
+ .join( ', ' );
244
+
245
+ const assetPath = outputFile.replace( /\.m?js$/, '.asset.php' );
246
+ const assetContent = `<?php return array(
247
+ 'dependencies' => array( ${ depsString } ),
248
+ 'version' => '${ version }',
249
+ 'type' => 'module',
250
+ );
251
+ `;
252
+
253
+ await writeFile( assetPath, assetContent );
254
+ } );
255
+ },
256
+ };
257
+ }
258
+
259
+ /**
260
+ * Strip SCSS/CSS imports from JS bundles; styles are compiled separately.
261
+ *
262
+ * @return {import('esbuild').Plugin}
263
+ */
264
+ export function stripStyleImportsPlugin() {
265
+ return {
266
+ name: 'strip-style-imports',
267
+ setup( build ) {
268
+ build.onResolve( { filter: /\.(scss|css)$/ }, () => ( {
269
+ path: 'empty-style',
270
+ namespace: 'empty-style',
271
+ } ) );
272
+
273
+ build.onLoad( { filter: /.*/, namespace: 'empty-style' }, () => ( {
274
+ contents: '',
275
+ loader: 'js',
276
+ } ) );
277
+ },
278
+ };
279
+ }
280
+
281
+ /**
282
+ * Compile imported SCSS in JS bundles when extractCss is enabled.
283
+ *
284
+ * @return {import('esbuild').Plugin}
285
+ */
286
+ export function extractStyleImportsPlugin() {
287
+ return {
288
+ name: 'extract-style-imports',
289
+ setup( build ) {
290
+ build.onResolve( { filter: /\.css$/ }, ( args ) => {
291
+ if ( args.namespace === 'file' ) {
292
+ return null;
293
+ }
294
+ return {
295
+ path: args.path,
296
+ namespace: 'css-file',
297
+ };
298
+ } );
299
+
300
+ build.onLoad( { filter: /.*/, namespace: 'css-file' }, async ( args ) => ( {
301
+ contents: await readFile( args.path, 'utf8' ),
302
+ loader: 'css',
303
+ } ) );
304
+
305
+ build.onResolve( { filter: /\.scss$/ }, ( args ) => ( {
306
+ path: args.path,
307
+ namespace: 'scss-file',
308
+ } ) );
309
+
310
+ build.onLoad( { filter: /.*/, namespace: 'scss-file' }, async ( args ) => {
311
+ const sass = await import( 'sass-embedded' );
312
+ const result = sass.compile( args.path );
313
+ return {
314
+ contents: result.css,
315
+ loader: 'css',
316
+ };
317
+ } );
318
+ },
319
+ };
320
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Write WordPress *.asset.php sidecars for built assets.
3
+ */
4
+
5
+ import { createHash } from 'crypto';
6
+ import { readFile, writeFile } from 'fs/promises';
7
+
8
+ /**
9
+ * @param {string} outfile Absolute path to the built asset (e.g. main.js or main.css).
10
+ * @param {object} [options]
11
+ * @param {string[]} [options.dependencies]
12
+ * @param {string} [options.type] e.g. 'module' for script modules.
13
+ * @return {Promise<string>} Path to the written .asset.php file.
14
+ */
15
+ export async function writeAssetPhp( outfile, options = {} ) {
16
+ const { dependencies = [], type } = options;
17
+ const content = await readFile( outfile );
18
+ const version = createHash( 'sha256' ).update( content ).digest( 'hex' ).slice( 0, 20 );
19
+
20
+ const depsString = dependencies
21
+ .map( ( dep ) => `'${ dep }'` )
22
+ .join( ', ' );
23
+
24
+ const typeLine = type ? `\n\t'type' => '${ type }',` : '';
25
+
26
+ const assetPath = outfile.replace( /\.(css|m?js)$/, '.asset.php' );
27
+ const assetContent = `<?php return array(
28
+ 'dependencies' => array( ${ depsString } ),${ typeLine }
29
+ 'version' => '${ version }',
30
+ );
31
+ `;
32
+
33
+ await writeFile( assetPath, assetContent );
34
+ return assetPath;
35
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@cloudcatch/wp-esbuild",
3
+ "version": "1.1.0",
4
+ "description": "Shared esbuild toolchain for WordPress development",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "wp-esbuild": "bin/wp-esbuild.mjs"
9
+ },
10
+ "exports": {
11
+ ".": "./lib/build.mjs",
12
+ "./config": "./lib/define-config.mjs",
13
+ "./blocks-manifest": "./lib/build-blocks-manifest.mjs",
14
+ "./wordpress-externals": "./lib/wordpress-externals-plugin.mjs"
15
+ },
16
+ "files": [
17
+ "bin",
18
+ "lib",
19
+ "README.md",
20
+ "ROADMAP.md",
21
+ "LICENSE"
22
+ ],
23
+ "scripts": {
24
+ "test": "node --test tests/**/*.test.mjs"
25
+ },
26
+ "engines": {
27
+ "node": ">=18"
28
+ },
29
+ "keywords": [
30
+ "wordpress",
31
+ "esbuild",
32
+ "blocks",
33
+ "gutenberg",
34
+ "scss",
35
+ "build"
36
+ ],
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "dependencies": {
41
+ "autoprefixer": "^10.4.21",
42
+ "change-case": "^4.1.2",
43
+ "chokidar": "^4.0.3",
44
+ "esbuild": "^0.27.2",
45
+ "esbuild-sass-plugin": "^3.3.1",
46
+ "fast-glob": "^3.3.3",
47
+ "postcss": "^8.5.3",
48
+ "rtlcss": "^4.3.0",
49
+ "sass-embedded": "^1.97.2"
50
+ }
51
+ }