@wordpress/build 0.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.
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Dependency graph utilities for WordPress packages.
3
+ *
4
+ * This module provides functions to analyze dependencies between @wordpress/* packages
5
+ * and determine the correct build order using topological sorting.
6
+ */
7
+
8
+ /**
9
+ * External dependencies
10
+ */
11
+ import toposort from 'toposort';
12
+
13
+ /**
14
+ * Internal dependencies
15
+ */
16
+ import { getPackageInfo } from './package-utils.mjs';
17
+
18
+ /**
19
+ * Check if a package is a script or script module.
20
+ * A package is a script if it has wpScript or wpScriptModuleExports.
21
+ *
22
+ * @param {string} packageName The full package name (e.g., '@wordpress/blocks').
23
+ * @return {boolean} True if the package is a script or script module.
24
+ */
25
+ function isScriptOrModule( packageName ) {
26
+ const packageJson = getPackageInfo( packageName );
27
+ if ( ! packageJson ) {
28
+ return false;
29
+ }
30
+ return !! ( packageJson.wpScript || packageJson.wpScriptModuleExports );
31
+ }
32
+
33
+ /**
34
+ * Get WordPress package dependencies from a package.json file.
35
+ *
36
+ * @param {string} packageName The full package name (e.g., '@wordpress/blocks').
37
+ * @return {string[]} Array of WordPress package names this package depends on.
38
+ */
39
+ function getWordPressDependencies( packageName ) {
40
+ const packageJson = getPackageInfo( packageName );
41
+ if ( ! packageJson ) {
42
+ return [];
43
+ }
44
+
45
+ const deps = packageJson.dependencies || {};
46
+
47
+ // Extract @wordpress/* package names (keep full names)
48
+ return Object.keys( deps ).filter( ( dep ) =>
49
+ dep.startsWith( '@wordpress/' )
50
+ );
51
+ }
52
+
53
+ /**
54
+ * Build a dependency graph for the given packages.
55
+ *
56
+ * @param {string[]} packages Array of full package names to analyze.
57
+ * @return {Array<[string, string]>} Array of [dependent, dependency] edges.
58
+ */
59
+ function buildDependencyGraph( packages ) {
60
+ /** @type {Array<[string, string]>} */
61
+ const edges = [];
62
+ const packagesSet = new Set( packages );
63
+
64
+ for ( const packageName of packages ) {
65
+ const deps = getWordPressDependencies( packageName );
66
+
67
+ // Only include edges where both packages are in our list
68
+ for ( const dep of deps ) {
69
+ if ( packagesSet.has( dep ) ) {
70
+ edges.push( [ packageName, dep ] );
71
+ }
72
+ }
73
+
74
+ // If package has no dependencies in our list, add a self-reference
75
+ // This ensures it appears in the sorted output
76
+ if ( deps.filter( ( dep ) => packagesSet.has( dep ) ).length === 0 ) {
77
+ edges.push( [ packageName, packageName ] );
78
+ }
79
+ }
80
+
81
+ return edges;
82
+ }
83
+
84
+ /**
85
+ * Sort packages in topological order based on their dependencies.
86
+ *
87
+ * @param {string[]} packages Array of full package names to sort.
88
+ * @return {string[]} Sorted array where dependencies come before dependents.
89
+ */
90
+ function topologicalSort( packages ) {
91
+ const edges = buildDependencyGraph( packages );
92
+
93
+ try {
94
+ // toposort returns dependencies first, then dependents
95
+ /** @type {Array<string>} */
96
+ const sorted = toposort( edges );
97
+
98
+ // Filter to only include packages in our input list
99
+ // (toposort might include extra nodes)
100
+ const packagesSet = new Set( packages );
101
+ return sorted.filter( ( pkg ) => packagesSet.has( pkg ) );
102
+ } catch ( error ) {
103
+ if ( error instanceof Error && error.message.includes( 'cyclic' ) ) {
104
+ console.error(
105
+ '❌ Cyclic dependency detected in packages:',
106
+ error.message
107
+ );
108
+ throw new Error(
109
+ 'Cannot build packages due to cyclic dependencies'
110
+ );
111
+ }
112
+ throw error;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Group packages by dependency depth level.
118
+ * Packages at the same depth level can be built in parallel.
119
+ *
120
+ * @param {string[]} packages Array of full package names to group.
121
+ * @return {string[][]} Array of arrays, where each inner array is a depth level.
122
+ */
123
+ function groupByDepth( packages ) {
124
+ const packagesSet = new Set( packages );
125
+ const depths = new Map();
126
+ const visited = new Set();
127
+
128
+ /**
129
+ * Calculate depth for a package recursively.
130
+ *
131
+ * @param {string} packageName Package name to calculate depth for.
132
+ * @return {number} Depth level (0 = no dependencies).
133
+ */
134
+ function calculateDepth( packageName ) {
135
+ if ( depths.has( packageName ) ) {
136
+ return depths.get( packageName );
137
+ }
138
+
139
+ // Prevent infinite loops in case of circular dependencies
140
+ if ( visited.has( packageName ) ) {
141
+ return 0;
142
+ }
143
+
144
+ visited.add( packageName );
145
+
146
+ const deps = getWordPressDependencies( packageName );
147
+ const relevantDeps = deps.filter( ( dep ) => packagesSet.has( dep ) );
148
+
149
+ if ( relevantDeps.length === 0 ) {
150
+ depths.set( packageName, 0 );
151
+ return 0;
152
+ }
153
+
154
+ const maxDepth = Math.max(
155
+ ...relevantDeps.map( ( dep ) => calculateDepth( dep ) )
156
+ );
157
+ const depth = maxDepth + 1;
158
+ depths.set( packageName, depth );
159
+
160
+ return depth;
161
+ }
162
+
163
+ // Calculate depth for all packages
164
+ for ( const packageName of packages ) {
165
+ calculateDepth( packageName );
166
+ }
167
+
168
+ // Group by depth
169
+ const levels = [];
170
+ const maxDepth = Math.max( ...depths.values() );
171
+
172
+ for ( let depth = 0; depth <= maxDepth; depth++ ) {
173
+ const packagesAtDepth = packages.filter(
174
+ ( pkg ) => depths.get( pkg ) === depth
175
+ );
176
+ if ( packagesAtDepth.length > 0 ) {
177
+ levels.push( packagesAtDepth );
178
+ }
179
+ }
180
+
181
+ return levels;
182
+ }
183
+
184
+ /**
185
+ * Get packages that depend on a given package (reverse dependencies).
186
+ *
187
+ * @param {string} packageName The full package name to find dependents of.
188
+ * @param {string[]} allPackages Array of all full package names to search.
189
+ * @return {string[]} Array of package names that depend on the given package.
190
+ */
191
+ function getReverseDependencies( packageName, allPackages ) {
192
+ const dependents = [];
193
+
194
+ for ( const pkg of allPackages ) {
195
+ const deps = getWordPressDependencies( pkg );
196
+ if ( deps.includes( packageName ) ) {
197
+ dependents.push( pkg );
198
+ }
199
+ }
200
+
201
+ return dependents;
202
+ }
203
+
204
+ /**
205
+ * Find scripts/script-modules that need to be rebundled when a bundled package changes.
206
+ * Uses BFS to traverse reverse dependencies, stopping at script/module boundaries.
207
+ *
208
+ * When a bundled package (no wpScript/wpScriptModuleExports) changes, we need to
209
+ * rebundle any scripts/modules that depend on it through a chain of bundled packages.
210
+ * We stop at script/module boundaries because they handle their own bundling.
211
+ *
212
+ * Example:
213
+ * - A (bundled) changes
214
+ * - B (bundled) depends on A
215
+ * - C (script) depends on B
216
+ * - D (script) depends on C
217
+ * Result: Only C needs rebundling (D stops at C boundary)
218
+ *
219
+ * @param {string} changedPackage The full package name that changed.
220
+ * @param {string[]} allPackages Array of all full package names.
221
+ * @return {string[]} Array of script/module package names to rebundle.
222
+ */
223
+ function findScriptsToRebundle( changedPackage, allPackages ) {
224
+ // If the changed package itself is a script/module, no need to find others
225
+ // (it will be rebuilt by the regular watch logic)
226
+ if ( isScriptOrModule( changedPackage ) ) {
227
+ return [];
228
+ }
229
+
230
+ const scriptsToRebundle = new Set();
231
+ const visited = new Set();
232
+ const queue = [ changedPackage ];
233
+
234
+ while ( queue.length > 0 ) {
235
+ const currentPackage = queue.shift();
236
+
237
+ if ( ! currentPackage || visited.has( currentPackage ) ) {
238
+ continue;
239
+ }
240
+ visited.add( currentPackage );
241
+
242
+ // Get all packages that depend on the current package
243
+ const dependents = getReverseDependencies(
244
+ currentPackage,
245
+ allPackages
246
+ );
247
+
248
+ for ( const dependent of dependents ) {
249
+ // If this dependent is a script/module, add it to the result
250
+ // but don't traverse further (stop at script boundaries)
251
+ if ( isScriptOrModule( dependent ) ) {
252
+ scriptsToRebundle.add( dependent );
253
+ } else if ( ! visited.has( dependent ) ) {
254
+ // If it's a bundled package, continue traversing
255
+ queue.push( dependent );
256
+ }
257
+ }
258
+ }
259
+
260
+ return Array.from( scriptsToRebundle );
261
+ }
262
+
263
+ export {
264
+ getWordPressDependencies,
265
+ buildDependencyGraph,
266
+ topologicalSort,
267
+ groupByDepth,
268
+ isScriptOrModule,
269
+ getReverseDependencies,
270
+ findScriptsToRebundle,
271
+ };
@@ -0,0 +1,71 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import { readFileSync } from 'fs';
5
+ import { fileURLToPath } from 'url';
6
+
7
+ /**
8
+ * Shared cache for package.json files to avoid redundant reads.
9
+ * Cache is keyed by the full package name from package.json's name field.
10
+ */
11
+ const packageJsonCache = new Map();
12
+ const packagePathCache = new Map();
13
+
14
+ /**
15
+ * @typedef {Object} PackageJson
16
+ *
17
+ * @property {string} name Package name.
18
+ * @property {string} version Package version.
19
+ * @property {string} [description] Package description.
20
+ * @property {string} [author] Package author.
21
+ * @property {string} [license] Package license.
22
+ * @property {string} [main] Main entry point.
23
+ * @property {string} [module] ES module entry point.
24
+ * @property {string} [react-native] React Native entry point.
25
+ * @property {Record<string, string>} [dependencies] Runtime dependencies.
26
+ * @property {Record<string, string>} [devDependencies] Development dependencies.
27
+ * @property {Record<string, string>} [peerDependencies] Peer dependencies.
28
+ * @property {string[]} [wpScript] WordPress script handles for dependency extraction.
29
+ * @property {Record<string, string>} [wpScriptModuleExports] WordPress script module exports.
30
+ * @property {Object} [sideEffects] Side effects configuration for tree shaking.
31
+ * @property {string} [publishConfig] NPM publish configuration.
32
+ * @property {Record<string, string>} [scripts] NPM scripts.
33
+ * @property {string[]} [files] Files to include in package.
34
+ * @property {string} [repository] Repository URL.
35
+ * @property {string[]} [keywords] Package keywords.
36
+ */
37
+
38
+ /**
39
+ * Get package.json info using Node's module resolution.
40
+ * Resolves the package using import.meta.resolve and reads its package.json.
41
+ *
42
+ * @param {string} fullPackageName The full package name (e.g., '@wordpress/blocks').
43
+ * @return {PackageJson|null} Package.json object or null if not found.
44
+ */
45
+ export function getPackageInfo( fullPackageName ) {
46
+ if ( packageJsonCache.has( fullPackageName ) ) {
47
+ return packageJsonCache.get( fullPackageName );
48
+ }
49
+
50
+ const resolved = import.meta.resolve( `${ fullPackageName }/package.json` );
51
+ const result = getPackageInfoFromFile( fileURLToPath( resolved ) );
52
+ packageJsonCache.set( fullPackageName, result );
53
+
54
+ return result;
55
+ }
56
+
57
+ /**
58
+ * Get package.json info from an explicit file path.
59
+ * Reads the package.json file and caches it by its name field.
60
+ *
61
+ * @param {string} packageJsonPath Absolute path to package.json file.
62
+ * @return {PackageJson|null} Package.json object or null if not found.
63
+ */
64
+ export function getPackageInfoFromFile( packageJsonPath ) {
65
+ if ( packagePathCache.has( packageJsonPath ) ) {
66
+ return packagePathCache.get( packageJsonPath );
67
+ }
68
+ const packageJson = JSON.parse( readFileSync( packageJsonPath, 'utf8' ) );
69
+ packagePathCache.set( packageJsonPath, packageJson );
70
+ return packageJson;
71
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import { readFile, writeFile, mkdir } from 'fs/promises';
5
+ import path from 'path';
6
+ import { fileURLToPath } from 'url';
7
+
8
+ /**
9
+ * Internal dependencies
10
+ */
11
+ import { getPackageInfoFromFile } from './package-utils.mjs';
12
+
13
+ const __dirname = path.dirname( fileURLToPath( import.meta.url ) );
14
+
15
+ /**
16
+ * Get PHP replacements from root package.json.
17
+ *
18
+ * @param {string} rootDir Root directory path.
19
+ * @return {Promise<Record<string, string>>} Replacements object with {{PREFIX}}, {{VERSION}}, {{VERSION_CONSTANT}}.
20
+ */
21
+ export async function getPhpReplacements( rootDir ) {
22
+ const rootPackageJson = getPackageInfoFromFile(
23
+ path.join( rootDir, 'package.json' )
24
+ );
25
+ if ( ! rootPackageJson ) {
26
+ throw new Error( 'Could not read root package.json' );
27
+ }
28
+
29
+ // @ts-expect-error specific override to package.json
30
+ const name = rootPackageJson.wpPlugin?.name || 'gutenberg';
31
+ const version = rootPackageJson.version;
32
+ const versionConstant =
33
+ name.toUpperCase().replace( /-/g, '_' ) + '_VERSION';
34
+
35
+ return {
36
+ '{{PREFIX}}': name,
37
+ '{{VERSION}}': version,
38
+ '{{VERSION_CONSTANT}}': versionConstant,
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Generate a PHP file from a template with replacements.
44
+ *
45
+ * @param {string} templateName Template file name.
46
+ * @param {string} outputPath Full output path.
47
+ * @param {Record<string, string>} replacements Replacements object (e.g. {'{{PREFIX}}': 'gutenberg'}).
48
+ */
49
+ export async function generatePhpFromTemplate(
50
+ templateName,
51
+ outputPath,
52
+ replacements
53
+ ) {
54
+ // Templates directory
55
+ const templatesDir = path.join( __dirname, '..', 'templates' );
56
+
57
+ // Read template
58
+ const template = await readFile(
59
+ path.join( templatesDir, templateName ),
60
+ 'utf8'
61
+ );
62
+
63
+ // Apply all replacements
64
+ let content = template;
65
+ for ( const [ placeholder, value ] of Object.entries( replacements ) ) {
66
+ content = content.replaceAll( placeholder, value );
67
+ }
68
+
69
+ // Write output file
70
+ await mkdir( path.dirname( outputPath ), { recursive: true } );
71
+ await writeFile( outputPath, content );
72
+ }