Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@
"format": "wp-scripts format",
"preformat:php": "npm run other:update-packages:php",
"format:php": "wp-env run --env-cwd='wp-content/plugins/gutenberg' cli composer run-script format",
"prelint": "npm run --if-present --workspaces prelint",
"lint": "concurrently \"npm run lint:lockfile\" \"npm run lint:tsconfig\" \"npm run lint:js\" \"npm run lint:pkg-json\" \"npm run lint:css\"",
"lint:css": "wp-scripts lint-style \"**/*.scss\"",
"lint:css:fix": "npm run lint:css -- --fix",
Expand Down Expand Up @@ -273,6 +274,7 @@
"wp-scripts format"
],
"*.{js,ts,tsx}": [
"npm run prelint",
"wp-scripts lint-js"
],
"*.scss": [
Expand Down
2 changes: 2 additions & 0 deletions packages/icons/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
src/library/index.ts
src/library/*.tsx
2 changes: 2 additions & 0 deletions packages/icons/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- Non-breaking, but major change: Switch to SVG icons as canonical source format, letting the system auto-generate the React elements and index. ([#71878](https://github.com/WordPress/gutenberg/pull/71878))

## 10.32.0 (2025-10-01)

## 10.31.0 (2025-09-17)
Expand Down
192 changes: 192 additions & 0 deletions packages/icons/bin/generate-library.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
/**
* External dependencies
*/
const path = require( 'path' );
const { readdir, readFile, writeFile } = require( 'fs' ).promises;
const { execFile } = require( 'child_process' );
const { promisify } = require( 'util' );

const execFileAsync = promisify( execFile );

const ICON_LIBRARY_DIR = path.join( __dirname, '..', 'src', 'library' );

// - Find *.svg files in ./library
// - For each, generate a sibling .tsx file
// - Build an index of these at ./library/index.ts
//
// Note that the generated files are ignored by Git.

// The SOURCE OF TRUTH for this package's library of icons consists of the SVG
// files found under `src/library`. We must thus first generate the TSX files
// corresponding to each SVG file, as well as an index of imports at
// `src/library/index.ts`.
async function main() {
await ensureSvgFilesTracked();
await cleanup();
await generateTsxFiles();
await generateIndex();
}

// Before automatically generating TSX files from SVG ones, ensure that all
// SVGs found are intended to be processed. If they aren't under version
// control, there is a chance that their presence is accidental, so halt.
async function ensureSvgFilesTracked() {
let untrackedFiles;
try {
// Avoid invoking `ls-files` with a wildcard (`*.svg`) due to the
// variability of wildcard behaviour across shells.
const { stdout } = await execFileAsync( 'git', [
'ls-files',
'-o',
'--full-name',
ICON_LIBRARY_DIR,
] );

// Filtering with `grep` in a single `exec` call was tempting, but this
// manual filtering avoids any shell escaping weirdness.
untrackedFiles = stdout
.trim()
.split( '\n' )
.filter( ( f ) => f.endsWith( '.svg' ) );
} catch {
return;
}

if ( untrackedFiles.length > 0 ) {
throw new Error(
`The following SVG files are not under version control:\n\n${ untrackedFiles
.map( ( file ) => ` - ${ file }` )
.join(
'\n'
) }\n\nPlease either delete them or add them to Git first:\n\n\tgit add ${ untrackedFiles.join(
' '
) }\n`
);
}
}

async function cleanup() {
await execFileAsync( 'git', [ 'clean', '-Xfq', ICON_LIBRARY_DIR ] );
}

// Generate src/library/*.tsx based on the available SVG files.
async function generateTsxFiles() {
const svgFiles = ( await readdir( ICON_LIBRARY_DIR ) ).filter( ( file ) =>
// Stricter than just checking for SVG suffix, thereby avoiding hidden
// files and characters that would get in the way of camel-casing.
file.match( /^[a-z0-9--]+\.svg$/ )
);

await Promise.all(
svgFiles.map( async ( svgFile ) => {
const svgPath = path.join( ICON_LIBRARY_DIR, svgFile );
const svgContent = await readFile( svgPath, 'utf8' );

const componentContent = svgToTsx( svgContent );
if ( ! componentContent ) {
throw new Error(
`Could not generate icon element from ${ svgPath }`
);
}

const tsxPath = svgPath.replace( /\.svg$/, '.tsx' );
await writeFile( tsxPath, componentContent );
} )
);
}

// Generate src/library/index.ts as a list of exports of the library's modules.
async function generateIndex() {
const tsxFiles = ( await readdir( ICON_LIBRARY_DIR ) ).filter( ( file ) =>
file.endsWith( '.tsx' )
);

let indexTemplate = tsxFiles
.map( ( file ) => {
const importPath = path.basename( file, '.tsx' );

// Camel case, but retaining 'RTL' acronym in uppercase
const identifier = importPath
.replace( /-([0-9A-Za-z])/g, ( _, c ) => c.toUpperCase() )
.replace( /Rtl\b/, 'RTL' );

return `export { default as ${ identifier } } from './${ importPath }';`;
} )
.join( '\n' );

// Trailing newlines make ESLint happy
indexTemplate += '\n';

await writeFile( path.join( ICON_LIBRARY_DIR, 'index.ts' ), indexTemplate );
}

// "Transform" to TSX by interpolating the SVG source into a simple TS module
// with a single default export.
//
// Detect SVG tags like `<circle>` and promote them to WordPress primitives
// like `<Circle />`, taking care of importing those primitives first.
function svgToTsx( svgContent ) {
let jsxContent = svgContent.trim();

jsxContent = jsxContent.replace( /\sclass=/g, ' className=' );

// Tags that ought to be converted to WordPress primitives when converting
// SVGs to React elements
const primitives = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think now that the mobile gutenberg is not really being developped we should get rid of the primitives package at some point.

circle: 'Circle',
clippath: 'ClipPath',
defs: 'Defs',
ellipse: 'Ellipse',
g: 'G',
line: 'Line',
path: 'Path',
polygon: 'Polygon',
polyline: 'Polyline',
rect: 'Rect',
svg: 'SVG',
};

// Prepare regular expressions to match opening tags and closing tags to
// transform to primitives: <circle ...>, </circle>, etc.
const tagsRe = Object.keys( primitives ).join( '|' );
const openRe = new RegExp( `<(${ tagsRe })\\b`, 'g' );
const closeRe = new RegExp( `<\/(${ tagsRe })>`, 'g' );

// Keep track of primitives used in the SVG body to later generate the
// imports statement
const usedPrimitives = new Set();

// Transform from <circle> to <Circle>, etc.
jsxContent = jsxContent
.replace( openRe, ( _, tagName ) => {
const primitive = primitives[ tagName ];
usedPrimitives.add( primitive );
return `<${ primitive }`;
} )
.replace( closeRe, ( _, tagName ) => {
const primitive = primitives[ tagName ];
return `</${ primitive }>`;
} );

// Indent by one level
jsxContent = jsxContent
.split( '\n' )
.map( ( line ) => '\t' + line )
.join( '\n' );

return `/* eslint-disable prettier/prettier */
/**
* WordPress dependencies
*/
import { ${ Array.from( usedPrimitives )
.sort()
.join( ', ' ) } } from '@wordpress/primitives';
export default (
${ jsxContent }
);
/* eslint-enable */
`;
}

main();
4 changes: 4 additions & 0 deletions packages/icons/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,9 @@
},
"publishConfig": {
"access": "public"
},
"scripts": {
"prelint": "npm run build",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand why is this in "prelint" and not "prebuild" or directly in "build" or something like that?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prebuild is too late. That's what I tried first. Because it's not just TS that needs resolvable import paths: ESLint needs it too.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see

"build": "node bin/generate-library"
}
}
9 changes: 1 addition & 8 deletions packages/icons/src/icon/stories/index.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,7 @@ import keywords from './keywords';
*/
import type { ReactElement } from 'react';

const {
Icon: _Icon,

// Deprecated aliases
warning: _warning,

...availableIcons
} = icons;
const { Icon: _Icon, ...availableIcons } = icons;

const meta = {
component: Icon,
Expand Down
Loading
Loading