Skip to content
Draft
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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/edit-site/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
{ "path": "../escape-html/tsconfig.build.json" },
{ "path": "../fields" },
{ "path": "../global-styles-engine/tsconfig.build.json" },
{ "path": "../global-styles-ui" },
{ "path": "../global-styles-ui/tsconfig.build.json" },
{ "path": "../hooks" },
{ "path": "../html-entities/tsconfig.build.json" },
{ "path": "../i18n/tsconfig.build.json" },
Expand Down
2 changes: 1 addition & 1 deletion packages/editor/tsconfig.build.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
{ "path": "../escape-html/tsconfig.build.json" },
{ "path": "../fields" },
{ "path": "../global-styles-engine/tsconfig.build.json" },
{ "path": "../global-styles-ui" },
{ "path": "../global-styles-ui/tsconfig.build.json" },
{ "path": "../hooks" },
{ "path": "../html-entities/tsconfig.build.json" },
{ "path": "../i18n/tsconfig.build.json" },
Expand Down
2 changes: 1 addition & 1 deletion packages/editor/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
{ "path": "../escape-html/tsconfig.build.json" },
{ "path": "../fields" },
{ "path": "../global-styles-engine/tsconfig.build.json" },
{ "path": "../global-styles-ui" },
{ "path": "../global-styles-ui/tsconfig.build.json" },
{ "path": "../hooks" },
{ "path": "../html-entities/tsconfig.build.json" },
{ "path": "../i18n/tsconfig.build.json" },
Expand Down
4 changes: 4 additions & 0 deletions packages/global-styles-ui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Bug Fixes

- Font Library: Escape uploaded font family names into CSS values before sending them to the server, and decode the escapes when loading font previews, so fonts whose names contain special characters (quotes, ampersands, angle brackets, …) install and render correctly instead of producing broken CSS ([#76782](https://github.com/WordPress/gutenberg/pull/76782)).

### Enhancements

- Expose typography and color controls for citations, inputs, and selects in Global Styles ([#80852](https://github.com/WordPress/gutenberg/pull/80852)).
Expand Down
5 changes: 5 additions & 0 deletions packages/global-styles-ui/global.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/*
* With typeRoots set in tsconfig, global types outside those directories,
* like the jest-dom matchers, must be referenced explicitly.
*/
import '@testing-library/jest-dom';
5 changes: 3 additions & 2 deletions packages/global-styles-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,10 @@
"colord": "^2.9.3"
},
"devDependencies": {
"@jest/globals": "^30.4.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2"
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/jest": "^30.0.0"
},
"peerDependencies": {
"@types/react": "^18 || ^19",
Expand Down
43 changes: 15 additions & 28 deletions packages/global-styles-ui/src/font-library/upload-fonts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,34 +99,21 @@ function UploadFonts() {
* @return {void}
*/
const loadFiles = async ( files: File[] ) => {
const fontFacesLoaded = (
await Promise.all(
files.map< Promise< FontFileMetadata | null > >(
async ( fontFile: File ) => {
const fontFaceMetadata =
await getFontFaceMetadata( fontFile );
const { fontDisplayName, file, ...metadata } =
fontFaceMetadata;

// It's technically possible to use "" as a font's family name, but
// that's disallowed here.
if ( ! fontDisplayName ) {
return null;
}

loadFontFaceInBrowser(
{
...metadata,
fontFamily: createCssString( fontDisplayName ),
},
file,
'all'
);
return fontFaceMetadata;
}
)
)
).filter( ( nullableFontFace ) => !! nullableFontFace );
const fontFacesLoaded = await Promise.all(
files.map( async ( fontFile: File ) => {
const fontFaceMetadata = await getFontFaceMetadata( fontFile );
const { fontDisplayName, file, ...metadata } = fontFaceMetadata;
await loadFontFaceInBrowser(
{
...metadata,
fontFamily: createCssString( fontDisplayName ),
},
file,
'all'
);
return fontFaceMetadata;
} )
);
handleInstall( fontFacesLoaded );
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,36 @@
/*
* Characters kept literal: ASCII letters, digits, "-", "_", and everything
* above U+007F. Every other character is escaped, for two reasons:
*
* - The value must be a fixed point of server-side sanitization
* (`WP_Font_Utils::sanitize_font_family()`, which applies
* `sanitize_text_field()`): no literal whitespace (runs collapse and the
* ends trim), no "<" (tag stripping), no "%" (percent-encoded octet
* stripping), no ",", ";", or quotes (list splitting and re-quoting).
* - When printing `@font-face` rules, core's `WP_Font_Face_Resolver` strips
* the surrounding quotes and `WP_Font_Face` only re-adds them when the
* value contains a literal space, so the value is also parsed as an
* UNQUOTED identifier sequence. Only identifier-safe characters may
* appear as literals or the rule is silently dropped.
*/
const ESCAPED_CHARACTER_PATTERN = /\r\n|[^A-Za-z0-9_\u{0080}-\u{10FFFF}-]/gu;

function escapeCharacter( character: string ): string {
// The newline forms are normalized to the LF escape, matching CSS input
// preprocessing. https://www.w3.org/TR/css-syntax-3/#input-preprocessing
if ( character === '\r\n' || character === '\r' || character === '\f' ) {
character = '\n';
}
return (
'\\' +
character
.codePointAt( 0 )!
.toString( 16 )
.toUpperCase()
.padStart( 6, '0' )
);
}

/**
* Construct a quoted CSS String from a plain JavaScript value.
*
Expand All @@ -12,59 +45,33 @@
* const fontFaceRule = `@font-face { font-family: ${createCssString(fontName)}; }`
* ```
*
* `fontFaceRule` will have the value `'@font-face { font-family: "CSS \\26 a \\22 \\3C style\\3E \\22 tag\\27 s strings"; }'`.
* `fontFaceRule` will have the value
* `'@font-face { font-family: "CSS\\000020\\000026\\000020a\\000020\\000022\\00003Cstyle\\00003E\\000022\\000020tag\\000027s\\000020strings"; }'`.
* The browser will parse this rule as equivalent to: `@font-face { font-family: "CSS & a \"<style>\" tag's strings"; }`.
*
* Some characters that are valid in CSS strings are escaped to avoid problems with subsequent
* processing that may not correctly parse CSS:
*
* - "<", ">", and "&" are replaced to prevent issues with KSES and other sanitization that
* is confused by HTML-like text.
* - CSS syntax characters are replaced to prevent issues where CSS may be processed by simple
* string splits or search and replaces: "',;{}
* - Plain "\" backslashes are escaped.
* Escape sequences use the fixed six-digit form (`\XXXXXX`) with no
* whitespace terminator. The escaped content is BOTH a valid CSS string and
* a valid CSS identifier sequence that decode to the same name: WordPress
* stores the value byte-identically (it is a fixed point of
* `WP_Font_Utils::sanitize_font_family()`), and the server-side `@font-face`
* printer re-emits it without the surrounding quotes, where an
* identifier-invalid literal would make browsers silently drop the rule.
*
* @param value The JavaScript string to serialize as a quoted CSS string.
* @return A quoted CSS string suitable for interpolation in CSS text.
*/
export function createCssString( value: string ): string {
return `"${ value
/*
* CSS Unicode escaping for problematic characters.
* https://www.w3.org/TR/css-syntax-3/#escaping
*
* Note that the Unicode escape sequences are used rather than backslash-escaping so the
* problematic characters are removed completely. CSS Unicode escapes are formed by a
* "\" followed by the character code in hexadecimal. The escape may be terminated by
* whitespace which is ignored.
*/

// Escape existing backslashes to prevent unintentional escapes in result.
.replaceAll( '\\', '\\5C ' )

// Pre-processing replaces NULLs and some newlines. Replace and escape as necessary.
.replaceAll( '\0', '\uFFFD' )

// Normalize and replace newlines. https://www.w3.org/TR/css-syntax-3/#input-preprocessing
.replaceAll( '\r\n', '\\A ' )
.replaceAll( '\r', '\\A ' )
.replaceAll( '\f', '\\A ' )

// Newlines must be escaped in CSS strings.
.replaceAll( '\n', '\\A ' )

// Arbitrary characters for Unicode escaping:
let escaped = value
// NULL is replaced with U+FFFD, matching CSS input preprocessing.
.replaceAll( '\0', '�' )
.replace( ESCAPED_CHARACTER_PATTERN, escapeCharacter );

// HTML syntax may be problematic.
.replaceAll( '<', '\\3C ' )
.replaceAll( '>', '\\3E ' )
.replaceAll( '&', '\\26 ' )
// An identifier sequence cannot start with a digit, or with "-" followed
// by a digit (or standing alone); escape the leading character so the
// unquoted form stays valid.
if ( /^[0-9]/.test( escaped ) || /^-($|[0-9])/.test( escaped ) ) {
escaped = escapeCharacter( escaped[ 0 ] ) + escaped.slice( 1 );
}

// CSS syntax may be problematic.
.replaceAll( ',', '\\2C ' )
.replaceAll( ';', '\\3B ' )
.replaceAll( '{', '\\7B ' )
.replaceAll( '}', '\\7D ' )
.replaceAll( '"', '\\22 ' )
.replaceAll( "'", '\\27 ' ) }"`;
return `"${ escaped }"`;
}
Loading
Loading