From f8f8bf98f037ae3481dc5999ba97b878b0d1d80c Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 16 Sep 2026 18:06:55 -0700 Subject: [PATCH 01/32] Add the emoji loader to the TypeScript checked files Include src/js/_enqueues/lib/emoji-loader.js in the tsconfig.json file list and resolve the 30 errors that surfaced, so that the file checks cleanly. Two defects came to light in the process: * supportsWorkerOffloading() did not return a boolean, contrary to its documented return type. The Worker feature detection tested the truthiness of URL.createObjectURL, which is always defined, so the expression evaluated to the method itself rather than to true. It now checks that the method is callable. * The WPEmojiSettings typedef documented source.concatemoji, source.twemoji and source.wpemoji as nested properties of a plain object, which meant none of them were ever actually typed. The supports property that this script writes and that wp-emoji.js reads was not documented at all. Both are now described by dedicated WPEmojiSettingsSource and EmojiSupports typedefs, and SupportTests is narrowed from nullable booleans to booleans, matching what browserSupportsEmoji() returns. The remaining changes are annotations rather than logic. The session storage value is null checked before being parsed, the promise wrapping the support tests is given an explicit type parameter, and the drawing context is cast so that one set of tests can continue to serve both the canvas and the OffscreenCanvas contexts. WorkerGlobalScope is declared in a new typings/worker-globals entry. It is absent from the dom lib, and the webworker lib that provides it cannot be added alongside dom. Co-Authored-By: Claude Opus 5 --- src/js/_enqueues/lib/emoji-loader.js | 108 ++++++++++++++++++--------- tsconfig.json | 2 + typings/worker-globals/index.d.ts | 9 +++ 3 files changed, 85 insertions(+), 34 deletions(-) create mode 100644 typings/worker-globals/index.d.ts diff --git a/src/js/_enqueues/lib/emoji-loader.js b/src/js/_enqueues/lib/emoji-loader.js index 5bce5aa43a44e..99fd9fab5bfba 100644 --- a/src/js/_enqueues/lib/emoji-loader.js +++ b/src/js/_enqueues/lib/emoji-loader.js @@ -4,14 +4,24 @@ // Note: This is loaded as a script module, so there is no need for an IIFE to prevent pollution of the global scope. +/** + * Emoji script source URLs as exported in PHP via _print_emoji_detection_script(). + * + * @typedef WPEmojiSettingsSource + * @type {Object} + * @property {string} [concatemoji] URL for the concatenated emoji script. + * @property {string} [twemoji] URL for the Twemoji script. + * @property {string} [wpemoji] URL for the wp-emoji script. + */ + /** * Emoji Settings as exported in PHP via _print_emoji_detection_script(). + * * @typedef WPEmojiSettings * @type {Object} - * @property {?object} source - * @property {?string} source.concatemoji - * @property {?string} source.twemoji - * @property {?string} source.wpemoji + * @property {WPEmojiSettingsSource} [source] Emoji script source URLs. + * @property {EmojiSupports} supports Which emoji the browser supports. Not exported from + * PHP; populated by this script. */ const selector = 'script#wp-emoji-settings'; @@ -22,17 +32,33 @@ if ( ! ( script instanceof HTMLScriptElement ) ) { const settings = /** @type {WPEmojiSettings} */ ( JSON.parse( script.text ) ); // For compatibility with other scripts that read from this global, in particular wp-includes/js/wp-emoji.js (source file: js/_enqueues/wp/emoji.js). -window._wpemojiSettings = settings; +/** @type {Window & { _wpemojiSettings?: WPEmojiSettings }} */ ( window )._wpemojiSettings = settings; /** - * Support tests. + * Results of the emoji support tests. + * * @typedef SupportTests * @type {Object} - * @property {?boolean} flag - * @property {?boolean} emoji + * @property {boolean} flag Whether the browser renders flag emoji. + * @property {boolean} emoji Whether the browser renders emoji. + */ + +/** + * Emoji support as exposed on the settings object for other scripts to read. + * + * The individual test results are absent until the support tests have completed. + * + * @typedef EmojiSupports + * @type {Object} + * @property {boolean} everything Whether the browser passed every test. + * @property {boolean} everythingExceptFlag Whether the browser passed every test but the flag test. + * @property {boolean} [flag] Whether the browser renders flag emoji. + * @property {boolean} [emoji] Whether the browser renders emoji. */ const sessionStorageKey = 'wpEmojiSettingsSupports'; + +/** @type {Array} */ const tests = [ 'flag', 'emoji' ]; /** @@ -49,7 +75,7 @@ function supportsWorkerOffloading() { typeof Worker !== 'undefined' && typeof OffscreenCanvas !== 'undefined' && typeof URL !== 'undefined' && - URL.createObjectURL && + typeof URL.createObjectURL === 'function' && typeof Blob !== 'undefined' ); } @@ -72,10 +98,13 @@ function supportsWorkerOffloading() { */ function getSessionSupportTests() { try { + const itemJson = sessionStorage.getItem( sessionStorageKey ); + if ( null === itemJson ) { + return null; + } + /** @type {SessionSupportTests} */ - const item = JSON.parse( - sessionStorage.getItem( sessionStorageKey ) - ); + const item = JSON.parse( itemJson ); if ( typeof item === 'object' && typeof item.timestamp === 'number' && @@ -302,10 +331,10 @@ function browserSupportsEmoji( context, type, emojiSetsRenderIdentically, emojiR * * @private * - * @param {string[]} tests Tests. - * @param {Function} browserSupportsEmoji Reference to browserSupportsEmoji function, needed due to minification. - * @param {Function} emojiSetsRenderIdentically Reference to emojiSetsRenderIdentically function, needed due to minification. - * @param {Function} emojiRendersEmptyCenterPoint Reference to emojiRendersEmptyCenterPoint function, needed due to minification. + * @param {Array} tests Tests. + * @param {Function} browserSupportsEmoji Reference to browserSupportsEmoji function, needed due to minification. + * @param {Function} emojiSetsRenderIdentically Reference to emojiSetsRenderIdentically function, needed due to minification. + * @param {Function} emojiRendersEmptyCenterPoint Reference to emojiRendersEmptyCenterPoint function, needed due to minification. * * @return {SupportTests} Support tests. */ @@ -320,7 +349,13 @@ function testEmojiSupports( tests, browserSupportsEmoji, emojiSetsRenderIdentica canvas = document.createElement( 'canvas' ); } - const context = canvas.getContext( '2d', { willReadFrequently: true } ); + /* + * Note: The OffscreenCanvas 2D context implements everything the tests below use, so it is cast + * to the canvas 2D context rather than each test having to account for both. + */ + const context = /** @type {CanvasRenderingContext2D} */ ( + /** @type {unknown} */ ( canvas.getContext( '2d', { willReadFrequently: true } ) ) + ); /* * Chrome on OS X added native emoji rendering in M41. Unfortunately, @@ -330,7 +365,7 @@ function testEmojiSupports( tests, browserSupportsEmoji, emojiSetsRenderIdentica context.textBaseline = 'top'; context.font = '600 32px Arial'; - const supports = {}; + const supports = /** @type {SupportTests} */ ( {} ); tests.forEach( ( test ) => { supports[ test ] = browserSupportsEmoji( context, test, emojiSetsRenderIdentically, emojiRendersEmptyCenterPoint ); } ); @@ -361,10 +396,11 @@ settings.supports = { }; // Obtain the emoji support from the browser, asynchronously when possible. -new Promise( ( resolve ) => { - let supportTests = getSessionSupportTests(); - if ( supportTests ) { - resolve( supportTests ); +/** @type {Promise} */ +const supportTestsPromise = new Promise( ( resolve ) => { + const sessionSupportTests = getSessionSupportTests(); + if ( sessionSupportTests ) { + resolve( sessionSupportTests ); return; } @@ -387,19 +423,21 @@ new Promise( ( resolve ) => { } ); const worker = new Worker( URL.createObjectURL( blob ), { name: 'wpTestEmojiSupports' } ); worker.onmessage = ( event ) => { - supportTests = event.data; - setSessionSupportTests( supportTests ); + const workerSupportTests = /** @type {SupportTests} */ ( event.data ); + setSessionSupportTests( workerSupportTests ); worker.terminate(); - resolve( supportTests ); + resolve( workerSupportTests ); }; return; } catch ( e ) {} } - supportTests = testEmojiSupports( tests, browserSupportsEmoji, emojiSetsRenderIdentically, emojiRendersEmptyCenterPoint ); - setSessionSupportTests( supportTests ); - resolve( supportTests ); -} ) + const testedSupportTests = testEmojiSupports( tests, browserSupportsEmoji, emojiSetsRenderIdentically, emojiRendersEmptyCenterPoint ); + setSessionSupportTests( testedSupportTests ); + resolve( testedSupportTests ); +} ); + +supportTestsPromise // Once the browser emoji support has been obtained from the session, finalize the settings. .then( ( supportTests ) => { /* @@ -407,15 +445,17 @@ new Promise( ( resolve ) => { * support settings accordingly. */ for ( const test in supportTests ) { - settings.supports[ test ] = supportTests[ test ]; + const key = /** @type {keyof SupportTests} */ ( test ); + const supported = supportTests[ key ]; + + settings.supports[ key ] = supported; settings.supports.everything = - settings.supports.everything && settings.supports[ test ]; + settings.supports.everything && supported; - if ( 'flag' !== test ) { + if ( 'flag' !== key ) { settings.supports.everythingExceptFlag = - settings.supports.everythingExceptFlag && - settings.supports[ test ]; + settings.supports.everythingExceptFlag && supported; } } diff --git a/tsconfig.json b/tsconfig.json index c583f18b34ddd..a918b1d06373b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,7 @@ "types": [ "node", "wp-globals", + "worker-globals", "codemirror/addon/lint/lint", "codemirror/addon/hint/show-hint" ] @@ -29,6 +30,7 @@ "files": [ "src/js/_enqueues/lib/codemirror/htmlhint-kses.js", "src/js/_enqueues/lib/codemirror/javascript-lint.js", + "src/js/_enqueues/lib/emoji-loader.js", "src/js/_enqueues/wp/code-editor.js", "src/js/_enqueues/wp/wp-tooltip.js", "tools/gutenberg/copy.js", diff --git a/typings/worker-globals/index.d.ts b/typings/worker-globals/index.d.ts new file mode 100644 index 0000000000000..acd848a428f06 --- /dev/null +++ b/typings/worker-globals/index.d.ts @@ -0,0 +1,9 @@ +/** + * Globals which exist only when a script is running inside a Worker. + * + * These are declared by TypeScript's `webworker` lib, which cannot be added alongside the `dom` lib + * because the two conflict. Code which may run in either context therefore has to declare them, and + * must guard each one with a `typeof` check before use. + */ + +declare var WorkerGlobalScope: Function | undefined; From 48bf2fadcd869636c8efe3c2a5df5d19fa07afdf Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 16 Sep 2026 22:09:44 -0700 Subject: [PATCH 02/32] Add wp-emoji to the TypeScript checked files Include src/js/_enqueues/wp/emoji.js in the tsconfig.json file list and resolve the 27 errors that surfaced. The emoji settings types move out of emoji-loader.js and into a new typings/wp-emoji entry, now that a second file reads them back. Keeping them as JSDoc in emoji-loader.js did work, because TypeScript treats that file as a global script and so publishes its typedefs globally, but that arrangement would break the moment either file gained an import or an export. The same entry declares the vendored Twemoji library, whose parse options include a doNotParse callback that upstream does not have. The settings types were also incomplete: baseUrl, ext, svgUrl and svgExt are all exported from PHP and read by wp-emoji, and none of them were described. typings/worker-globals is renamed to typings/browser-globals, since the vendor-prefixed MutationObserver aliases belong beside WorkerGlobalScope as another thing the dom lib does not declare. Typing Twemoji revealed that the onerror handler passed to it never does anything. It refers to the library object in three places where it means the image element, and the library object has no parentNode, so the guard is never true. A broken emoji image is therefore never replaced by its alt text, and the data-error attribute the MutationObserver tests for is never set, leaving that branch dead as well. Correcting this changes behavior, so it is marked with @ts-expect-error and a note, and is left to be tracked on its own. Those directives fail once the references are corrected, so they cannot be forgotten. The remaining changes are annotations. Two small extractions were needed to give TypeScript something to narrow: doNotParse reads the class name into a variable before testing it, and the imgAttr override captures the value rather than reading it through the arguments object inside a closure. Co-Authored-By: Claude Opus 5 --- src/js/_enqueues/lib/emoji-loader.js | 35 +-------- src/js/_enqueues/wp/emoji.js | 59 ++++++++++---- tsconfig.json | 4 +- typings/browser-globals/index.d.ts | 20 +++++ typings/worker-globals/index.d.ts | 9 --- typings/wp-emoji/index.d.ts | 112 +++++++++++++++++++++++++++ 6 files changed, 182 insertions(+), 57 deletions(-) create mode 100644 typings/browser-globals/index.d.ts delete mode 100644 typings/worker-globals/index.d.ts create mode 100644 typings/wp-emoji/index.d.ts diff --git a/src/js/_enqueues/lib/emoji-loader.js b/src/js/_enqueues/lib/emoji-loader.js index 99fd9fab5bfba..d74282e894d6d 100644 --- a/src/js/_enqueues/lib/emoji-loader.js +++ b/src/js/_enqueues/lib/emoji-loader.js @@ -4,25 +4,7 @@ // Note: This is loaded as a script module, so there is no need for an IIFE to prevent pollution of the global scope. -/** - * Emoji script source URLs as exported in PHP via _print_emoji_detection_script(). - * - * @typedef WPEmojiSettingsSource - * @type {Object} - * @property {string} [concatemoji] URL for the concatenated emoji script. - * @property {string} [twemoji] URL for the Twemoji script. - * @property {string} [wpemoji] URL for the wp-emoji script. - */ - -/** - * Emoji Settings as exported in PHP via _print_emoji_detection_script(). - * - * @typedef WPEmojiSettings - * @type {Object} - * @property {WPEmojiSettingsSource} [source] Emoji script source URLs. - * @property {EmojiSupports} supports Which emoji the browser supports. Not exported from - * PHP; populated by this script. - */ +// Note: The WPEmojiSettings and EmojiSupports types are declared in typings/wp-emoji, since wp-emoji.js reads them back. const selector = 'script#wp-emoji-settings'; const script = document.querySelector( selector ); @@ -32,7 +14,7 @@ if ( ! ( script instanceof HTMLScriptElement ) ) { const settings = /** @type {WPEmojiSettings} */ ( JSON.parse( script.text ) ); // For compatibility with other scripts that read from this global, in particular wp-includes/js/wp-emoji.js (source file: js/_enqueues/wp/emoji.js). -/** @type {Window & { _wpemojiSettings?: WPEmojiSettings }} */ ( window )._wpemojiSettings = settings; +window._wpemojiSettings = settings; /** * Results of the emoji support tests. @@ -43,19 +25,6 @@ const settings = /** @type {WPEmojiSettings} */ ( JSON.parse( script.text ) ); * @property {boolean} emoji Whether the browser renders emoji. */ -/** - * Emoji support as exposed on the settings object for other scripts to read. - * - * The individual test results are absent until the support tests have completed. - * - * @typedef EmojiSupports - * @type {Object} - * @property {boolean} everything Whether the browser passed every test. - * @property {boolean} everythingExceptFlag Whether the browser passed every test but the flag test. - * @property {boolean} [flag] Whether the browser renders flag emoji. - * @property {boolean} [emoji] Whether the browser renders emoji. - */ - const sessionStorageKey = 'wpEmojiSettingsSupports'; /** @type {Array} */ diff --git a/src/js/_enqueues/wp/emoji.js b/src/js/_enqueues/wp/emoji.js index 274868f52d859..228669f5fb8dd 100644 --- a/src/js/_enqueues/wp/emoji.js +++ b/src/js/_enqueues/wp/emoji.js @@ -2,11 +2,21 @@ * wp-emoji.js is used to replace emoji with images in browsers when the browser * doesn't support emoji natively. * - * @param {Window} window The global window object. - * @param {Object} settings The settings object. + * @param {Window} window The global window object. + * @param {WPEmojiSettings} settings The settings object. * @output wp-includes/js/wp-emoji.js */ +/** + * Additional options accepted by wp.emoji.parse(). + * + * @typedef WPEmojiParseArgs + * @type {Object} + * @property {string} [className] Class name to give each generated image. + * @property {Record} [imgAttr] Attributes to set on each generated image, in + * place of the default ones. + */ + ( function( window, settings ) { /** * Replaces emoji with images when browsers don't support emoji. @@ -28,7 +38,10 @@ document = window.document, // Private. - twemoji, timer, + /** @type {Twemoji|undefined} */ + twemoji, + /** @type {number|undefined} */ + timer, loaded = false, count = 0, ie11 = window.navigator.userAgent.indexOf( 'Trident/7.0' ) > 0; @@ -113,8 +126,8 @@ ii === 1 && removedNodes.length === 1 && addedNodes[0].nodeType === 3 && removedNodes[0].nodeName === 'IMG' && - addedNodes[0].data === removedNodes[0].alt && - 'load-failed' === removedNodes[0].getAttribute( 'data-error' ) + /** @type {Text} */ ( addedNodes[0] ).data === /** @type {HTMLImageElement} */ ( removedNodes[0] ).alt && + 'load-failed' === /** @type {HTMLImageElement} */ ( removedNodes[0] ).getAttribute( 'data-error' ) ) { return; } @@ -139,7 +152,7 @@ * Node type 3 is a TEXT_NODE. */ while( node.nextSibling && 3 === node.nextSibling.nodeType ) { - node.nodeValue = node.nodeValue + node.nextSibling.nodeValue; + node.nodeValue = /** @type {string} */ ( node.nodeValue ) + /** @type {string} */ ( node.nextSibling.nodeValue ); node.parentNode.removeChild( node.nextSibling ); } } @@ -148,7 +161,7 @@ } if ( test( node.textContent ) ) { - parse( node ); + parse( /** @type {HTMLElement} */ ( node ) ); } } } @@ -168,7 +181,7 @@ * * @memberOf wp.emoji * - * @param {string} text The string to test. + * @param {?string} text The string to test. * * @return {boolean} Whether the string contains emoji characters. */ @@ -197,12 +210,13 @@ * @memberOf wp.emoji * * @param {HTMLElement|string} object The element or string to parse. - * @param {Object} args Additional options for Twemoji. + * @param {WPEmojiParseArgs} [args] Additional options for Twemoji. * * @return {HTMLElement|string} A string where all emoji are now image tags of * emoji. Or the element that was passed as the first argument. */ function parse( object, args ) { + /** @type {TwemojiParseOptions} */ var params; /* @@ -250,17 +264,32 @@ }; }, onerror: function() { + /* + * TODO: This handler never does anything. It refers to the Twemoji library object + * in three places where it means the image element, which is what Twemoji's own + * onerror uses and what `this` is bound to here. The library object has no + * parentNode, so the condition below is never true: the data-error attribute is + * never set, and a broken image is never replaced by its alt text. The + * MutationObserver above tests for that same attribute, so it is dead too. + * + * Fixing this changes behavior, so it is being tracked separately. The + * @ts-expect-error directives below are what keep that decision from being made + * silently here; they will start failing once the references are corrected. + */ + // @ts-expect-error -- See the note above. if ( twemoji.parentNode ) { this.setAttribute( 'data-error', 'load-failed' ); + // @ts-expect-error -- See the note above. twemoji.parentNode.replaceChild( document.createTextNode( twemoji.alt ), twemoji ); } }, doNotParse: function( node ) { + var className = node && /** @type {Element} */ ( node ).className; + if ( - node && - node.className && - typeof node.className === 'string' && - node.className.indexOf( 'wp-exclude-emoji' ) !== -1 + className && + typeof className === 'string' && + className.indexOf( 'wp-exclude-emoji' ) !== -1 ) { // Do not parse this node. Emojis will not be replaced in this node and all sub-nodes. return true; @@ -271,8 +300,10 @@ }; if ( typeof args.imgAttr === 'object' ) { + var imgAttr = args.imgAttr; + params.attributes = function() { - return args.imgAttr; + return imgAttr; }; } diff --git a/tsconfig.json b/tsconfig.json index a918b1d06373b..8cf26250a1f92 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,7 +21,8 @@ "types": [ "node", "wp-globals", - "worker-globals", + "browser-globals", + "wp-emoji", "codemirror/addon/lint/lint", "codemirror/addon/hint/show-hint" ] @@ -32,6 +33,7 @@ "src/js/_enqueues/lib/codemirror/javascript-lint.js", "src/js/_enqueues/lib/emoji-loader.js", "src/js/_enqueues/wp/code-editor.js", + "src/js/_enqueues/wp/emoji.js", "src/js/_enqueues/wp/wp-tooltip.js", "tools/gutenberg/copy.js", "tools/gutenberg/download.js", diff --git a/typings/browser-globals/index.d.ts b/typings/browser-globals/index.d.ts new file mode 100644 index 0000000000000..423154b770188 --- /dev/null +++ b/typings/browser-globals/index.d.ts @@ -0,0 +1,20 @@ +/** + * Globals which the TypeScript `dom` lib does not declare. + * + * Each is optional, because none of them can be assumed to exist. Code must guard every one of them + * with a `typeof` check, or reach it as a property of `window`, before use. + */ + +/** + * Present only when the script is running inside a Worker. + * + * This is declared by the `webworker` lib, which cannot be added alongside the `dom` lib because the + * two conflict. Code which may run in either context therefore has to declare it. + */ +declare var WorkerGlobalScope: Function | undefined; + +/** + * Vendor-prefixed aliases of `MutationObserver`, from before the unprefixed name was standardized. + */ +declare var WebKitMutationObserver: typeof MutationObserver | undefined; +declare var MozMutationObserver: typeof MutationObserver | undefined; diff --git a/typings/worker-globals/index.d.ts b/typings/worker-globals/index.d.ts deleted file mode 100644 index acd848a428f06..0000000000000 --- a/typings/worker-globals/index.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Globals which exist only when a script is running inside a Worker. - * - * These are declared by TypeScript's `webworker` lib, which cannot be added alongside the `dom` lib - * because the two conflict. Code which may run in either context therefore has to declare them, and - * must guard each one with a `typeof` check before use. - */ - -declare var WorkerGlobalScope: Function | undefined; diff --git a/typings/wp-emoji/index.d.ts b/typings/wp-emoji/index.d.ts new file mode 100644 index 0000000000000..66d019381f2fe --- /dev/null +++ b/typings/wp-emoji/index.d.ts @@ -0,0 +1,112 @@ +/** + * Types for the emoji settings and for the vendored Twemoji library. + * + * These live here rather than beside either script because two files share them: the emoji loader + * (js/_enqueues/lib/emoji-loader.js) reads the settings and records which emoji the browser + * supports, and wp-emoji (js/_enqueues/wp/emoji.js) then reads both back. + */ + +/** + * Emoji script source URLs. + * + * Either `concatemoji` alone, or `wpemoji` together with `twemoji`, depending on whether the + * concatenated script is in use. + */ +interface WPEmojiSettingsSource { + concatemoji?: string; + twemoji?: string; + wpemoji?: string; +} + +/** + * Which emoji the browser supports. + * + * The individual test results are absent until the support tests have completed. + */ +interface EmojiSupports { + /** Whether the browser passed every test. */ + everything: boolean; + /** Whether the browser passed every test but the flag test. */ + everythingExceptFlag: boolean; + /** Whether the browser renders flag emoji. */ + flag?: boolean; + /** Whether the browser renders emoji. */ + emoji?: boolean; +} + +/** + * Emoji settings as exported in PHP via `_print_emoji_detection_script()`. + */ +interface WPEmojiSettings { + /** Base URL for the PNG emoji images. */ + baseUrl: string; + /** File extension for the PNG emoji images. */ + ext: string; + /** Base URL for the SVG emoji images. */ + svgUrl: string; + /** File extension for the SVG emoji images. */ + svgExt: string; + /** Emoji script source URLs. */ + source?: WPEmojiSettingsSource; + /** + * Which emoji the browser supports. + * + * Not exported from PHP; populated by the emoji loader. + */ + supports: EmojiSupports; +} + +/** + * The emoji settings, as published by the emoji loader for other scripts to read. + */ +declare var _wpemojiSettings: WPEmojiSettings; + +/** + * Options accepted by `twemoji.parse()`. + * + * Note that `doNotParse` is not part of the upstream library. The vendored copy in + * js/_enqueues/vendor/twemoji.js was patched to add it. + */ +interface TwemojiParseOptions { + /** Base URL to prepend to each image source. */ + base?: string; + /** File extension to append to each image source. */ + ext?: string; + /** Class name to give each generated image. */ + className?: string; + /** Returns the source for an icon, or false to leave the character as it is. */ + callback?: ( icon: string, options: TwemojiResolvedParseOptions ) => string | false; + /** Returns the attributes to set on each generated image. */ + attributes?: ( rawText: string, iconId: string ) => Record< string, string >; + /** Runs on the generated image when it fails to load, with the image as `this`. */ + onerror?: ( this: HTMLImageElement ) => void; + /** Returns true to leave a node, and everything under it, unparsed. */ + doNotParse?: ( node: Node ) => boolean; +} + +/** + * The options as Twemoji passes them on to `callback`, once it has filled in its own defaults for + * whatever the caller left out. + */ +interface TwemojiResolvedParseOptions extends TwemojiParseOptions { + base: string; + ext: string; + className: string; +} + +/** + * The vendored Twemoji library. + * + * Absent until js/_enqueues/vendor/twemoji.js has loaded, so callers must guard with a `typeof` + * check. Only the members which WordPress itself uses are declared. + */ +interface Twemoji { + /** Replaces the emoji in an element, in place. */ + parse( node: HTMLElement, options?: TwemojiParseOptions ): HTMLElement; + /** Replaces the emoji in a string and returns the result. */ + parse( text: string, options?: TwemojiParseOptions ): string; + /** Replaces the emoji in whichever of the two was given. */ + parse( nodeOrText: HTMLElement | string, options?: TwemojiParseOptions ): HTMLElement | string; +} + +declare var twemoji: Twemoji | undefined; From 2206a9def0d2ba168930b11d7fae654010d0a443 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 16 Sep 2026 22:15:06 -0700 Subject: [PATCH 03/32] Replace var with let and const in wp-emoji Every declaration in src/js/_enqueues/wp/emoji.js now uses const, or let where the binding is reassigned, and each variable gets its own declaration rather than sharing a comma separated list. The file already required an ES2020 toolchain, as .jshintrc sets esversion 11 and the emoji loader beside it has shipped const and arrow functions since it became a script module. Two loop bodies change shape. In the MutationObserver callback, addedNodes, removedNodes and ii were hoisted to the top of the callback and reassigned on each pass; they are now declared where they are first assigned, inside the loop. The same goes for node in the inner loop. Each was written before being read on every iteration, so the narrower scope is not observable. The params object in parse() was declared at the top of the function and assigned further down, past an early return. It is now a const at the point of assignment, which is also where its type annotation belongs. Co-Authored-By: Claude Opus 5 --- src/js/_enqueues/wp/emoji.js | 45 +++++++++++++++++++----------------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/src/js/_enqueues/wp/emoji.js b/src/js/_enqueues/wp/emoji.js index 228669f5fb8dd..16f24d9178081 100644 --- a/src/js/_enqueues/wp/emoji.js +++ b/src/js/_enqueues/wp/emoji.js @@ -32,19 +32,22 @@ * @return {Object} The wpEmoji parse and test functions. */ function wpEmoji() { - var MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver, + const MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver; // Compression and maintain local scope. - document = window.document, + const document = window.document; // Private. + const ie11 = window.navigator.userAgent.indexOf( 'Trident/7.0' ) > 0; + /** @type {Twemoji|undefined} */ - twemoji, + let twemoji; + /** @type {number|undefined} */ - timer, - loaded = false, - count = 0, - ie11 = window.navigator.userAgent.indexOf( 'Trident/7.0' ) > 0; + let timer; + + let loaded = false; + let count = 0; /** * Detect if the browser supports SVG. @@ -104,13 +107,13 @@ // replaceable emoji characters. if ( MutationObserver ) { new MutationObserver( function( mutationRecords ) { - var i = mutationRecords.length, - addedNodes, removedNodes, ii, node; + let i = mutationRecords.length; while ( i-- ) { - addedNodes = mutationRecords[ i ].addedNodes; - removedNodes = mutationRecords[ i ].removedNodes; - ii = addedNodes.length; + const addedNodes = mutationRecords[ i ].addedNodes; + const removedNodes = mutationRecords[ i ].removedNodes; + + let ii = addedNodes.length; /* * Checks if an image has been replaced by a text element @@ -134,7 +137,7 @@ // Loop through all the added nodes. while ( ii-- ) { - node = addedNodes[ ii ]; + let node = addedNodes[ ii ]; // Node type 3 is a TEXT_NODE. if ( node.nodeType === 3 ) { @@ -187,9 +190,10 @@ */ function test( text ) { // Single char. U+20E3 to detect keycaps. U+00A9 "copyright sign" and U+00AE "registered sign" not included. - var single = /[\u203C\u2049\u20E3\u2122\u2139\u2194-\u2199\u21A9\u21AA\u2300\u231A\u231B\u2328\u2388\u23CF\u23E9-\u23F3\u23F8-\u23FA\u24C2\u25AA\u25AB\u25B6\u25C0\u25FB-\u25FE\u2600-\u2604\u260E\u2611\u2614\u2615\u2618\u261D\u2620\u2622\u2623\u2626\u262A\u262E\u262F\u2638\u2639\u263A\u2648-\u2653\u2660\u2663\u2665\u2666\u2668\u267B\u267F\u2692\u2693\u2694\u2696\u2697\u2699\u269B\u269C\u26A0\u26A1\u26AA\u26AB\u26B0\u26B1\u26BD\u26BE\u26C4\u26C5\u26C8\u26CE\u26CF\u26D1\u26D3\u26D4\u26E9\u26EA\u26F0-\u26F5\u26F7-\u26FA\u26FD\u2702\u2705\u2708-\u270D\u270F\u2712\u2714\u2716\u271D\u2721\u2728\u2733\u2734\u2744\u2747\u274C\u274E\u2753\u2754\u2755\u2757\u2763\u2764\u2795\u2796\u2797\u27A1\u27B0\u27BF\u2934\u2935\u2B05\u2B06\u2B07\u2B1B\u2B1C\u2B50\u2B55\u3030\u303D\u3297\u3299]/, + const single = /[\u203C\u2049\u20E3\u2122\u2139\u2194-\u2199\u21A9\u21AA\u2300\u231A\u231B\u2328\u2388\u23CF\u23E9-\u23F3\u23F8-\u23FA\u24C2\u25AA\u25AB\u25B6\u25C0\u25FB-\u25FE\u2600-\u2604\u260E\u2611\u2614\u2615\u2618\u261D\u2620\u2622\u2623\u2626\u262A\u262E\u262F\u2638\u2639\u263A\u2648-\u2653\u2660\u2663\u2665\u2666\u2668\u267B\u267F\u2692\u2693\u2694\u2696\u2697\u2699\u269B\u269C\u26A0\u26A1\u26AA\u26AB\u26B0\u26B1\u26BD\u26BE\u26C4\u26C5\u26C8\u26CE\u26CF\u26D1\u26D3\u26D4\u26E9\u26EA\u26F0-\u26F5\u26F7-\u26FA\u26FD\u2702\u2705\u2708-\u270D\u270F\u2712\u2714\u2716\u271D\u2721\u2728\u2733\u2734\u2744\u2747\u274C\u274E\u2753\u2754\u2755\u2757\u2763\u2764\u2795\u2796\u2797\u27A1\u27B0\u27BF\u2934\u2935\u2B05\u2B06\u2B07\u2B1B\u2B1C\u2B50\u2B55\u3030\u303D\u3297\u3299]/; + // Surrogate pair range. Only tests for the second half. - pair = /[\uDC00-\uDFFF]/; + const pair = /[\uDC00-\uDFFF]/; if ( text ) { return pair.test( text ) || single.test( text ); @@ -216,9 +220,6 @@ * emoji. Or the element that was passed as the first argument. */ function parse( object, args ) { - /** @type {TwemojiParseOptions} */ - var params; - /* * If the browser has full support, twemoji is not loaded or our * object is not what was expected, we do not parse anything. @@ -231,7 +232,9 @@ // Compose the params for the twitter emoji library. args = args || {}; - params = { + + /** @type {TwemojiParseOptions} */ + const params = { base: browserSupportsSvgAsImage() ? settings.svgUrl : settings.baseUrl, ext: browserSupportsSvgAsImage() ? settings.svgExt : settings.ext, className: args.className || 'emoji', @@ -284,7 +287,7 @@ } }, doNotParse: function( node ) { - var className = node && /** @type {Element} */ ( node ).className; + const className = node && /** @type {Element} */ ( node ).className; if ( className && @@ -300,7 +303,7 @@ }; if ( typeof args.imgAttr === 'object' ) { - var imgAttr = args.imgAttr; + const imgAttr = args.imgAttr; params.attributes = function() { return imgAttr; From 2b6aa985f8a2ecf18e150230b15a5f457e276a76 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 16 Sep 2026 22:17:40 -0700 Subject: [PATCH 04/32] Split the wp-emoji file docblock to match the other scripts Every other script which wraps itself in an IIFE puts the @output tag in a docblock of its own at the top of the file, and then gives the IIFE a second docblock carrying the description and the @param tags, immediately above the function. See api-request.js, api.js and autosave.js. Typedefs sit between the two, as in code-editor.js. wp-emoji was the one file combining all of it into a single block, with @output trailing after the parameters it had nothing to do with. Adding the WPEmojiParseArgs typedef made that worse, since the typedef then separated the @param tags from the function they describe. Note that TypeScript does not read these tags. It types both parameters from the arguments at the bottom of the file instead, so the annotations here serve documentation and nothing else. Co-Authored-By: Claude Opus 5 --- src/js/_enqueues/wp/emoji.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/js/_enqueues/wp/emoji.js b/src/js/_enqueues/wp/emoji.js index 16f24d9178081..a2ecffd3c41c4 100644 --- a/src/js/_enqueues/wp/emoji.js +++ b/src/js/_enqueues/wp/emoji.js @@ -1,9 +1,4 @@ /** - * wp-emoji.js is used to replace emoji with images in browsers when the browser - * doesn't support emoji natively. - * - * @param {Window} window The global window object. - * @param {WPEmojiSettings} settings The settings object. * @output wp-includes/js/wp-emoji.js */ @@ -17,6 +12,13 @@ * place of the default ones. */ +/** + * wp-emoji.js is used to replace emoji with images in browsers when the browser + * doesn't support emoji natively. + * + * @param {Window} window The global window object. + * @param {WPEmojiSettings} settings The settings object. + */ ( function( window, settings ) { /** * Replaces emoji with images when browsers don't support emoji. From 4cafa8fcc5aef6e91d4b259b22d70e7acad213a8 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 16 Sep 2026 22:20:40 -0700 Subject: [PATCH 05/32] Remove the unreachable IE 11 workaround from wp-emoji Support for Internet Explorer 11 was dropped in 5.8, and since wp-emoji.js started using const and let the browser can no longer so much as parse the file: it fails on the first declaration in wpEmoji(), long before reaching the guarded block. The Trident/7.0 user agent sniff and the branch it protected were therefore dead in every browser which can run this script at all. The branch existed because IE 11 implemented MutationObserver by splitting a text node wherever it met a template interpolation symbol such as "{{". It joined the pieces back together before testing them for emoji. With a Trident/7.0 user agent forced, the old code merged three sibling text nodes into one and removed the other two from the document; both before and after this change the same parent node is handed to parse(), so nothing downstream sees a difference. Removing the loop also retires the two string casts its node value arithmetic needed, which were only there because nodeValue is nullable. Co-Authored-By: Claude Opus 5 --- src/js/_enqueues/wp/emoji.js | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/src/js/_enqueues/wp/emoji.js b/src/js/_enqueues/wp/emoji.js index a2ecffd3c41c4..182b9c63ca6c9 100644 --- a/src/js/_enqueues/wp/emoji.js +++ b/src/js/_enqueues/wp/emoji.js @@ -40,8 +40,6 @@ const document = window.document; // Private. - const ie11 = window.navigator.userAgent.indexOf( 'Trident/7.0' ) > 0; - /** @type {Twemoji|undefined} */ let twemoji; @@ -147,21 +145,6 @@ continue; } - if ( ie11 ) { - /* - * IE 11's implementation of MutationObserver is buggy. - * It unnecessarily splits text nodes when it encounters a HTML - * template interpolation symbol ( "{{", for example ). So, we - * join the text nodes back together as a work-around. - * - * Node type 3 is a TEXT_NODE. - */ - while( node.nextSibling && 3 === node.nextSibling.nodeType ) { - node.nodeValue = /** @type {string} */ ( node.nodeValue ) + /** @type {string} */ ( node.nextSibling.nodeValue ); - node.parentNode.removeChild( node.nextSibling ); - } - } - node = node.parentNode; } From 70d820e34ac71d15f8ff137cb0001f8b05bbcd1e Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 16 Sep 2026 22:23:39 -0700 Subject: [PATCH 06/32] Use MutationObserver in wp-emoji without the feature detection MutationObserver has been baseline widely available since July 2015, so wp-emoji no longer needs to look for the vendor-prefixed spellings of it, and no longer needs to guard its use behind a truthiness check. The observer is constructed directly and the block it used to sit inside is outdented. The constructor is now reached as a plain global rather than as a property of window. Going through window was only ever a requirement of the detection itself, since naming WebKitMutationObserver directly would throw where it does not exist. The two vendor-prefixed names were also the only reason the browser-globals typings declared anything besides WorkerGlobalScope, so those declarations go as well. Note that mce-view.js and comment-reply.js still carry the same fallback chain; neither is type checked yet, so neither is touched here. Co-Authored-By: Claude Opus 5 --- src/js/_enqueues/wp/emoji.js | 92 ++++++++++++++---------------- typings/browser-globals/index.d.ts | 6 -- 2 files changed, 44 insertions(+), 54 deletions(-) diff --git a/src/js/_enqueues/wp/emoji.js b/src/js/_enqueues/wp/emoji.js index 182b9c63ca6c9..920e2f034340c 100644 --- a/src/js/_enqueues/wp/emoji.js +++ b/src/js/_enqueues/wp/emoji.js @@ -34,8 +34,6 @@ * @return {Object} The wpEmoji parse and test functions. */ function wpEmoji() { - const MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver; - // Compression and maintain local scope. const document = window.document; @@ -105,59 +103,57 @@ // Initialize the mutation observer, which checks all added nodes for // replaceable emoji characters. - if ( MutationObserver ) { - new MutationObserver( function( mutationRecords ) { - let i = mutationRecords.length; - - while ( i-- ) { - const addedNodes = mutationRecords[ i ].addedNodes; - const removedNodes = mutationRecords[ i ].removedNodes; - - let ii = addedNodes.length; - - /* - * Checks if an image has been replaced by a text element - * with the same text as the alternate description of the replaced image. - * (presumably because the image could not be loaded). - * If it is, do absolutely nothing. - * - * Node type 3 is a TEXT_NODE. - * - * @link https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType - */ - if ( - ii === 1 && removedNodes.length === 1 && - addedNodes[0].nodeType === 3 && - removedNodes[0].nodeName === 'IMG' && - /** @type {Text} */ ( addedNodes[0] ).data === /** @type {HTMLImageElement} */ ( removedNodes[0] ).alt && - 'load-failed' === /** @type {HTMLImageElement} */ ( removedNodes[0] ).getAttribute( 'data-error' ) - ) { - return; - } + new MutationObserver( function( mutationRecords ) { + let i = mutationRecords.length; - // Loop through all the added nodes. - while ( ii-- ) { - let node = addedNodes[ ii ]; + while ( i-- ) { + const addedNodes = mutationRecords[ i ].addedNodes; + const removedNodes = mutationRecords[ i ].removedNodes; - // Node type 3 is a TEXT_NODE. - if ( node.nodeType === 3 ) { - if ( ! node.parentNode ) { - continue; - } + let ii = addedNodes.length; - node = node.parentNode; - } + /* + * Checks if an image has been replaced by a text element + * with the same text as the alternate description of the replaced image. + * (presumably because the image could not be loaded). + * If it is, do absolutely nothing. + * + * Node type 3 is a TEXT_NODE. + * + * @link https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType + */ + if ( + ii === 1 && removedNodes.length === 1 && + addedNodes[0].nodeType === 3 && + removedNodes[0].nodeName === 'IMG' && + /** @type {Text} */ ( addedNodes[0] ).data === /** @type {HTMLImageElement} */ ( removedNodes[0] ).alt && + 'load-failed' === /** @type {HTMLImageElement} */ ( removedNodes[0] ).getAttribute( 'data-error' ) + ) { + return; + } - if ( test( node.textContent ) ) { - parse( /** @type {HTMLElement} */ ( node ) ); + // Loop through all the added nodes. + while ( ii-- ) { + let node = addedNodes[ ii ]; + + // Node type 3 is a TEXT_NODE. + if ( node.nodeType === 3 ) { + if ( ! node.parentNode ) { + continue; } + + node = node.parentNode; + } + + if ( test( node.textContent ) ) { + parse( /** @type {HTMLElement} */ ( node ) ); } } - } ).observe( document.body, { - childList: true, - subtree: true - } ); - } + } + } ).observe( document.body, { + childList: true, + subtree: true + } ); parse( document.body ); } diff --git a/typings/browser-globals/index.d.ts b/typings/browser-globals/index.d.ts index 423154b770188..d79c4e8499f8b 100644 --- a/typings/browser-globals/index.d.ts +++ b/typings/browser-globals/index.d.ts @@ -12,9 +12,3 @@ * two conflict. Code which may run in either context therefore has to declare it. */ declare var WorkerGlobalScope: Function | undefined; - -/** - * Vendor-prefixed aliases of `MutationObserver`, from before the unprefixed name was standardized. - */ -declare var WebKitMutationObserver: typeof MutationObserver | undefined; -declare var MozMutationObserver: typeof MutationObserver | undefined; From ef39fbf3674ac74f487f513336fd2e42ab5b73bc Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 16 Sep 2026 22:26:11 -0700 Subject: [PATCH 07/32] Always use the SVG emoji images in wp-emoji browserSupportsSvgAsImage() could not return false. It asked document.implementation.hasFeature() whether SVG images were supported, but the DOM standard requires hasFeature() to return true for any argument at all, so the check has reported support regardless of the browser ever since that change was adopted. The one other path through the function, taken when hasFeature is missing entirely, already returned true on the same reasoning. The PNG fallback was therefore unreachable in both directions, and the params now name the SVG settings directly. This leaves baseUrl and ext unread by any script. They stay in the exported settings, both because something else may be reading the settings object and because the emoji_url and emoji_ext filters behind them still feed wp_staticize_emoji(), which builds img tags server side for feeds and email. A note to that effect is added where the two are declared, so that they are not mistaken for dead weight later. Worth noting separately: a site filtering emoji_url to serve its own PNGs has had no effect on the front end for as long as the detection has been broken. That is not a change in behavior, only a change in how visible it is. Co-Authored-By: Claude Opus 5 --- src/js/_enqueues/wp/emoji.js | 25 ++----------------------- typings/wp-emoji/index.d.ts | 10 ++++++++-- 2 files changed, 10 insertions(+), 25 deletions(-) diff --git a/src/js/_enqueues/wp/emoji.js b/src/js/_enqueues/wp/emoji.js index 920e2f034340c..7bf8d6ddb6ef1 100644 --- a/src/js/_enqueues/wp/emoji.js +++ b/src/js/_enqueues/wp/emoji.js @@ -47,27 +47,6 @@ let loaded = false; let count = 0; - /** - * Detect if the browser supports SVG. - * - * @since 4.6.0 - * @private - * - * @see Modernizr - * @link https://github.com/Modernizr/Modernizr/blob/master/feature-detects/svg/asimg.js - * - * @return {boolean} True if the browser supports svg, false if not. - */ - function browserSupportsSvgAsImage() { - if ( !! document.implementation.hasFeature ) { - return document.implementation.hasFeature( 'http://www.w3.org/TR/SVG11/feature#Image', '1.1' ); - } - - // document.implementation.hasFeature is deprecated. It can be presumed - // if future browsers remove it, the browser will support SVGs as images. - return true; - } - /** * Runs when the document load event is fired, so we can do our first parse of * the page. @@ -216,8 +195,8 @@ /** @type {TwemojiParseOptions} */ const params = { - base: browserSupportsSvgAsImage() ? settings.svgUrl : settings.baseUrl, - ext: browserSupportsSvgAsImage() ? settings.svgExt : settings.ext, + base: settings.svgUrl, + ext: settings.svgExt, className: args.className || 'emoji', callback: function( icon, options ) { // Ignore some standard characters that TinyMCE recommends in its character map. diff --git a/typings/wp-emoji/index.d.ts b/typings/wp-emoji/index.d.ts index 66d019381f2fe..d19d6131e3657 100644 --- a/typings/wp-emoji/index.d.ts +++ b/typings/wp-emoji/index.d.ts @@ -38,9 +38,15 @@ interface EmojiSupports { * Emoji settings as exported in PHP via `_print_emoji_detection_script()`. */ interface WPEmojiSettings { - /** Base URL for the PNG emoji images. */ + /** + * Base URL for the PNG emoji images. + * + * No longer read by any script, since wp-emoji always uses the SVG images. Still exported, both + * for anything reading the settings itself and because the emoji_url filter behind it also + * feeds wp_staticize_emoji(). + */ baseUrl: string; - /** File extension for the PNG emoji images. */ + /** File extension for the PNG emoji images. See the note on baseUrl. */ ext: string; /** Base URL for the SVG emoji images. */ svgUrl: string; From 6c395a7d108855910116c3f2a53b80f2fd65ce60 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 16 Sep 2026 22:30:46 -0700 Subject: [PATCH 08/32] Match the emoji exclusion class with classList in wp-emoji doNotParse() searched the class attribute for the substring wp-exclude-emoji rather than testing for the class itself, so a class which merely contained those characters excluded an element too. An element classed wp-exclude-emoji-wrapper, my-wp-exclude-emoji or wp-exclude-emojis was skipped along with everything beneath it. Using classList.contains() narrows this to the class the feature is documented around, which is also the form the only consumer in the tree uses: ExternalLink in @wordpress/components. Both guards around the test are dropped as well. Twemoji only calls doNotParse for element nodes, and skips anything within an SVG before it gets that far, so neither the null check nor the string check on className could be reached. The callback is typed to take an Element accordingly, which is what lets classList be read without a cast. One consequence worth recording: the callback no longer tolerates being handed something which is not an element. Nothing hands it one today, but a throw here would escape through grabAllTextNodes() and stop the whole page being parsed rather than merely failing to exclude one element. Co-Authored-By: Claude Opus 5 --- src/js/_enqueues/wp/emoji.js | 16 +++------------- typings/wp-emoji/index.d.ts | 9 +++++++-- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/src/js/_enqueues/wp/emoji.js b/src/js/_enqueues/wp/emoji.js index 7bf8d6ddb6ef1..24032e8d486ca 100644 --- a/src/js/_enqueues/wp/emoji.js +++ b/src/js/_enqueues/wp/emoji.js @@ -246,19 +246,9 @@ twemoji.parentNode.replaceChild( document.createTextNode( twemoji.alt ), twemoji ); } }, - doNotParse: function( node ) { - const className = node && /** @type {Element} */ ( node ).className; - - if ( - className && - typeof className === 'string' && - className.indexOf( 'wp-exclude-emoji' ) !== -1 - ) { - // Do not parse this node. Emojis will not be replaced in this node and all sub-nodes. - return true; - } - - return false; + doNotParse: function( element ) { + // Emoji will not be replaced in this element, nor in any of its descendants. + return element.classList.contains( 'wp-exclude-emoji' ); } }; diff --git a/typings/wp-emoji/index.d.ts b/typings/wp-emoji/index.d.ts index d19d6131e3657..b3bb9ce80843b 100644 --- a/typings/wp-emoji/index.d.ts +++ b/typings/wp-emoji/index.d.ts @@ -86,8 +86,13 @@ interface TwemojiParseOptions { attributes?: ( rawText: string, iconId: string ) => Record< string, string >; /** Runs on the generated image when it fails to load, with the image as `this`. */ onerror?: ( this: HTMLImageElement ) => void; - /** Returns true to leave a node, and everything under it, unparsed. */ - doNotParse?: ( node: Node ) => boolean; + /** + * Returns true to leave an element, and everything under it, unparsed. + * + * Twemoji only calls this for element nodes, and never for anything inside an SVG, so callers do + * not have to test for either. + */ + doNotParse?: ( element: Element ) => boolean; } /** From 5eb16e165245e61a92108a7d5418a276ee266397 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 16 Sep 2026 22:39:20 -0700 Subject: [PATCH 09/32] Describe what wpEmoji() returns in wp-emoji The return was documented as a bare Object, which said nothing about the two functions on it. It now names them, as typeof parse and typeof test, so the shape is checked against what is actually returned and the signatures cannot drift from the docblocks on parse() and test() themselves. Spelling the signatures out here instead would have duplicated documentation which already sits on each function. The returned object also uses shorthand property names. Co-Authored-By: Claude Opus 5 --- src/js/_enqueues/wp/emoji.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/js/_enqueues/wp/emoji.js b/src/js/_enqueues/wp/emoji.js index 24032e8d486ca..008ca44cc598c 100644 --- a/src/js/_enqueues/wp/emoji.js +++ b/src/js/_enqueues/wp/emoji.js @@ -31,7 +31,10 @@ * @see Twitter Emoji library * @link https://github.com/twitter/twemoji * - * @return {Object} The wpEmoji parse and test functions. + * @return {{ + * parse: typeof parse, + * test: typeof test, + * }} The wpEmoji parse and test functions. */ function wpEmoji() { // Compression and maintain local scope. @@ -265,10 +268,7 @@ load(); - return { - parse: parse, - test: test - }; + return { parse, test }; } window.wp = window.wp || {}; From 03e88be35b633c3d153db6f9c38abb91a60dd4f0 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 16 Sep 2026 22:58:37 -0700 Subject: [PATCH 10/32] Iterate mutation records with for...of in wp-emoji The observer callback counted backwards through two nested while loops, reading each collection by index. Both are now for...of loops, with the record destructured into the two node lists it is read for. The node checks become instanceof tests rather than comparisons against nodeType and nodeName. That is what the surrounding code meant in the first place, and because instanceof narrows where the numeric comparison did not, the three casts needed to reach data, alt and getAttribute are no longer required. IDEs which do not follow inline cast comments were reporting those three properties as unresolved. Reading a text node's containing element through parentElement rather than parentNode is likewise both narrower and closer to the intent, since only an element is ever passed on to be parsed. One behavioral fix comes with this. The guard for an image which has been replaced by its own alt text ended in a return, abandoning every remaining record in the callback rather than just the one it had recognized. That made the callback sensitive to the order the records were visited in: counting backwards it discarded the older records, and counting forwards it would instead discard the newer ones, so a legitimately added element could be left unparsed. It is now a continue, which skips only the record in question and leaves the outcome independent of the order entirely. The guard is unreachable in practice today, because the data-error attribute it looks for is only set by the onerror handler that never runs. Co-Authored-By: Claude Opus 5 --- src/js/_enqueues/wp/emoji.js | 41 ++++++++++++++---------------------- 1 file changed, 16 insertions(+), 25 deletions(-) diff --git a/src/js/_enqueues/wp/emoji.js b/src/js/_enqueues/wp/emoji.js index 008ca44cc598c..b66d46694eb59 100644 --- a/src/js/_enqueues/wp/emoji.js +++ b/src/js/_enqueues/wp/emoji.js @@ -86,45 +86,36 @@ // Initialize the mutation observer, which checks all added nodes for // replaceable emoji characters. new MutationObserver( function( mutationRecords ) { - let i = mutationRecords.length; - - while ( i-- ) { - const addedNodes = mutationRecords[ i ].addedNodes; - const removedNodes = mutationRecords[ i ].removedNodes; - - let ii = addedNodes.length; + for ( const { addedNodes, removedNodes } of mutationRecords ) { + const addedNode = addedNodes[ 0 ]; + const removedNode = removedNodes[ 0 ]; /* * Checks if an image has been replaced by a text element * with the same text as the alternate description of the replaced image. * (presumably because the image could not be loaded). - * If it is, do absolutely nothing. - * - * Node type 3 is a TEXT_NODE. - * - * @link https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType + * If it is, leave this record alone, so that the text is not turned + * straight back into the image which just failed to load. */ if ( - ii === 1 && removedNodes.length === 1 && - addedNodes[0].nodeType === 3 && - removedNodes[0].nodeName === 'IMG' && - /** @type {Text} */ ( addedNodes[0] ).data === /** @type {HTMLImageElement} */ ( removedNodes[0] ).alt && - 'load-failed' === /** @type {HTMLImageElement} */ ( removedNodes[0] ).getAttribute( 'data-error' ) + addedNodes.length === 1 && removedNodes.length === 1 && + addedNode instanceof Text && + removedNode instanceof HTMLImageElement && + addedNode.data === removedNode.alt && + 'load-failed' === removedNode.getAttribute( 'data-error' ) ) { - return; + continue; } // Loop through all the added nodes. - while ( ii-- ) { - let node = addedNodes[ ii ]; - - // Node type 3 is a TEXT_NODE. - if ( node.nodeType === 3 ) { - if ( ! node.parentNode ) { + for ( let node of addedNodes ) { + // Emoji in a text node are replaced by parsing the element which contains it. + if ( node instanceof Text ) { + if ( ! node.parentElement ) { continue; } - node = node.parentNode; + node = node.parentElement; } if ( test( node.textContent ) ) { From a9403305a43a0bf6e1a20d7179bfee4a8c51931f Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 16 Sep 2026 23:01:17 -0700 Subject: [PATCH 11/32] Narrow the parsed node with instanceof in wp-emoji Testing the node against HTMLElement before handing it to parse() removes the last cast in the observer callback, since the narrowing now establishes what the cast was asserting. Resolving the text node's container with a conditional rather than reassigning the loop variable also retires the continue which guarded against a text node having no containing element, as such a node now simply fails the same instanceof test. This stops non-HTML elements being parsed, which corrects a case Twemoji itself tries to avoid. It skips anything inside an SVG when collecting text to replace, but that test is applied to the descendants of whatever it is given, not to the node itself, so an arriving as an added node had its own text children replaced with elements, which is not valid inside SVG. The same went for MathML. Both are now left alone. Comment nodes are skipped marginally sooner than before. They reached parse() previously but were returned from immediately, having no child nodes, so nothing about the outcome changes. Co-Authored-By: Claude Opus 5 --- src/js/_enqueues/wp/emoji.js | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/js/_enqueues/wp/emoji.js b/src/js/_enqueues/wp/emoji.js index b66d46694eb59..b4f49f93f3392 100644 --- a/src/js/_enqueues/wp/emoji.js +++ b/src/js/_enqueues/wp/emoji.js @@ -108,18 +108,12 @@ } // Loop through all the added nodes. - for ( let node of addedNodes ) { + for ( const addedNode of addedNodes ) { // Emoji in a text node are replaced by parsing the element which contains it. - if ( node instanceof Text ) { - if ( ! node.parentElement ) { - continue; - } + const node = addedNode instanceof Text ? addedNode.parentElement : addedNode; - node = node.parentElement; - } - - if ( test( node.textContent ) ) { - parse( /** @type {HTMLElement} */ ( node ) ); + if ( node instanceof HTMLElement && test( node.textContent ) ) { + parse( node ); } } } From 0efb75b1ce4b4b6566c7a3d20d9e91bba6b2da20 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 17 Sep 2026 09:52:31 -0700 Subject: [PATCH 12/32] Restore the emoji image load failure fallback in wp-emoji The handler given to Twemoji for an image which fails to load referred to the Twemoji library object in three places where it meant the image element. The library object has no parentNode, so the condition never held and the handler returned having done nothing: the data-error attribute was never set, and a broken image was never replaced by the emoji character it stood for. A reader was left with a broken image icon and no text at all. Twemoji's own handler, which this one was adapted from, uses `this` throughout, which is what it is called with. The MutationObserver looks for that same data-error attribute in order to recognize a replacement it must leave alone, so that branch could never be reached either. Both halves work now, and they depend on each other: without the attribute the observer parses the replacement text straight back into the image which has just failed, and round it goes. Co-Authored-By: Claude Opus 5 --- src/js/_enqueues/wp/emoji.js | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/src/js/_enqueues/wp/emoji.js b/src/js/_enqueues/wp/emoji.js index b4f49f93f3392..7a41d341958aa 100644 --- a/src/js/_enqueues/wp/emoji.js +++ b/src/js/_enqueues/wp/emoji.js @@ -216,22 +216,13 @@ }, onerror: function() { /* - * TODO: This handler never does anything. It refers to the Twemoji library object - * in three places where it means the image element, which is what Twemoji's own - * onerror uses and what `this` is bound to here. The library object has no - * parentNode, so the condition below is never true: the data-error attribute is - * never set, and a broken image is never replaced by its alt text. The - * MutationObserver above tests for that same attribute, so it is dead too. - * - * Fixing this changes behavior, so it is being tracked separately. The - * @ts-expect-error directives below are what keep that decision from being made - * silently here; they will start failing once the references are corrected. + * Put the emoji character back in place of the image which failed to load. The + * attribute is what tells the MutationObserver above that this replacement is + * the one it must not turn straight back into an image. */ - // @ts-expect-error -- See the note above. - if ( twemoji.parentNode ) { + if ( this.parentNode ) { this.setAttribute( 'data-error', 'load-failed' ); - // @ts-expect-error -- See the note above. - twemoji.parentNode.replaceChild( document.createTextNode( twemoji.alt ), twemoji ); + this.parentNode.replaceChild( document.createTextNode( this.alt ), this ); } }, doNotParse: function( element ) { From 2fad9b25b2f0280db9c1f795c44bd22931559194 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 17 Sep 2026 10:00:58 -0700 Subject: [PATCH 13/32] Tweak comment per LanguageTool --- src/js/_enqueues/lib/emoji-loader.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/js/_enqueues/lib/emoji-loader.js b/src/js/_enqueues/lib/emoji-loader.js index d74282e894d6d..bc963a376ecf1 100644 --- a/src/js/_enqueues/lib/emoji-loader.js +++ b/src/js/_enqueues/lib/emoji-loader.js @@ -254,7 +254,7 @@ function browserSupportsEmoji( context, type, emojiSetsRenderIdentically, emojiR /* * Test for English flag compatibility. England is a country in the United Kingdom, it - * does not have a two letter locale code but rather a five letter sub-division code. + * does not have a two letter locale code but rather a five letter subdivision code. * * To test for support, we try to render it, and compare the rendering to how it would look if * the browser doesn't render it correctly (black flag emoji + [G] + [B] + [E] + [N] + [G]). From 36ae6651557f93f868ff379a0cc05f236d90df59 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 17 Sep 2026 10:23:16 -0700 Subject: [PATCH 14/32] Describe what the emoji loader documents but does not explain Three docblocks in the emoji loader said the name of a thing over again in place of describing it. The SessionSupportTests typedef had no description at all and neither of its two properties had one, so nothing recorded that the timestamp is in milliseconds since the epoch, which is the detail a reader of that code most needs. The supportTests parameter of setSessionSupportTests() and the tests parameter of testEmojiSupports() were each described by their own name reworded. These were found by running the rules which the JSDoc configuration does not enable yet, as suggested for that effort, in this case require-property- description and informative-docs. The configuration itself is left alone. Co-Authored-By: Claude Opus 5 --- src/js/_enqueues/lib/emoji-loader.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/js/_enqueues/lib/emoji-loader.js b/src/js/_enqueues/lib/emoji-loader.js index bc963a376ecf1..30b6cdcf381e4 100644 --- a/src/js/_enqueues/lib/emoji-loader.js +++ b/src/js/_enqueues/lib/emoji-loader.js @@ -50,10 +50,12 @@ function supportsWorkerOffloading() { } /** + * Support tests as they are stored in session storage. + * * @typedef SessionSupportTests * @type {Object} - * @property {number} timestamp - * @property {SupportTests} supportTests + * @property {number} timestamp When the tests were run, in milliseconds since the epoch. + * @property {SupportTests} supportTests What the tests found. */ /** @@ -93,7 +95,7 @@ function getSessionSupportTests() { * * @private * - * @param {SupportTests} supportTests Support tests. + * @param {SupportTests} supportTests What the tests found. */ function setSessionSupportTests( supportTests ) { try { @@ -300,7 +302,7 @@ function browserSupportsEmoji( context, type, emojiSetsRenderIdentically, emojiR * * @private * - * @param {Array} tests Tests. + * @param {Array} tests Which support tests to run. * @param {Function} browserSupportsEmoji Reference to browserSupportsEmoji function, needed due to minification. * @param {Function} emojiSetsRenderIdentically Reference to emojiSetsRenderIdentically function, needed due to minification. * @param {Function} emojiRendersEmptyCenterPoint Reference to emojiRendersEmptyCenterPoint function, needed due to minification. From e0760b40925487242f29c6b3a5a3581ba2459de3 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 17 Sep 2026 10:27:46 -0700 Subject: [PATCH 15/32] Reach the emoji error marker through dataset in wp-emoji The attribute recording that an emoji image failed to load was written with setAttribute() and read back with getAttribute(), naming data-error in full at both ends. Both now go through dataset, which is what it is for, and which names the attribute once each way without the prefix. This is the same attribute either way, so anything else setting or reading data-error directly continues to interoperate. Both directions were exercised: the marker written through dataset is visible to getAttribute(), and a marker written by setAttribute() is still recognized by the observer. Co-Authored-By: Claude Opus 5 --- src/js/_enqueues/wp/emoji.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/js/_enqueues/wp/emoji.js b/src/js/_enqueues/wp/emoji.js index 7a41d341958aa..8c44898df14c9 100644 --- a/src/js/_enqueues/wp/emoji.js +++ b/src/js/_enqueues/wp/emoji.js @@ -102,7 +102,7 @@ addedNode instanceof Text && removedNode instanceof HTMLImageElement && addedNode.data === removedNode.alt && - 'load-failed' === removedNode.getAttribute( 'data-error' ) + 'load-failed' === removedNode.dataset.error ) { continue; } @@ -221,7 +221,7 @@ * the one it must not turn straight back into an image. */ if ( this.parentNode ) { - this.setAttribute( 'data-error', 'load-failed' ); + this.dataset.error = 'load-failed'; this.parentNode.replaceChild( document.createTextNode( this.alt ), this ); } }, From 6df94a46c7e0babcf6b4615bbb16e01ebabccc91 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 17 Sep 2026 10:31:22 -0700 Subject: [PATCH 16/32] Correct the spacing in the emoji scripts The JavaScript coding standard calls for a space after function when the function is anonymous, as in the ( function ( $ ) { form it gives for the jQuery wrapper, while a named function keeps its name against the parenthesis. Seven closures in wp-emoji were missing that space. This is the same spacing the PHP standard asks of a closure, and the standard is explicit elsewhere that such agreement is deliberate, noting that a space is preferred after the negation operator in order to conform to the PHP standards. The rest is the standard's spacing rules applied where they had been missed: an extra space had crept in after a return, a property value was padded to line up with the one above it although the standard aligns nothing in an object declaration, a call passed its arguments without the spaces inside the parentheses which are always required, and a closing parenthesis was up against a template literal. None of this is checked by anything today. The ESLint configuration covers the inline documentation alone, and says so. Co-Authored-By: Claude Opus 5 --- src/js/_enqueues/lib/emoji-loader.js | 4 ++-- src/js/_enqueues/wp/emoji.js | 18 +++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/js/_enqueues/lib/emoji-loader.js b/src/js/_enqueues/lib/emoji-loader.js index 30b6cdcf381e4..17c8cb29c760e 100644 --- a/src/js/_enqueues/lib/emoji-loader.js +++ b/src/js/_enqueues/lib/emoji-loader.js @@ -9,7 +9,7 @@ const selector = 'script#wp-emoji-settings'; const script = document.querySelector( selector ); if ( ! ( script instanceof HTMLScriptElement ) ) { - throw new Error( `Element missing: ${ selector }`); + throw new Error( `Element missing: ${ selector }` ); } const settings = /** @type {WPEmojiSettings} */ ( JSON.parse( script.text ) ); @@ -188,7 +188,7 @@ function emojiRendersEmptyCenterPoint( context, emoji ) { context.fillText( emoji, 0, 0 ); // Test if the center point (16, 16) is empty (0,0,0,0). - const centerPoint = context.getImageData(16, 16, 1, 1); + const centerPoint = context.getImageData( 16, 16, 1, 1 ); for ( let i = 0; i < centerPoint.data.length; i++ ) { if ( centerPoint.data[ i ] !== 0 ) { // Stop checking the moment it's known not to be empty. diff --git a/src/js/_enqueues/wp/emoji.js b/src/js/_enqueues/wp/emoji.js index 8c44898df14c9..661269c67c103 100644 --- a/src/js/_enqueues/wp/emoji.js +++ b/src/js/_enqueues/wp/emoji.js @@ -19,7 +19,7 @@ * @param {Window} window The global window object. * @param {WPEmojiSettings} settings The settings object. */ -( function( window, settings ) { +( function ( window, settings ) { /** * Replaces emoji with images when browsers don't support emoji. * @@ -85,7 +85,7 @@ // Initialize the mutation observer, which checks all added nodes for // replaceable emoji characters. - new MutationObserver( function( mutationRecords ) { + new MutationObserver( function ( mutationRecords ) { for ( const { addedNodes, removedNodes } of mutationRecords ) { const addedNode = addedNodes[ 0 ]; const removedNode = removedNodes[ 0 ]; @@ -144,7 +144,7 @@ const pair = /[\uDC00-\uDFFF]/; if ( text ) { - return pair.test( text ) || single.test( text ); + return pair.test( text ) || single.test( text ); } return false; @@ -184,9 +184,9 @@ /** @type {TwemojiParseOptions} */ const params = { base: settings.svgUrl, - ext: settings.svgExt, + ext: settings.svgExt, className: args.className || 'emoji', - callback: function( icon, options ) { + callback: function ( icon, options ) { // Ignore some standard characters that TinyMCE recommends in its character map. switch ( icon ) { case 'a9': @@ -209,12 +209,12 @@ return ''.concat( options.base, icon, options.ext ); }, - attributes: function() { + attributes: function () { return { role: 'img' }; }, - onerror: function() { + onerror: function () { /* * Put the emoji character back in place of the image which failed to load. The * attribute is what tells the MutationObserver above that this replacement is @@ -225,7 +225,7 @@ this.parentNode.replaceChild( document.createTextNode( this.alt ), this ); } }, - doNotParse: function( element ) { + doNotParse: function ( element ) { // Emoji will not be replaced in this element, nor in any of its descendants. return element.classList.contains( 'wp-exclude-emoji' ); } @@ -234,7 +234,7 @@ if ( typeof args.imgAttr === 'object' ) { const imgAttr = args.imgAttr; - params.attributes = function() { + params.attributes = function () { return imgAttr; }; } From a2a34ca9abf282fbbafe362263ad8c11b280ce7f Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 17 Sep 2026 10:38:37 -0700 Subject: [PATCH 17/32] Use arrow functions for the wp-emoji callbacks Five of the callbacks passed to Twemoji, and the one given to the `MutationObserver`, are arrow functions now. None of them referred to this or to arguments, so none of them needed a binding of their own, and the emoji loader alongside this file already writes every one of its callbacks this way. The two which return a single expression say so: the attributes callback returns its object directly, and the override installed for `imgAttr` returns the captured value. The handler for a failed image load stays a function expression. It is called with the image as `this`, which an arrow would take from the enclosing scope instead. That is not left to be noticed by eye: the callback is declared to take an `HTMLImageElement` as `this`, so converting it stops the build. The function which wraps the whole file is also left as it is, that being the form the coding standard gives for such a wrapper. Co-Authored-By: Claude Opus 5 --- src/js/_enqueues/wp/emoji.js | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/js/_enqueues/wp/emoji.js b/src/js/_enqueues/wp/emoji.js index 661269c67c103..e8a41d2828bda 100644 --- a/src/js/_enqueues/wp/emoji.js +++ b/src/js/_enqueues/wp/emoji.js @@ -85,7 +85,7 @@ // Initialize the mutation observer, which checks all added nodes for // replaceable emoji characters. - new MutationObserver( function ( mutationRecords ) { + new MutationObserver( ( mutationRecords ) => { for ( const { addedNodes, removedNodes } of mutationRecords ) { const addedNode = addedNodes[ 0 ]; const removedNode = removedNodes[ 0 ]; @@ -186,7 +186,7 @@ base: settings.svgUrl, ext: settings.svgExt, className: args.className || 'emoji', - callback: function ( icon, options ) { + callback: ( icon, options ) => { // Ignore some standard characters that TinyMCE recommends in its character map. switch ( icon ) { case 'a9': @@ -209,11 +209,7 @@ return ''.concat( options.base, icon, options.ext ); }, - attributes: function () { - return { - role: 'img' - }; - }, + attributes: () => ( { role: 'img' } ), onerror: function () { /* * Put the emoji character back in place of the image which failed to load. The @@ -225,7 +221,7 @@ this.parentNode.replaceChild( document.createTextNode( this.alt ), this ); } }, - doNotParse: function ( element ) { + doNotParse: ( element ) => { // Emoji will not be replaced in this element, nor in any of its descendants. return element.classList.contains( 'wp-exclude-emoji' ); } @@ -234,9 +230,7 @@ if ( typeof args.imgAttr === 'object' ) { const imgAttr = args.imgAttr; - params.attributes = function () { - return imgAttr; - }; + params.attributes = () => imgAttr; } return twemoji.parse( object, params ); From 68f7fb2c3bbeba76f3bcc12bf03d2114d65e7224 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 17 Sep 2026 10:46:31 -0700 Subject: [PATCH 18/32] Decide the emoji image attributes once in wp-emoji The attributes callback was declared returning the default, and then the whole property was replaced afterwards when the caller had supplied its own. Which attributes apply is now settled in one place, before the params are composed, and the callback returns whatever that came to. This removes the local copy the replacement needed. It could not read args.imgAttr directly, because args is a parameter which is assigned a fallback just above, so the check that the property is an object is not something TypeScript will still hold to inside a callback which runs later. Deciding it once sidesteps that: the result is a constant, and nothing reads the property again. The default is now one object for the whole parse rather than a new one for each image. Twemoji only ever reads what the callback returns, copying it onto the image attribute by attribute, and the caller supplied path has always handed back the same object every time regardless. Co-Authored-By: Claude Opus 5 --- src/js/_enqueues/wp/emoji.js | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/js/_enqueues/wp/emoji.js b/src/js/_enqueues/wp/emoji.js index e8a41d2828bda..7309016c10a67 100644 --- a/src/js/_enqueues/wp/emoji.js +++ b/src/js/_enqueues/wp/emoji.js @@ -181,6 +181,9 @@ // Compose the params for the twitter emoji library. args = args || {}; + // The caller may replace the attributes given to every generated image. + const attributes = typeof args.imgAttr === 'object' ? args.imgAttr : { role: 'img' }; + /** @type {TwemojiParseOptions} */ const params = { base: settings.svgUrl, @@ -209,7 +212,7 @@ return ''.concat( options.base, icon, options.ext ); }, - attributes: () => ( { role: 'img' } ), + attributes: () => attributes, onerror: function () { /* * Put the emoji character back in place of the image which failed to load. The @@ -227,12 +230,6 @@ } }; - if ( typeof args.imgAttr === 'object' ) { - const imgAttr = args.imgAttr; - - params.attributes = () => imgAttr; - } - return twemoji.parse( object, params ); } From 5aa8f9affc25fd30dddcfaa536027df1153bf096 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 17 Sep 2026 11:09:31 -0700 Subject: [PATCH 19/32] Add QUnit tests for wp-emoji Neither of the emoji scripts had a test of any kind. The PHPUnit tests for emoji touch wp-includes/js/wp-emoji-loader.js into existence so that the PHP which prints the script tag can find it, but nothing has ever run a line of either file. These cover wp.emoji.test() and everything wp.emoji.parse() decides: that it declines to do anything when the browser needs no help, when there is nothing to parse, or when the element has no children; which URL and extension the images are given; the class name and the attributes, each with and without the caller replacing them; which characters the callback declines to replace, and which it still replaces when only flags are unsupported; which class names exclude an element from parsing and which merely resemble the one that does; and that a failed image is replaced by the character it stood for and marked so that the observer leaves it be. The test page gains the settings and a stand-in for Twemoji, both before wp-emoji itself, since it reads the one and waits for the other. The stand-in records what it is handed, which is how the tests reach the callbacks that wp-emoji composes but does not otherwise expose. The elements are built detached from the document: wp-emoji watches the body for additions, so attaching them would start it parsing partway through a test. Co-Authored-By: Claude Opus 5 --- tests/qunit/index.html | 32 +++ tests/qunit/wp-includes/js/wp-emoji.js | 269 +++++++++++++++++++++++++ 2 files changed, 301 insertions(+) create mode 100644 tests/qunit/wp-includes/js/wp-emoji.js diff --git a/tests/qunit/index.html b/tests/qunit/index.html index 82524daa01227..37f4a90952aac 100644 --- a/tests/qunit/index.html +++ b/tests/qunit/index.html @@ -97,6 +97,37 @@ + + @@ -157,6 +188,7 @@ + diff --git a/tests/qunit/wp-includes/js/wp-emoji.js b/tests/qunit/wp-includes/js/wp-emoji.js new file mode 100644 index 0000000000000..4dab3b8f29181 --- /dev/null +++ b/tests/qunit/wp-includes/js/wp-emoji.js @@ -0,0 +1,269 @@ +/* global wp, twemoji */ + +/* + * The elements built here are deliberately left out of the document. wp-emoji observes the body for + * added nodes, so attaching them would set that observer going in the middle of a test and call the + * Twemoji stand-in again behind the assertions. + */ + +const EMOJI = '😀'; // Grinning face. + +/** + * Builds a detached element containing the given text. + * + * @param {string} text Text to place inside the element. + * @param {string} className Class attribute for the element. + * + * @return {HTMLElement} The element. + */ +function emojiFixtureElement( text, className ) { + const element = document.createElement( 'div' ); + + if ( className ) { + element.setAttribute( 'class', className ); + } + + element.appendChild( document.createTextNode( text ) ); + + return element; +} + +/** + * Parses a fixture element and returns the params wp-emoji handed to Twemoji. + * + * @param {Object} [args] Additional options for wp.emoji.parse(). + * + * @return {Object} The params Twemoji was called with. + */ +function paramsFromParse( args ) { + wp.emoji.parse( emojiFixtureElement( EMOJI ), args ); + + return twemoji.lastParams; +} + +QUnit.module( 'wp.emoji.test' ); + +QUnit.test( 'recognizes a surrogate pair emoji', function ( assert ) { + assert.strictEqual( wp.emoji.test( EMOJI ), true, 'A grinning face is an emoji.' ); +} ); + +QUnit.test( 'recognizes an emoji within surrounding text', function ( assert ) { + assert.strictEqual( wp.emoji.test( 'before ' + EMOJI + ' after' ), true, 'An emoji is found among other text.' ); +} ); + +QUnit.test( 'recognizes a single code point emoji', function ( assert ) { + assert.strictEqual( wp.emoji.test( '❤' ), true, 'A heavy black heart is an emoji.' ); +} ); + +QUnit.test( 'does not recognize plain text', function ( assert ) { + assert.strictEqual( wp.emoji.test( 'Hello world' ), false, 'Plain text contains no emoji.' ); +} ); + +QUnit.test( 'does not recognize a copyright sign', function ( assert ) { + assert.strictEqual( wp.emoji.test( '©' ), false, 'The copyright sign is excluded from the test.' ); +} ); + +QUnit.test( 'returns false for values which are not text', function ( assert ) { + assert.strictEqual( wp.emoji.test( '' ), false, 'An empty string contains no emoji.' ); + assert.strictEqual( wp.emoji.test( null ), false, 'Null contains no emoji.' ); + assert.strictEqual( wp.emoji.test( undefined ), false, 'Undefined contains no emoji.' ); +} ); + +QUnit.module( 'wp.emoji.parse', { + beforeEach: function () { + twemoji.calls = 0; + twemoji.lastObject = null; + twemoji.lastParams = null; + window._wpemojiSettings.supports = { + everything: false, + everythingExceptFlag: false, + flag: false, + emoji: false + }; + } +} ); + +QUnit.test( 'does nothing when the browser supports every emoji', function ( assert ) { + window._wpemojiSettings.supports.everything = true; + + const element = emojiFixtureElement( EMOJI ); + + assert.strictEqual( wp.emoji.parse( element ), element, 'The element is returned as it was.' ); + assert.strictEqual( twemoji.calls, 0, 'Twemoji is not called.' ); +} ); + +QUnit.test( 'does nothing when given an element with no child nodes', function ( assert ) { + const element = document.createElement( 'div' ); + + assert.strictEqual( wp.emoji.parse( element ), element, 'The element is returned as it was.' ); + assert.strictEqual( twemoji.calls, 0, 'Twemoji is not called.' ); +} ); + +QUnit.test( 'does nothing when given nothing to parse', function ( assert ) { + assert.strictEqual( wp.emoji.parse( null ), null, 'Null is returned as it was.' ); + assert.strictEqual( twemoji.calls, 0, 'Twemoji is not called.' ); +} ); + +QUnit.test( 'passes a string on to Twemoji', function ( assert ) { + wp.emoji.parse( EMOJI ); + + assert.strictEqual( twemoji.calls, 1, 'Twemoji is called once.' ); + assert.strictEqual( twemoji.lastObject, EMOJI, 'Twemoji is given the string.' ); +} ); + +QUnit.test( 'uses the SVG images', function ( assert ) { + const params = paramsFromParse(); + + assert.strictEqual( params.base, window._wpemojiSettings.svgUrl, 'The SVG URL is used as the base.' ); + assert.strictEqual( params.ext, window._wpemojiSettings.svgExt, 'The SVG extension is used.' ); +} ); + +QUnit.test( 'gives each image the emoji class by default', function ( assert ) { + assert.strictEqual( paramsFromParse().className, 'emoji', 'The class name defaults to emoji.' ); +} ); + +QUnit.test( 'allows the class name to be replaced', function ( assert ) { + assert.strictEqual( paramsFromParse( { className: 'custom' } ).className, 'custom', 'The given class name is used.' ); +} ); + +QUnit.test( 'marks each image as an image for assistive technology', function ( assert ) { + assert.deepEqual( paramsFromParse().attributes(), { role: 'img' }, 'The role attribute is set.' ); +} ); + +QUnit.test( 'allows the attributes to be replaced', function ( assert ) { + const imgAttr = { 'aria-hidden': 'true' }; + + assert.deepEqual( paramsFromParse( { imgAttr: imgAttr } ).attributes(), imgAttr, 'The given attributes are used.' ); +} ); + +QUnit.test( 'ignores attributes which are not an object', function ( assert ) { + assert.deepEqual( paramsFromParse( { imgAttr: 'not an object' } ).attributes(), { role: 'img' }, 'The default attributes are used.' ); +} ); + +QUnit.module( 'wp.emoji.parse callback', { + beforeEach: function () { + twemoji.calls = 0; + twemoji.lastParams = null; + window._wpemojiSettings.supports = { + everything: false, + everythingExceptFlag: false, + flag: false, + emoji: false + }; + } +} ); + +QUnit.test( 'builds the image source from the base and extension', function ( assert ) { + const params = paramsFromParse(); + + assert.strictEqual( + params.callback( '1f600', params ), + window._wpemojiSettings.svgUrl + '1f600' + window._wpemojiSettings.svgExt, + 'The source is the base, the icon and the extension.' + ); +} ); + +QUnit.test( 'leaves the characters TinyMCE offers in its character map alone', function ( assert ) { + const params = paramsFromParse(); + + [ 'a9', 'ae', '2122', '2194', '2660', '2663', '2665', '2666' ].forEach( function ( icon ) { + assert.strictEqual( params.callback( icon, params ), false, icon + ' is left as it is.' ); + } ); +} ); + +QUnit.test( 'replaces only flags when everything but flags is supported', function ( assert ) { + window._wpemojiSettings.supports.everythingExceptFlag = true; + + const params = paramsFromParse(); + + assert.strictEqual( params.callback( '1f600', params ), false, 'A grinning face is left as it is.' ); + assert.strictEqual( + params.callback( '1f1e8-1f1f6', params ), + window._wpemojiSettings.svgUrl + '1f1e8-1f1f6' + window._wpemojiSettings.svgExt, + 'A country flag is replaced.' + ); + assert.strictEqual( + params.callback( '1f3f3-fe0f-200d-1f308', params ), + window._wpemojiSettings.svgUrl + '1f3f3-fe0f-200d-1f308' + window._wpemojiSettings.svgExt, + 'The rainbow flag is replaced.' + ); +} ); + +QUnit.module( 'wp.emoji.parse doNotParse', { + beforeEach: function () { + window._wpemojiSettings.supports = { + everything: false, + everythingExceptFlag: false, + flag: false, + emoji: false + }; + } +} ); + +QUnit.test( 'excludes an element carrying the exclusion class', function ( assert ) { + const doNotParse = paramsFromParse().doNotParse; + + assert.strictEqual( doNotParse( emojiFixtureElement( '', 'wp-exclude-emoji' ) ), true, 'The class on its own excludes.' ); + assert.strictEqual( doNotParse( emojiFixtureElement( '', 'one wp-exclude-emoji two' ) ), true, 'The class among others excludes.' ); +} ); + +QUnit.test( 'does not treat a longer class name as the exclusion class', function ( assert ) { + const doNotParse = paramsFromParse().doNotParse; + + assert.strictEqual( doNotParse( emojiFixtureElement( '', 'wp-exclude-emoji-wrapper' ) ), false, 'A class which begins with it does not exclude.' ); + assert.strictEqual( doNotParse( emojiFixtureElement( '', 'my-wp-exclude-emoji' ) ), false, 'A class which ends with it does not exclude.' ); + assert.strictEqual( doNotParse( emojiFixtureElement( '', 'wp-exclude-emojis' ) ), false, 'A plural of it does not exclude.' ); +} ); + +QUnit.test( 'does not exclude an unrelated element', function ( assert ) { + const doNotParse = paramsFromParse().doNotParse; + + assert.strictEqual( doNotParse( emojiFixtureElement( '', 'unrelated' ) ), false, 'Another class does not exclude.' ); + assert.strictEqual( doNotParse( emojiFixtureElement( '' ) ), false, 'No class at all does not exclude.' ); +} ); + +QUnit.module( 'wp.emoji.parse onerror', { + beforeEach: function () { + window._wpemojiSettings.supports = { + everything: false, + everythingExceptFlag: false, + flag: false, + emoji: false + }; + } +} ); + +QUnit.test( 'puts the emoji character back when the image cannot be loaded', function ( assert ) { + const parent = document.createElement( 'p' ); + const image = document.createElement( 'img' ); + + image.alt = EMOJI; + parent.appendChild( image ); + + paramsFromParse().onerror.call( image ); + + assert.strictEqual( parent.textContent, EMOJI, 'The parent holds the emoji character.' ); + assert.strictEqual( parent.contains( image ), false, 'The image is gone.' ); +} ); + +QUnit.test( 'marks the image it removed, so that it is not put back', function ( assert ) { + const parent = document.createElement( 'p' ); + const image = document.createElement( 'img' ); + + image.alt = EMOJI; + parent.appendChild( image ); + + paramsFromParse().onerror.call( image ); + + assert.strictEqual( image.dataset.error, 'load-failed', 'The image carries the marker the observer looks for.' ); +} ); + +QUnit.test( 'does nothing to an image which is not in the document', function ( assert ) { + const image = document.createElement( 'img' ); + + image.alt = EMOJI; + + paramsFromParse().onerror.call( image ); + + assert.strictEqual( image.dataset.error, undefined, 'The image is left unmarked.' ); +} ); From c2e043598957fe20822e88bed61e2441af912a2e Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 17 Sep 2026 12:17:49 -0700 Subject: [PATCH 20/32] Cover the wp-emoji mutation observer with QUnit tests The observer had no tests. It is what notices emoji added to the page after the first parse, and it holds the other half of the recovery from an image which fails to load: the handler marks the image it is replacing, and the observer leaves that replacement alone rather than turning it straight back into the image which has just failed. Only the marking half was covered. Four tests are added. Two are for the ordinary path, an element and a text node added to the page. The other two are the guard and its control: the same replacement is made twice, once with the marker the handler sets and once without, and only the unmarked one is parsed. That the two differ is what shows the marker to be doing the work. These attach their elements, unlike the rest of the file, since the observer is watching for exactly that. Waiting for it needs a little care. Mutation records are delivered in a microtask, so the tests settle on one to put themselves behind the observer. A timer cannot be used: every test in this suite is wrapped by sinon-test, which swaps the timers for a fake clock, and a callback left on that clock is never called at all. Co-Authored-By: Claude Opus 5 --- tests/qunit/wp-includes/js/wp-emoji.js | 118 +++++++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/tests/qunit/wp-includes/js/wp-emoji.js b/tests/qunit/wp-includes/js/wp-emoji.js index 4dab3b8f29181..a6acd1ce9c21c 100644 --- a/tests/qunit/wp-includes/js/wp-emoji.js +++ b/tests/qunit/wp-includes/js/wp-emoji.js @@ -267,3 +267,121 @@ QUnit.test( 'does nothing to an image which is not in the document', function ( assert.strictEqual( image.dataset.error, undefined, 'The image is left unmarked.' ); } ); + +/* + * Unlike everything above, these do attach their elements, because what is under test is the + * observer wp-emoji sets on the body. + */ +QUnit.module( 'wp-emoji mutation observer', { + beforeEach: function () { + window._wpemojiSettings.supports = { + everything: false, + everythingExceptFlag: false, + flag: false, + emoji: false + }; + } +} ); + +/** + * Waits for pending mutation records to be delivered to the observer. + * + * Records reach an observer in a microtask, so settling on one puts this behind the observer's own + * callback. A timer cannot be used to wait: every test here is wrapped by sinon-test, which swaps + * the timers for a fake clock that nothing in this file advances. + * + * @return {Promise} A promise which settles once the observer has run. + */ +function afterMutations() { + return Promise.resolve(); +} + +/** + * Adds an element to the fixture, and returns once the observer has seen it. + * + * @param {HTMLElement} element Element to add. + * + * @return {Promise} A promise which settles once the observer has run. + */ +function attachToFixture( element ) { + document.getElementById( 'qunit-fixture' ).appendChild( element ); + + return afterMutations(); +} + +/** + * Builds a paragraph holding an emoji image, as Twemoji would have left it. + * + * @param {?string} error Value for the data-error attribute, or null to leave it off. + * + * @return {Object} The paragraph and the image within it. + */ +function emojiImageParagraph( error ) { + const paragraph = document.createElement( 'p' ); + const image = document.createElement( 'img' ); + + image.alt = EMOJI; + + if ( error ) { + image.dataset.error = error; + } + + paragraph.appendChild( image ); + + return { paragraph: paragraph, image: image }; +} + +QUnit.test( 'parses an element added to the document', async function ( assert ) { + const element = emojiFixtureElement( EMOJI ); + + twemoji.calls = 0; + twemoji.lastObject = null; + + await attachToFixture( element ); + + assert.strictEqual( twemoji.calls, 1, 'Twemoji is called once.' ); + assert.strictEqual( twemoji.lastObject, element, 'Twemoji is given the added element.' ); +} ); + +QUnit.test( 'parses the containing element of an added text node', async function ( assert ) { + const paragraph = document.createElement( 'p' ); + + await attachToFixture( paragraph ); + + twemoji.calls = 0; + twemoji.lastObject = null; + + paragraph.appendChild( document.createTextNode( EMOJI ) ); + await afterMutations(); + + assert.strictEqual( twemoji.calls, 1, 'Twemoji is called once.' ); + assert.strictEqual( twemoji.lastObject, paragraph, 'Twemoji is given the containing element.' ); +} ); + +QUnit.test( 'leaves alone an image replaced by its own alternative text', async function ( assert ) { + const nodes = emojiImageParagraph( 'load-failed' ); + + await attachToFixture( nodes.paragraph ); + + twemoji.calls = 0; + + nodes.paragraph.replaceChild( document.createTextNode( EMOJI ), nodes.image ); + await afterMutations(); + + assert.strictEqual( twemoji.calls, 0, 'The text which replaced the image is not parsed back into one.' ); +} ); + +QUnit.test( 'parses the same replacement when the image was not marked', async function ( assert ) { + const nodes = emojiImageParagraph( null ); + + await attachToFixture( nodes.paragraph ); + + twemoji.calls = 0; + twemoji.lastObject = null; + + nodes.paragraph.replaceChild( document.createTextNode( EMOJI ), nodes.image ); + await afterMutations(); + + assert.strictEqual( twemoji.calls, 1, 'Twemoji is called once.' ); + assert.strictEqual( twemoji.lastObject, nodes.paragraph, 'It is the marker, and nothing else, which stops the replacement being parsed.' ); +} ); From 560f5ff3655b5e51fd66b0564d47fecd5dea9fa3 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 17 Sep 2026 12:22:09 -0700 Subject: [PATCH 21/32] Address static analysis issues in QUnit test --- tests/qunit/wp-includes/js/wp-emoji.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/qunit/wp-includes/js/wp-emoji.js b/tests/qunit/wp-includes/js/wp-emoji.js index a6acd1ce9c21c..d6166e3d2ef79 100644 --- a/tests/qunit/wp-includes/js/wp-emoji.js +++ b/tests/qunit/wp-includes/js/wp-emoji.js @@ -11,8 +11,8 @@ const EMOJI = '😀'; // Grinning face. /** * Builds a detached element containing the given text. * - * @param {string} text Text to place inside the element. - * @param {string} className Class attribute for the element. + * @param {string} text Text to place inside the element. + * @param {string} [className] Class attribute for the element. * * @return {HTMLElement} The element. */ @@ -312,9 +312,9 @@ function attachToFixture( element ) { /** * Builds a paragraph holding an emoji image, as Twemoji would have left it. * - * @param {?string} error Value for the data-error attribute, or null to leave it off. + * @param {string} [error] Value for the data-error attribute. * - * @return {Object} The paragraph and the image within it. + * @return {{ paragraph: HTMLParagraphElement, image: HTMLImageElement }} The paragraph and the image within it. */ function emojiImageParagraph( error ) { const paragraph = document.createElement( 'p' ); @@ -328,7 +328,7 @@ function emojiImageParagraph( error ) { paragraph.appendChild( image ); - return { paragraph: paragraph, image: image }; + return { paragraph, image }; } QUnit.test( 'parses an element added to the document', async function ( assert ) { @@ -372,7 +372,7 @@ QUnit.test( 'leaves alone an image replaced by its own alternative text', async } ); QUnit.test( 'parses the same replacement when the image was not marked', async function ( assert ) { - const nodes = emojiImageParagraph( null ); + const nodes = emojiImageParagraph(); await attachToFixture( nodes.paragraph ); From 574526a67fbe1cdea4c98f255bf2392f21190695 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 17 Sep 2026 12:32:08 -0700 Subject: [PATCH 22/32] Spell out what wpEmoji() returns rather than referring to itself The return was described as typeof parse and typeof test, which TypeScript resolves but which editors do not, leaving them unable to say what wp.emoji is at all. Every call made through it was then reported as a call to something which might not be a function, thirteen of them in the QUnit tests alone. The two signatures are written out instead. TypeScript checks the returned object against either form, so nothing is given up by preferring the one both understand. While here, wpEmoji() is called as the function it is. It builds no instance and never refers to this, returning an object instead, so calling it with new did nothing but discard an empty instance, and the @class tag above it described something the code has never been. Note that this was not what the editors were complaining about, and correcting it alone does not quiet them. Co-Authored-By: Claude Opus 5 --- src/js/_enqueues/wp/emoji.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/js/_enqueues/wp/emoji.js b/src/js/_enqueues/wp/emoji.js index 7309016c10a67..f2cf8cc07067b 100644 --- a/src/js/_enqueues/wp/emoji.js +++ b/src/js/_enqueues/wp/emoji.js @@ -26,14 +26,12 @@ * @since 4.2.0 * @access private * - * @class - * * @see Twitter Emoji library * @link https://github.com/twitter/twemoji * * @return {{ - * parse: typeof parse, - * test: typeof test, + * parse: ( object: HTMLElement|string, args?: WPEmojiParseArgs ) => HTMLElement|string, + * test: ( text: ?string ) => boolean * }} The wpEmoji parse and test functions. */ function wpEmoji() { @@ -243,6 +241,6 @@ /** * @namespace wp.emoji */ - window.wp.emoji = new wpEmoji(); + window.wp.emoji = wpEmoji(); } )( window, window._wpemojiSettings ); From 6aa115b0ece576215ccfa097d82d5d44cacade4d Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 17 Sep 2026 14:09:34 -0700 Subject: [PATCH 23/32] Say which contexts the emoji support tests accept The tests were documented as taking a canvas 2D context, although the context they are given is an offscreen one whenever they run in a Worker. Reconciling the two took a cast through unknown, since neither context type is assignable to the other. Saying that either will do removes the need for it. The two are named once in a typedef rather than in each of the three docblocks. Obtaining the context is also moved to where the kind of canvas is still known. Asking a canvas which might be either for a 2D context gives back a union including every other kind of context, because the argument no longer picks out an overload, and that in turn was most of what the cast was covering up. What is left is a context which might be null, which the tests have always assumed away. That assumption is now written down. It fails in the same place as before, one line earlier and saying what went wrong instead of reporting that a property of null cannot be set. Co-Authored-By: Claude Opus 5 --- src/js/_enqueues/lib/emoji-loader.js | 46 ++++++++++++++++------------ 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/src/js/_enqueues/lib/emoji-loader.js b/src/js/_enqueues/lib/emoji-loader.js index 17c8cb29c760e..4a126ff5c355d 100644 --- a/src/js/_enqueues/lib/emoji-loader.js +++ b/src/js/_enqueues/lib/emoji-loader.js @@ -112,6 +112,15 @@ function setSessionSupportTests( supportTests ) { } catch ( e ) {} } +/** + * A 2D context for the support tests. + * + * Which of the two it is depends on the kind of canvas it came from, and the tests use only what + * both provide. + * + * @typedef {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} EmojiTestContext + */ + /** * Checks if two sets of Emoji characters render the same visually. * @@ -127,9 +136,9 @@ function setSessionSupportTests( supportTests ) { * * @private * - * @param {CanvasRenderingContext2D} context 2D Context. - * @param {string} set1 Set of Emoji to test. - * @param {string} set2 Set of Emoji to test. + * @param {EmojiTestContext} context 2D Context. + * @param {string} set1 Set of Emoji to test. + * @param {string} set2 Set of Emoji to test. * * @return {boolean} True if the two sets render the same. */ @@ -177,8 +186,8 @@ function emojiSetsRenderIdentically( context, set1, set2 ) { * * @private * - * @param {CanvasRenderingContext2D} context 2D Context. - * @param {string} emoji Emoji to test. + * @param {EmojiTestContext} context 2D Context. + * @param {string} emoji Emoji to test. * * @return {boolean} True if the center point is empty. */ @@ -209,10 +218,10 @@ function emojiRendersEmptyCenterPoint( context, emoji ) { * * @private * - * @param {CanvasRenderingContext2D} context 2D Context. - * @param {string} type Whether to test for support of "flag" or "emoji". - * @param {Function} emojiSetsRenderIdentically Reference to emojiSetsRenderIdentically function, needed due to minification. - * @param {Function} emojiRendersEmptyCenterPoint Reference to emojiRendersEmptyCenterPoint function, needed due to minification. + * @param {EmojiTestContext} context 2D Context. + * @param {string} type Whether to test for support of "flag" or "emoji". + * @param {Function} emojiSetsRenderIdentically Reference to emojiSetsRenderIdentically function, needed due to minification. + * @param {Function} emojiRendersEmptyCenterPoint Reference to emojiRendersEmptyCenterPoint function, needed due to minification. * * @return {boolean} True if the browser can render emoji, false if it cannot. */ @@ -310,23 +319,22 @@ function browserSupportsEmoji( context, type, emojiSetsRenderIdentically, emojiR * @return {SupportTests} Support tests. */ function testEmojiSupports( tests, browserSupportsEmoji, emojiSetsRenderIdentically, emojiRendersEmptyCenterPoint ) { - let canvas; + /** @type {?EmojiTestContext} */ + let context; + if ( typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope ) { - canvas = new OffscreenCanvas( 300, 150 ); // Dimensions are default for HTMLCanvasElement. + // Dimensions are default for HTMLCanvasElement. + context = new OffscreenCanvas( 300, 150 ).getContext( '2d', { willReadFrequently: true } ); } else { - canvas = document.createElement( 'canvas' ); + context = document.createElement( 'canvas' ).getContext( '2d', { willReadFrequently: true } ); } - /* - * Note: The OffscreenCanvas 2D context implements everything the tests below use, so it is cast - * to the canvas 2D context rather than each test having to account for both. - */ - const context = /** @type {CanvasRenderingContext2D} */ ( - /** @type {unknown} */ ( canvas.getContext( '2d', { willReadFrequently: true } ) ) - ); + if ( ! context ) { + throw new Error( 'Unable to obtain a 2D context for the emoji support tests.' ); + } /* * Chrome on OS X added native emoji rendering in M41. Unfortunately, From 8053eb8ef6331653df27edacdc2068754b2af702 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 17 Sep 2026 14:17:21 -0700 Subject: [PATCH 24/32] Describe the functions the emoji support tests are handed The tests are passed to one another as arguments rather than referred to by name, so that minification cannot rename them out from under the Worker the tests are serialized into. Each was documented only as a Function, which accepts any arguments at all, so nothing about how they were called was checked. The one call to browserSupportsEmoji() went through such a parameter, which is why narrowing what that function accepts had no effect on it. Each of the three now has a callback describing what it takes and returns, and those are used in place of Function. Passing them in the wrong order is reported, as is asking for a test which does not exist, neither of which was reported before. Worth having for code which is turned into a string and run somewhere else, where a mistake of that kind surfaces as emoji quietly not being replaced. Co-Authored-By: Claude Opus 5 --- src/js/_enqueues/lib/emoji-loader.js | 52 +++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/src/js/_enqueues/lib/emoji-loader.js b/src/js/_enqueues/lib/emoji-loader.js index 4a126ff5c355d..4f406543c4371 100644 --- a/src/js/_enqueues/lib/emoji-loader.js +++ b/src/js/_enqueues/lib/emoji-loader.js @@ -121,6 +121,42 @@ function setSessionSupportTests( supportTests ) { * @typedef {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} EmojiTestContext */ +/** + * Checks if two sets of Emoji characters render the same visually. + * + * @callback EmojiSetsRenderIdentically + * + * @param {EmojiTestContext} context 2D Context. + * @param {string} set1 Set of Emoji to test. + * @param {string} set2 Set of Emoji to test. + * + * @return {boolean} True if the two sets render the same. + */ + +/** + * Checks if the center point of a single emoji is empty. + * + * @callback EmojiRendersEmptyCenterPoint + * + * @param {EmojiTestContext} context 2D Context. + * @param {string} emoji Emoji to test. + * + * @return {boolean} True if the center point is empty. + */ + +/** + * Determines if the browser properly renders Emoji that Twemoji can supplement. + * + * @callback BrowserSupportsEmoji + * + * @param {EmojiTestContext} context 2D Context. + * @param {keyof SupportTests} type Which support test to run. + * @param {EmojiSetsRenderIdentically} emojiSetsRenderIdentically Reference to emojiSetsRenderIdentically function, needed due to minification. + * @param {EmojiRendersEmptyCenterPoint} emojiRendersEmptyCenterPoint Reference to emojiRendersEmptyCenterPoint function, needed due to minification. + * + * @return {boolean} True if the browser can render emoji, false if it cannot. + */ + /** * Checks if two sets of Emoji characters render the same visually. * @@ -218,10 +254,10 @@ function emojiRendersEmptyCenterPoint( context, emoji ) { * * @private * - * @param {EmojiTestContext} context 2D Context. - * @param {string} type Whether to test for support of "flag" or "emoji". - * @param {Function} emojiSetsRenderIdentically Reference to emojiSetsRenderIdentically function, needed due to minification. - * @param {Function} emojiRendersEmptyCenterPoint Reference to emojiRendersEmptyCenterPoint function, needed due to minification. + * @param {EmojiTestContext} context 2D Context. + * @param {keyof SupportTests} type Which support test to run. + * @param {EmojiSetsRenderIdentically} emojiSetsRenderIdentically Reference to emojiSetsRenderIdentically function, needed due to minification. + * @param {EmojiRendersEmptyCenterPoint} emojiRendersEmptyCenterPoint Reference to emojiRendersEmptyCenterPoint function, needed due to minification. * * @return {boolean} True if the browser can render emoji, false if it cannot. */ @@ -311,10 +347,10 @@ function browserSupportsEmoji( context, type, emojiSetsRenderIdentically, emojiR * * @private * - * @param {Array} tests Which support tests to run. - * @param {Function} browserSupportsEmoji Reference to browserSupportsEmoji function, needed due to minification. - * @param {Function} emojiSetsRenderIdentically Reference to emojiSetsRenderIdentically function, needed due to minification. - * @param {Function} emojiRendersEmptyCenterPoint Reference to emojiRendersEmptyCenterPoint function, needed due to minification. + * @param {Array} tests Which support tests to run. + * @param {BrowserSupportsEmoji} browserSupportsEmoji Reference to browserSupportsEmoji function, needed due to minification. + * @param {EmojiSetsRenderIdentically} emojiSetsRenderIdentically Reference to emojiSetsRenderIdentically function, needed due to minification. + * @param {EmojiRendersEmptyCenterPoint} emojiRendersEmptyCenterPoint Reference to emojiRendersEmptyCenterPoint function, needed due to minification. * * @return {SupportTests} Support tests. */ From 1621a4d214da607bd2bc310612664acdcdbed6fc Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 17 Sep 2026 15:29:07 -0700 Subject: [PATCH 25/32] Type the last two loose values in the emoji loader A sweep of both emoji scripts for values TypeScript could only see as any left two in the loader, and none at all in wp-emoji. The variable holding the result of each flag comparison was declared without a type and assigned further down, so it was any throughout. It holds what emojiSetsRenderIdentically() returns, which is now described, so it is said to be a boolean. The message arriving from the Worker was cast to the support tests where it was read. It is described in the handler's signature instead. This is not merely moving the assertion: the Worker is built a few lines above from this file's own function, which posts exactly what that function returns, so the shape of the message is a contract this file controls rather than a guess about data from somewhere else. What remains untyped is the three ignored catch clauses, which TypeScript gives as unknown under strict, and which is correct. Co-Authored-By: Claude Opus 5 --- src/js/_enqueues/lib/emoji-loader.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/js/_enqueues/lib/emoji-loader.js b/src/js/_enqueues/lib/emoji-loader.js index 4f406543c4371..2f67372becf1a 100644 --- a/src/js/_enqueues/lib/emoji-loader.js +++ b/src/js/_enqueues/lib/emoji-loader.js @@ -262,6 +262,7 @@ function emojiRendersEmptyCenterPoint( context, emoji ) { * @return {boolean} True if the browser can render emoji, false if it cannot. */ function browserSupportsEmoji( context, type, emojiSetsRenderIdentically, emojiRendersEmptyCenterPoint ) { + /** @type {boolean} */ let isIdentical; switch ( type ) { @@ -437,11 +438,10 @@ const supportTestsPromise = new Promise( ( resolve ) => { type: 'text/javascript' } ); const worker = new Worker( URL.createObjectURL( blob ), { name: 'wpTestEmojiSupports' } ); - worker.onmessage = ( event ) => { - const workerSupportTests = /** @type {SupportTests} */ ( event.data ); - setSessionSupportTests( workerSupportTests ); + worker.onmessage = ( /** @type {MessageEvent} */ event ) => { + setSessionSupportTests( event.data ); worker.terminate(); - resolve( workerSupportTests ); + resolve( event.data ); }; return; } catch ( e ) {} From a6466d15e8b79f4833367e2271ded4e7539ec39d Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 17 Sep 2026 15:35:41 -0700 Subject: [PATCH 26/32] Let the emoji support tests be checked for exhaustiveness Now that the kind of test being run is named rather than merely called a string, the switch which runs it covers every kind there is, and the return beneath it can no longer be reached. Removing it means a test added without a case is reported, where before it would quietly have been answered with false: the browser would have been recorded as unable to render the new emoji, and Twemoji loaded for no reason. Note that the compiler says nothing about this as configured. Unreachable code is a suggestion by default, which editors show and the command line does not, and allowUnreachableCode is not among the things strict turns on. Co-Authored-By: Claude Opus 5 --- src/js/_enqueues/lib/emoji-loader.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/js/_enqueues/lib/emoji-loader.js b/src/js/_enqueues/lib/emoji-loader.js index 2f67372becf1a..1e0f224255729 100644 --- a/src/js/_enqueues/lib/emoji-loader.js +++ b/src/js/_enqueues/lib/emoji-loader.js @@ -334,8 +334,6 @@ function browserSupportsEmoji( context, type, emojiSetsRenderIdentically, emojiR const notSupported = emojiRendersEmptyCenterPoint( context, '\uD83E\u1FAC8' ); return ! notSupported; } - - return false; } /** From 727be9db64cbed11ce8b79a92be60ee3da955a29 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 17 Sep 2026 15:37:24 -0700 Subject: [PATCH 27/32] Report unreachable code from the command line TypeScript treats unreachable code as a suggestion unless told otherwise, which editors show and the command line does not, and it is not among the things strict turns on. Anyone running the check would see nothing, while anyone with the file open would see it marked. Setting allowUnreachableCode reports it either way. Every file currently checked passes with it on, so nothing needs fixing to adopt it; it is here to catch what comes next as more files are added. Co-Authored-By: Claude Opus 5 --- tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tsconfig.json b/tsconfig.json index 8cf26250a1f92..df150871d9311 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,7 @@ "erasableSyntaxOnly": true, "noUnusedLocals": true, "noUnusedParameters": true, + "allowUnreachableCode": false, "skipLibCheck": true, "isolatedModules": true, "allowSyntheticDefaultImports": true, From ad9284ee2ef9bcccc0362cc74ca4f1da3976e7b8 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 17 Sep 2026 15:42:29 -0700 Subject: [PATCH 28/32] Cover the case which distinguishes skipping a record from abandoning them all The observer tests each made one change and then waited, so the observer was only ever given a single record at a time, and a single record is exactly the case in which skipping one and abandoning the rest look the same. Returning from the callback rather than continuing past the record it recognized passed the whole suite. The test added here makes both changes before waiting, so that the two arrive together, and puts the fallback first. The element added beside it must still be parsed. Returning instead of continuing now fails, as does any later change which makes the callback sensitive to the order the records arrive in. Co-Authored-By: Claude Opus 5 --- tests/qunit/wp-includes/js/wp-emoji.js | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/qunit/wp-includes/js/wp-emoji.js b/tests/qunit/wp-includes/js/wp-emoji.js index d6166e3d2ef79..c6a62c195a52f 100644 --- a/tests/qunit/wp-includes/js/wp-emoji.js +++ b/tests/qunit/wp-includes/js/wp-emoji.js @@ -371,6 +371,30 @@ QUnit.test( 'leaves alone an image replaced by its own alternative text', async assert.strictEqual( twemoji.calls, 0, 'The text which replaced the image is not parsed back into one.' ); } ); +QUnit.test( 'parses other additions delivered alongside a fallback', async function ( assert ) { + const nodes = emojiImageParagraph( 'load-failed' ); + + await attachToFixture( nodes.paragraph ); + + const other = emojiFixtureElement( EMOJI ); + + twemoji.calls = 0; + twemoji.lastObject = null; + + /* + * Both changes are made before waiting, so that the observer is given the two records in one + * call. The fallback comes first: recognizing it must not stop the rest of the records being + * looked at, which is the difference between skipping that record and abandoning the callback. + */ + nodes.paragraph.replaceChild( document.createTextNode( EMOJI ), nodes.image ); + document.getElementById( 'qunit-fixture' ).appendChild( other ); + + await afterMutations(); + + assert.strictEqual( twemoji.calls, 1, 'Twemoji is called once.' ); + assert.strictEqual( twemoji.lastObject, other, 'The element added alongside the fallback is still parsed.' ); +} ); + QUnit.test( 'parses the same replacement when the image was not marked', async function ( assert ) { const nodes = emojiImageParagraph(); From 4158486c758ea02f5ecd26109c5185642d9a175e Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 17 Sep 2026 16:20:37 -0700 Subject: [PATCH 29/32] Cover the elements the observer must not hand to Twemoji What keeps an SVG or MathML element from being parsed is the test that the element is an HTML one, and nothing exercised it. Widening that test back to any element, which is what the observer did before, passed the whole suite. Three cases are added: an SVG element and a MathML element, each holding an emoji and each added after the page has loaded, and a text node added within an SVG element, which reaches the same test by way of the element containing it. None of the three may reach Twemoji, which would otherwise put an HTML image inside markup where one does not belong. Co-Authored-By: Claude Opus 5 --- tests/qunit/wp-includes/js/wp-emoji.js | 45 ++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/qunit/wp-includes/js/wp-emoji.js b/tests/qunit/wp-includes/js/wp-emoji.js index c6a62c195a52f..f94cdd2723b48 100644 --- a/tests/qunit/wp-includes/js/wp-emoji.js +++ b/tests/qunit/wp-includes/js/wp-emoji.js @@ -309,6 +309,22 @@ function attachToFixture( element ) { return afterMutations(); } +/** + * Adds an element of the given kind, holding an emoji, to the fixture. + * + * @param {string} namespace Namespace URI for the element. + * @param {string} name Local name for the element. + * + * @return {Promise} A promise which settles once the observer has run. + */ +function attachNamespacedElement( namespace, name ) { + const element = document.createElementNS( namespace, name ); + + element.appendChild( document.createTextNode( EMOJI ) ); + + return attachToFixture( element ); +} + /** * Builds a paragraph holding an emoji image, as Twemoji would have left it. * @@ -371,6 +387,35 @@ QUnit.test( 'leaves alone an image replaced by its own alternative text', async assert.strictEqual( twemoji.calls, 0, 'The text which replaced the image is not parsed back into one.' ); } ); +QUnit.test( 'leaves an SVG element alone', async function ( assert ) { + twemoji.calls = 0; + + await attachNamespacedElement( 'http://www.w3.org/2000/svg', 'svg' ); + + assert.strictEqual( twemoji.calls, 0, 'An SVG element is not given to Twemoji, which would put an image inside it.' ); +} ); + +QUnit.test( 'leaves a MathML element alone', async function ( assert ) { + twemoji.calls = 0; + + await attachNamespacedElement( 'http://www.w3.org/1998/Math/MathML', 'math' ); + + assert.strictEqual( twemoji.calls, 0, 'A MathML element is not given to Twemoji.' ); +} ); + +QUnit.test( 'leaves alone a text node added within an SVG element', async function ( assert ) { + const svg = document.createElementNS( 'http://www.w3.org/2000/svg', 'svg' ); + + await attachToFixture( svg ); + + twemoji.calls = 0; + + svg.appendChild( document.createTextNode( EMOJI ) ); + await afterMutations(); + + assert.strictEqual( twemoji.calls, 0, 'The SVG element containing the text is not parsed either.' ); +} ); + QUnit.test( 'parses other additions delivered alongside a fallback', async function ( assert ) { const nodes = emojiImageParagraph( 'load-failed' ); From 8e3ad60be5d7871f2f3389c9147f27b27a68e69f Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 17 Sep 2026 16:21:36 -0700 Subject: [PATCH 30/32] Say what the unmarked replacement test checks The message on its closing assertion described the pair of tests it belongs to rather than the one thing that assertion establishes, and described it the wrong way round: the replacement is parsed here, precisely because the marker is absent. Read on a failure, which is the only time it is read, it pointed at the opposite behavior. Co-Authored-By: Claude Opus 5 --- tests/qunit/wp-includes/js/wp-emoji.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/qunit/wp-includes/js/wp-emoji.js b/tests/qunit/wp-includes/js/wp-emoji.js index f94cdd2723b48..90ba9f056ed52 100644 --- a/tests/qunit/wp-includes/js/wp-emoji.js +++ b/tests/qunit/wp-includes/js/wp-emoji.js @@ -452,5 +452,5 @@ QUnit.test( 'parses the same replacement when the image was not marked', async f await afterMutations(); assert.strictEqual( twemoji.calls, 1, 'Twemoji is called once.' ); - assert.strictEqual( twemoji.lastObject, nodes.paragraph, 'It is the marker, and nothing else, which stops the replacement being parsed.' ); + assert.strictEqual( twemoji.lastObject, nodes.paragraph, 'Without the marker, the element containing the replacement is parsed.' ); } ); From 2361670e2da26ce32276acefdb90799c81c6474c Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 17 Sep 2026 16:52:16 -0700 Subject: [PATCH 31/32] Correct the Twemoji types against the library as it is vendored These were written from what wp-emoji passes rather than from what Twemoji accepts, and were incomplete as a result. Checking them against the vendored copy, which is the one that matters since it carries WordPress's own patch, turned up three things. Neither size nor folder was declared, though parse() reads both when working out where the assets are, so passing either would have been rejected. Nor did the attributes callback admit returning null, which is what Twemoji falls back to when none is given, and what wp-emoji itself returns when handed a null in place of the attributes. The resolved options a callback receives always carry a size, so that is said too. Only what wp-emoji uses is described, which is now stated rather than left to be discovered: the library also exposes replace(), test() and convert, and parse() takes a callback in place of the options object. Co-Authored-By: Claude Opus 5 --- typings/wp-emoji/index.d.ts | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/typings/wp-emoji/index.d.ts b/typings/wp-emoji/index.d.ts index b3bb9ce80843b..3b59f65361b27 100644 --- a/typings/wp-emoji/index.d.ts +++ b/typings/wp-emoji/index.d.ts @@ -70,27 +70,37 @@ declare var _wpemojiSettings: WPEmojiSettings; /** * Options accepted by `twemoji.parse()`. * - * Note that `doNotParse` is not part of the upstream library. The vendored copy in - * js/_enqueues/vendor/twemoji.js was patched to add it. + * Taken from the vendored copy in js/_enqueues/vendor/twemoji.js rather than from the library's own + * documentation, since that copy is patched: `doNotParse` is WordPress's own addition and is not + * part of the library upstream. */ interface TwemojiParseOptions { /** Base URL to prepend to each image source. */ base?: string; /** File extension to append to each image source. */ ext?: string; + /** Asset size, squared into a path segment: 72 becomes 72x72. */ + size?: string | number; + /** Path segment to use in place of the size, when the assets are not in a square named folder. */ + folder?: string; /** Class name to give each generated image. */ className?: string; /** Returns the source for an icon, or false to leave the character as it is. */ callback?: ( icon: string, options: TwemojiResolvedParseOptions ) => string | false; - /** Returns the attributes to set on each generated image. */ - attributes?: ( rawText: string, iconId: string ) => Record< string, string >; + /** + * Returns the attributes to set on each generated image. + * + * Null is allowed, and is what Twemoji itself falls back to: the result is only ever read with + * `for...in`, which does nothing when given it. + */ + attributes?: ( rawText: string, iconId: string ) => Record< string, string > | null; /** Runs on the generated image when it fails to load, with the image as `this`. */ onerror?: ( this: HTMLImageElement ) => void; /** * Returns true to leave an element, and everything under it, unparsed. * - * Twemoji only calls this for element nodes, and never for anything inside an SVG, so callers do - * not have to test for either. + * Twemoji only calls this for element nodes, never for anything within an SVG, and never for + * script, style and the like, so callers do not have to test for any of those. */ doNotParse?: ( element: Element ) => boolean; } @@ -102,6 +112,7 @@ interface TwemojiParseOptions { interface TwemojiResolvedParseOptions extends TwemojiParseOptions { base: string; ext: string; + size: string | number; className: string; } @@ -109,7 +120,11 @@ interface TwemojiResolvedParseOptions extends TwemojiParseOptions { * The vendored Twemoji library. * * Absent until js/_enqueues/vendor/twemoji.js has loaded, so callers must guard with a `typeof` - * check. Only the members which WordPress itself uses are declared. + * check. + * + * Only what WordPress itself uses is declared. The library also exposes `replace()`, `test()`, + * `convert`, and the defaults behind the options above, and `parse()` additionally accepts a + * callback in place of the options object. None of that is described here. */ interface Twemoji { /** Replaces the emoji in an element, in place. */ From 98ba5b25231112b479b08b397edad20a3f6baee1 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 17 Sep 2026 16:52:52 -0700 Subject: [PATCH 32/32] Point wp-emoji at the Twemoji it actually uses The library was swapped for the jdecked fork in r57626, after the original was abandoned following the change of ownership at Twitter, and everything about the vendored copy has said so since: its licence header, its default asset URL, and the version it reports. The docblock at the top of wp-emoji was not updated with it, and still sent readers to the repository which was left behind. Co-Authored-By: Claude Opus 5 --- src/js/_enqueues/wp/emoji.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/js/_enqueues/wp/emoji.js b/src/js/_enqueues/wp/emoji.js index f2cf8cc07067b..a1f0f4face234 100644 --- a/src/js/_enqueues/wp/emoji.js +++ b/src/js/_enqueues/wp/emoji.js @@ -26,8 +26,8 @@ * @since 4.2.0 * @access private * - * @see Twitter Emoji library - * @link https://github.com/twitter/twemoji + * @see Twemoji + * @link https://github.com/jdecked/twemoji * * @return {{ * parse: ( object: HTMLElement|string, args?: WPEmojiParseArgs ) => HTMLElement|string,