diff --git a/.eslintrc.js b/.eslintrc.js index c38d6646522a30..9d0776c1730c69 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -105,6 +105,7 @@ module.exports = { 'flatMap', 'flatten', 'flattenDeep', + 'flow', 'flowRight', 'forEach', 'fromPairs', @@ -151,6 +152,7 @@ module.exports = { 'sum', 'sumBy', 'take', + 'throttle', 'times', 'toString', 'trim', @@ -162,7 +164,6 @@ module.exports = { 'uniqWith', 'upperFirst', 'values', - 'words', 'xor', 'zip', ], @@ -355,7 +356,7 @@ module.exports = { }, }, { - files: [ 'bin/**/*.js', 'packages/env/**' ], + files: [ 'bin/**/*.js', 'bin/**/*.mjs', 'packages/env/**' ], rules: { 'no-console': 'off', }, diff --git a/bin/api-docs/gen-block-lib-list.js b/bin/api-docs/gen-block-lib-list.js index 969ed2617fc0ab..60b0a99b096ab8 100644 --- a/bin/api-docs/gen-block-lib-list.js +++ b/bin/api-docs/gen-block-lib-list.js @@ -1,7 +1,7 @@ /** * Generates core block documentation using block.json files. * Reads from : packages/block-library/src - * Publishes to: docs/reference-guides/core-blocks.ms + * Publishes to: docs/reference-guides/core-blocks.md */ /** diff --git a/bin/api-docs/gen-theme-reference.js b/bin/api-docs/gen-theme-reference.js index 8a6633bf878a8c..d051261963daf4 100644 --- a/bin/api-docs/gen-theme-reference.js +++ b/bin/api-docs/gen-theme-reference.js @@ -1,7 +1,7 @@ /** - * Generates core block documentation using block.json files. - * Reads from : packages/block-library/src - * Publishes to: docs/reference-guides/core-blocks.ms + * Generates theme.json documentation using theme.json schema. + * Reads from : schemas/json/theme.json + * Publishes to: docs/reference-guides/theme-json-reference/theme-json-living.md */ /** diff --git a/bin/cherry-pick.mjs b/bin/cherry-pick.mjs index 0bf1d7926b5bdb..8d1da80b316d53 100644 --- a/bin/cherry-pick.mjs +++ b/bin/cherry-pick.mjs @@ -6,11 +6,10 @@ import readline from 'readline'; import { spawnSync } from 'node:child_process'; -const LABEL = process.argv[2] || "Backport to WP Minor Release"; +const LABEL = process.argv[ 2 ] || 'Backport to WP Minor Release'; const BRANCH = getCurrentBranch(); -const GITHUB_CLI_AVAILABLE = spawnSync( 'gh', ['auth', 'status'] ) - ?.stderr - ?.toString() +const GITHUB_CLI_AVAILABLE = spawnSync( 'gh', [ 'auth', 'status' ] ) + ?.stderr?.toString() .includes( '✓ Logged in to github.com as' ); const AUTO_PROPAGATE_RESULTS_TO_GITHUB = GITHUB_CLI_AVAILABLE; @@ -28,30 +27,34 @@ const AUTO_PROPAGATE_RESULTS_TO_GITHUB = GITHUB_CLI_AVAILABLE; * * Reports the results */ async function main() { - if ( !GITHUB_CLI_AVAILABLE ) { + if ( ! GITHUB_CLI_AVAILABLE ) { await reportGhUnavailable(); } - console.log( `You are on branch "${BRANCH}".` ); + console.log( `You are on branch "${ BRANCH }".` ); console.log( `This script will:` ); - console.log( `• Cherry-pick the merged PRs labeled as "${LABEL}" to this branch` ); + console.log( + `• Cherry-pick the merged PRs labeled as "${ LABEL }" to this branch` + ); console.log( `• Ask whether you want to push this branch` ); console.log( `• Comment on each PR` ); console.log( `• Remove the label from each PR` ); - console.log( `The last two actions will be performed USING YOUR GITHUB ACCOUNT that` ) - console.log( `you've linked to your GitHub CLI (gh command)` ) + console.log( + `The last two actions will be performed USING YOUR GITHUB ACCOUNT that` + ); + console.log( `you've linked to your GitHub CLI (gh command)` ); console.log( `` ); await promptDoYouWantToProceed(); console.log( `$ git pull origin ${ BRANCH } --rebase...` ); - cli( 'git', ['pull', 'origin', BRANCH, '--rebase'], true ); + cli( 'git', [ 'pull', 'origin', BRANCH, '--rebase' ], true ); console.log( `$ git fetch origin trunk...` ); - cli( 'git', ['fetch', 'origin', 'trunk'], true ); + cli( 'git', [ 'fetch', 'origin', 'trunk' ], true ); const PRs = await fetchPRs(); console.log( 'Trying to cherry-pick one by one...' ); - const [successes, failures] = cherryPickAll( PRs ); + const [ successes, failures ] = cherryPickAll( PRs ); console.log( 'Cherry-picking finished!' ); reportSummaryNextSteps( successes, failures ); @@ -60,17 +63,17 @@ async function main() { if ( AUTO_PROPAGATE_RESULTS_TO_GITHUB ) { console.log( `About to push to origin/${ BRANCH }` ); await promptDoYouWantToProceed(); - cli( 'git', ['push', 'origin', BRANCH] ); + cli( 'git', [ 'push', 'origin', BRANCH ] ); console.log( `Commenting and removing labels...` ); successes.forEach( GHcommentAndRemoveLabel ); } else { - console.log( "Cherry-picked PRs with copy-able comments:" ); + console.log( 'Cherry-picked PRs with copy-able comments:' ); successes.forEach( reportSuccessManual ); } } if ( failures.length ) { - console.log( "PRs that could not be cherry-picked automatically:" ); + console.log( 'PRs that could not be cherry-picked automatically:' ); failures.forEach( reportFailure ); } console.log( `Done!` ); @@ -79,9 +82,9 @@ async function main() { /** * Synchronously executes a CLI command and returns the result or throws an error on failure. * - * @param {string} command A command to execute. - * @param {string[]} args CLI args. - * @param {boolean} pipe If true, pipes the output to this process's stdout and stderr. + * @param {string} command A command to execute. + * @param {string[]} args CLI args. + * @param {boolean} pipe If true, pipes the output to this process's stdout and stderr. * @return {string} Command's output. */ function cli( command, args, pipe = false ) { @@ -91,7 +94,11 @@ function cli( command, args, pipe = false ) { stdio: 'pipe', encoding: 'utf-8', }; - const result = spawnSync( command, args, ...( pipe ? [ pipeOptions ] : [] ) ); + const result = spawnSync( + command, + args, + ...( pipe ? [ pipeOptions ] : [] ) + ); if ( result.status !== 0 ) { throw new Error( result.stderr?.toString()?.trim() ); } @@ -105,30 +112,39 @@ function cli( command, args, pipe = false ) { */ async function fetchPRs() { const { items } = await GitHubFetch( - `/search/issues?q=is:pr state:closed sort:updated label:"${ LABEL }" repo:WordPress/gutenberg`, + `/search/issues?q=is:pr state:closed sort:updated label:"${ LABEL }" repo:WordPress/gutenberg` ); - const PRs = items.map( ( { id, number, title } ) => ( { id, number, title } ) ); + const PRs = items.map( ( { id, number, title } ) => ( { + id, + number, + title, + } ) ); console.log( 'Found the following PRs to cherry-pick: ' ); - PRs.forEach( ( { number, title } ) => console.log( indent( `#${ number } – ${ title }` ) ) ); + PRs.forEach( ( { number, title } ) => + console.log( indent( `#${ number } – ${ title }` ) ) + ); console.log( 'Fetching commit IDs...' ); const PRsWithMergeCommit = []; for ( const PR of PRs ) { - const { merge_commit_sha } = await GitHubFetch( - '/repos/WordPress/Gutenberg/pulls/' + PR.number, + const { mergeCommitHash } = await GitHubFetch( + '/repos/WordPress/Gutenberg/pulls/' + PR.number ); PRsWithMergeCommit.push( { ...PR, - mergeCommitHash: merge_commit_sha, + mergeCommitHash, } ); - if ( !merge_commit_sha ) { - throw new Error( `Cannot fetch the merge commit sha for ${ prToString( PR ) }` ); + if ( ! mergeCommitHash ) { + throw new Error( + `Cannot fetch the merge commit sha for ${ prToString( PR ) }` + ); } } console.log( 'Done!' ); - PRsWithMergeCommit - .forEach( ( msg ) => console.log( indent( `${ prToString( msg ) }` ) ) ); + PRsWithMergeCommit.forEach( ( msg ) => + console.log( indent( `${ prToString( msg ) }` ) ) + ); return PRsWithMergeCommit; } @@ -139,14 +155,11 @@ async function fetchPRs() { * @return {Promise} Parsed response JSON. */ async function GitHubFetch( path ) { - const response = await fetch( - 'https://api.github.com' + path, - { - headers: { - Accept: 'application/vnd.github.v3+json', - }, + const response = await fetch( 'https://api.github.com' + path, { + headers: { + Accept: 'application/vnd.github.v3+json', }, - ); + } ); return await response.json(); } @@ -160,20 +173,22 @@ async function GitHubFetch( path ) { * @return {Array} A two-tuple containing a list of successful cherry-picks and a list of failed ones. */ function cherryPickAll( PRs ) { - let remainingPRs = [...PRs]; + let remainingPRs = [ ...PRs ]; let i = 1; let allSuccesses = []; while ( remainingPRs.length ) { - console.log( `Cherry-picking round ${ i ++ }: ` ); - const [successes, failures] = cherryPickRound( remainingPRs ); - allSuccesses = [...allSuccesses, ...successes]; + console.log( `Cherry-picking round ${ i++ }: ` ); + const [ successes, failures ] = cherryPickRound( remainingPRs ); + allSuccesses = [ ...allSuccesses, ...successes ]; remainingPRs = failures; - if ( !successes.length ) { - console.log( 'Nothing merged cleanly in the last round, breaking.' ); + if ( ! successes.length ) { + console.log( + 'Nothing merged cleanly in the last round, breaking.' + ); break; } } - return [allSuccesses, remainingPRs]; + return [ allSuccesses, remainingPRs ]; } /** @@ -185,7 +200,7 @@ function cherryPickAll( PRs ) { * @return {Array} A two-tuple containing a list of successful cherry-picks and a list of failed ones. */ function cherryPickRound( PRs ) { - const stack = [...PRs]; + const stack = [ ...PRs ]; const successes = []; const failures = []; while ( stack.length ) { @@ -198,7 +213,12 @@ function cherryPickRound( PRs ) { } ); console.log( indent( - `✅ cherry-pick commit: ${ cherryPickHash } for PR: ${ prToString( PR, false ) }` ) ); + `✅ cherry-pick commit: ${ cherryPickHash } for PR: ${ prToString( + PR, + false + ) }` + ) + ); } catch ( e ) { failures.push( { ...PR, @@ -207,7 +227,7 @@ function cherryPickRound( PRs ) { console.log( indent( `❌ ${ prToString( PR ) }` ) ); } } - return [successes, failures]; + return [ successes, failures ]; } /** @@ -216,48 +236,64 @@ function cherryPickRound( PRs ) { * @param {*} x Input. * @return {*} Input */ -const identity = x => x; +const identity = ( x ) => x; /** * Formats a PR object in a human readable way. * - * @param {Object} PR PR details. + * @param {Object} PR PR details. + * @param {number} PR.number + * @param {string} PR.mergeCommitHash + * @param {string} PR.title * @param {boolean} withMergeCommitHash Should include the commit hash in the output? * @return {string} Formatted text */ -function prToString( { number, mergeCommitHash, title }, withMergeCommitHash = true ) { +function prToString( + { number, mergeCommitHash, title }, + withMergeCommitHash = true +) { return [ `#${ number }`, withMergeCommitHash ? mergeCommitHash?.substr( 0, 20 ) : '', `${ title?.substr( 0, 30 ) }${ title?.length > 30 ? '...' : '' }`, - ].filter( identity ).join( ' – ' ); + ] + .filter( identity ) + .join( ' – ' ); } /** * Indents a block of text with {width} spaces * - * @param {string} text The text to indent. + * @param {string} text The text to indent. * @param {number} width Number of spaces to use. * @return {string} Indented text. */ function indent( text, width = 3 ) { - const indent = ' '.repeat( width ); - return text.split( "\n" ).map( line => indent + line ).join( "\n" ); + const _indent = ' '.repeat( width ); + return text + .split( '\n' ) + .map( ( line ) => _indent + line ) + .join( '\n' ); } /** * Attempts to cherry-pick a given commit into the current branch, * + * @param {string} commit A commit hash. * @return {string} Branch name. */ function cherryPickOne( commit ) { - const result = spawnSync( 'git', ['cherry-pick', commit] ); + const result = spawnSync( 'git', [ 'cherry-pick', commit ] ); const message = result.stdout.toString().trim(); - if ( result.status !== 0 || !message.includes( 'Author: ' ) ) { - spawnSync( 'git', ['reset', '--hard'] ); + if ( result.status !== 0 || ! message.includes( 'Author: ' ) ) { + spawnSync( 'git', [ 'reset', '--hard' ] ); throw new Error( result.stderr.toString().trim() ); } - const commitHashOutput = spawnSync( 'git', ['rev-parse', '--short', 'HEAD'] ); + const commitHashOutput = spawnSync( 'git', [ + 'rev-parse', + '--short', + 'HEAD', + ] ); return commitHashOutput.stdout.toString().trim(); } @@ -266,17 +302,24 @@ function cherryPickOne( commit ) { * and informs about the next steps to take. * * @param {Array} successes Successful cherry-picks. - * @param {Array} failures Failed cherry-picks. + * @param {Array} failures Failed cherry-picks. */ function reportSummaryNextSteps( successes, failures ) { console.log( 'Summary:' ); - console.log( indent( `✅ ${ successes.length } PRs got cherry-picked cleanly` ) ); console.log( - indent( `${ failures.length > 0 ? '❌' : '✅' } ${ failures.length } PRs failed` ) ); + indent( `✅ ${ successes.length } PRs got cherry-picked cleanly` ) + ); + console.log( + indent( + `${ failures.length > 0 ? '❌' : '✅' } ${ + failures.length + } PRs failed` + ) + ); console.log( '' ); const nextSteps = []; - if ( successes.length && !AUTO_PROPAGATE_RESULTS_TO_GITHUB ) { + if ( successes.length && ! AUTO_PROPAGATE_RESULTS_TO_GITHUB ) { nextSteps.push( 'Push this branch' ); nextSteps.push( 'Go to each of the cherry-picked Pull Requests' ); nextSteps.push( `Remove the ${ LABEL } label` ); @@ -287,8 +330,8 @@ function reportSummaryNextSteps( successes, failures ) { nextSteps.push( 'Manually cherry-pick the PRs that failed' ); } if ( nextSteps.length ) { - console.log( "Next steps:" ); - for ( let i = 0; i < nextSteps.length; i ++ ) { + console.log( 'Next steps:' ); + for ( let i = 0; i < nextSteps.length; i++ ) { console.log( indent( `${ i + 1 }. ${ nextSteps[ i ] }` ) ); } console.log( '' ); @@ -307,8 +350,8 @@ function GHcommentAndRemoveLabel( pr ) { const { number, cherryPickHash } = pr; const comment = prComment( cherryPickHash ); try { - cli( 'gh', ['pr', 'comment', number, '--body', comment] ); - cli( 'gh', ['pr', 'edit', number, '--remove-label', LABEL] ); + cli( 'gh', [ 'pr', 'comment', number, '--body', comment ] ); + cli( 'gh', [ 'pr', 'edit', number, '--remove-label', LABEL ] ); console.log( `✅ ${ number }: ${ comment }` ); } catch ( e ) { console.log( `❌ ${ number }. ${ comment } ` ); @@ -322,7 +365,10 @@ function GHcommentAndRemoveLabel( pr ) { /** * When cherry-pick succeeds, this function outputs the manual next steps to take. * - * @param {Object} PR PR details. + * @param {Object} PR PR details. + * @param {number} PR.number + * @param {string} PR.title + * @param {string} PR.cherryPickHash */ function reportSuccessManual( { number, title, cherryPickHash } ) { console.log( indent( prUrl( number ) ) ); @@ -334,7 +380,11 @@ function reportSuccessManual( { number, title, cherryPickHash } ) { /** * When cherry-pick fails, this function outputs the details. * - * @param {Object} PR PR details. + * @param {Object} PR PR details. + * @param {number} PR.number + * @param {string} PR.title + * @param {string} PR.error + * @param {string} PR.mergeCommitHash */ function reportFailure( { number, title, error, mergeCommitHash } ) { console.log( indent( prUrl( number ) ) ); @@ -348,6 +398,7 @@ function reportFailure( { number, title, error, mergeCommitHash } ) { /** * Returns the URL of the Gutenberg PR given its number. * + * @param {number} number * @return {string} PR URL. */ function prUrl( number ) { @@ -371,7 +422,9 @@ function prComment( cherryPickHash ) { * @return {string} Branch name. */ function getCurrentBranch() { - return spawnSync( 'git', ['rev-parse', '--abbrev-ref', 'HEAD'] ).stdout.toString().trim(); + return spawnSync( 'git', [ 'rev-parse', '--abbrev-ref', 'HEAD' ] ) + .stdout.toString() + .trim(); } /** @@ -381,12 +434,19 @@ function getCurrentBranch() { * @return {Promise} */ async function reportGhUnavailable() { - console.log( 'Github CLI is not setup. This script will not be able to automatically' ); - console.log( 'comment on the processed PRs and remove the backport label from them.' ); - console.log( 'Instead, you will see a detailed list of next steps to perform manually.' ); + console.log( + 'Github CLI is not setup. This script will not be able to automatically' + ); + console.log( + 'comment on the processed PRs and remove the backport label from them.' + ); + console.log( + 'Instead, you will see a detailed list of next steps to perform manually.' + ); console.log( '' ); console.log( - 'To enable automatic handling, install the `gh` utility from https://cli.github.com/' ); + 'To enable automatic handling, install the `gh` utility from https://cli.github.com/' + ); console.log( '' ); await promptDoYouWantToProceed(); } @@ -403,10 +463,11 @@ async function promptDoYouWantToProceed() { output: process.stdout, } ); - const question = ( prompt ) => new Promise( ( resolve ) => rl.question( prompt, resolve ) ); + const question = ( prompt ) => + new Promise( ( resolve ) => rl.question( prompt, resolve ) ); do { const answer = await question( 'Do you want to proceed? (Y/n)' ); - if ( !answer || answer === 'Y' ) { + if ( ! answer || answer === 'Y' ) { break; } if ( answer === 'n' ) { diff --git a/bin/plugin/commands/changelog.js b/bin/plugin/commands/changelog.js index 7c8ad778d3cbf4..e6fb8c7054b16e 100644 --- a/bin/plugin/commands/changelog.js +++ b/bin/plugin/commands/changelog.js @@ -1,7 +1,7 @@ /** * External dependencies */ -const { groupBy, flow } = require( 'lodash' ); +const { groupBy } = require( 'lodash' ); const Octokit = require( '@octokit/rest' ); const { sprintf } = require( 'sprintf-js' ); const semver = require( 'semver' ); @@ -185,6 +185,21 @@ const REWORD_TERMS = { docs: 'documentation', }; +/** + * Creates a pipe function. Performs left-to-right function composition, where + * each successive invocation is supplied the return value of the previous. + * + * @param {Function[]} functions Functions to pipe. + */ +function pipe( functions ) { + return ( /** @type {unknown[]} */ ...args ) => { + return functions.reduce( + ( prev, func ) => [ func( ...prev ) ], + args + )[ 0 ]; + }; +} + /** * Escapes the RegExp special characters. * @@ -880,7 +895,7 @@ function skipCreatedByBots( pullRequests ) { * @return {string} The formatted props section. */ function getContributorProps( pullRequests ) { - const contributorsList = flow( [ + const contributorsList = pipe( [ skipCreatedByBots, getFirstTimeContributorPRs, getUniqueByUsername, @@ -920,7 +935,7 @@ function getContributorsMarkdownList( pullRequests ) { * @return {string} The formatted contributors section. */ function getContributorsList( pullRequests ) { - const contributorsList = flow( [ + const contributorsList = pipe( [ skipCreatedByBots, getUniqueByUsername, sortByUsername, diff --git a/changelog.txt b/changelog.txt index c3638cd1a13d75..4399018de22861 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,202 @@ == Changelog == += 14.2.0 = + +## Changelog + +### Deprecations + +- Officially deprecate the `children` and `node` block attribute sources. ([44265](https://github.com/WordPress/gutenberg/pull/44265)) + +### Enhancements + +#### Components + +- Link/Unlink buttons: Add more polished styling and UX. ([43802](https://github.com/WordPress/gutenberg/pull/43802)) + +#### Global Styles + +- Allow setting Letter case and Decoration to 'None' and add Letter case to Global Styles. ([44067](https://github.com/WordPress/gutenberg/pull/44067)) +- Block supports: Allow overriding prettify options for enqueued CSS. ([44248](https://github.com/WordPress/gutenberg/pull/44248)) +- Show Letter Spacing in Global Styles -> Typography -> Headings. ([44142](https://github.com/WordPress/gutenberg/pull/44142)) +- Spacing presets: Switch to using numbers instead of t-shirt sizes for labels. ([44247](https://github.com/WordPress/gutenberg/pull/44247)) + +#### Block Editor + +- Hide all floating block UI when typing. ([44083](https://github.com/WordPress/gutenberg/pull/44083)) +- Update animation for line and sibling inserter. ([44269](https://github.com/WordPress/gutenberg/pull/44269)) +- Warning component: Align the action buttons. ([44328](https://github.com/WordPress/gutenberg/pull/44328)) + +#### Block Library + +- Calendar block: Add color supports and polish styles. ([42029](https://github.com/WordPress/gutenberg/pull/42029)) +- Embed block: Mark which attributes should be considered content. ([44039](https://github.com/WordPress/gutenberg/pull/44039)) +- List Item block: Improve writing flow. ([43954](https://github.com/WordPress/gutenberg/pull/43954)) +- Post Navigation Link block: Add arrows. ([40684](https://github.com/WordPress/gutenberg/pull/40684)) +- Query Loop block: Suggest active variation patterns. + ([44197](https://github.com/WordPress/gutenberg/pull/44197)) + +#### Patterns + +- Add a new category for block patterns called "Banners". ([44203](https://github.com/WordPress/gutenberg/pull/44203)) +- Add a new category for block patterns called "Footers". + ([44200](https://github.com/WordPress/gutenberg/pull/44200)) + +### Bug Fixes + +#### Components + +- BlockMover: Clean up focus styles. ([44241](https://github.com/WordPress/gutenberg/pull/44241)) +- Fix animation running in loop while dragging an image on a dropzone. ([44264](https://github.com/WordPress/gutenberg/pull/44264)) +- Fix popover stacking in the customize widgets editor. ([44282](https://github.com/WordPress/gutenberg/pull/44282)) +- Link autocompleter: Enable for all blocks. ([44078](https://github.com/WordPress/gutenberg/pull/44078)) +- Remove unexpected `has-text` class when empty children are passed to `Button`. ([44198](https://github.com/WordPress/gutenberg/pull/44198)) + +#### Block Library + +##### Bugfixes to blocks: + +- Cover block: Fix preview. ([44321](https://github.com/WordPress/gutenberg/pull/44321)) +- Freeform block: Hide "Convert to blocks" when the block locked. + ([44288](https://github.com/WordPress/gutenberg/pull/44288)) +- Group block: Change default variation in inserter. ([44176](https://github.com/WordPress/gutenberg/pull/44176)) +- Missing block: Fix HTML block check. ([44327](https://github.com/WordPress/gutenberg/pull/44327)) +- Navigation block: Fix submenu colors for imported classic menus. + ([44283](https://github.com/WordPress/gutenberg/pull/44283)) +- Navigation block: Remove ellipses as menu icon options for now. ([44222](https://github.com/WordPress/gutenberg/pull/44222)) +- Pullquote block: Fix transform to quote crash. + ([44315](https://github.com/WordPress/gutenberg/pull/44315)) +- Post Featured Image block: Fix height/scale overwriting `img` inline styles. ([44213](https://github.com/WordPress/gutenberg/pull/44213)) +- Query Loop block: Fix broken preview in specific category template. ([44294](https://github.com/WordPress/gutenberg/pull/44294)) +- Query Loop block: Fix variation with declared`icon` object with `src`. + ([44270](https://github.com/WordPress/gutenberg/pull/44270)) + +##### Other bugfixes not related to a specific block: + +- Avoid showing the recursion warning in previews when replacing template parts. ([44256](https://github.com/WordPress/gutenberg/pull/44256)) +- Ensure replacing a template part using a pattern doesn't update the existing +- Fix demo content cover block text color. ([44174](https://github.com/WordPress/gutenberg/pull/44174)) + entity. ([44221](https://github.com/WordPress/gutenberg/pull/44221)) +- Add style-engine to Gutenberg's `tsconfig.json` references. ([44516](https://github.com/WordPress/gutenberg/pull/44516)) + +#### Block Editor + +- Block Toolbar: Update position when moving blocks. ([44301](https://github.com/WordPress/gutenberg/pull/44301)) +- Capture state changes scheduled between render and effect. ([38509](https://github.com/WordPress/gutenberg/pull/38509)) +- Writing flow: Fix partial selection when dragging in between blocks. ([44230](https://github.com/WordPress/gutenberg/pull/44230)) + +#### Accessibility + +- Add `role=application` to List View to prevent browse mode triggering in NVDA. ([44291](https://github.com/WordPress/gutenberg/pull/44291)) +- Block label and title don't consider variations. ([44250](https://github.com/WordPress/gutenberg/pull/44250)) +- Make inline toolbar navigable by arrow keys. ([43645](https://github.com/WordPress/gutenberg/pull/43645)) +- Text Selection in Safari: Try new fix for recent version. ([44148](https://github.com/WordPress/gutenberg/pull/44148)) + +#### Global Styles + +- Add: Frontend section presets output. ([42124](https://github.com/WordPress/gutenberg/pull/42124)) +- Block supports: Prioritize prettify options over `SCRIPT_DEBUG`. ([44254](https://github.com/WordPress/gutenberg/pull/44254)) +- Fix padding on the post editor when `RootPaddingAwareAlignments` setting is enabled. ([44209](https://github.com/WordPress/gutenberg/pull/44209)) +- Remove the beta label from global styles header. ([44251](https://github.com/WordPress/gutenberg/pull/44251)) +- Theme.json: Fix some outline properties that don't work properly in the editor. ([44504](https://github.com/WordPress/gutenberg/pull/44504)) + +#### Site Editor + +- Make template names and descriptions dynamic. ([43862](https://github.com/WordPress/gutenberg/pull/43862)) +- Prevent editor from creating multiple templates when multi-clicking the + "create" button. ([44146](https://github.com/WordPress/gutenberg/pull/44146)) + +#### Keycodes + +- Keyboard Shortcuts: Fix settings sidebar toggle shortcut. ([43428](https://github.com/WordPress/gutenberg/pull/43428)) + +### Performance + +- Avoid calling redux actions constantly when moving the mouse or scrolling. ([44325](https://github.com/WordPress/gutenberg/pull/44325)) +- Batch container block settings action calls. ([43958](https://github.com/WordPress/gutenberg/pull/43958)) +- Prevent resize observer loops in BlockPreview. ([44319](https://github.com/WordPress/gutenberg/pull/44319)) + +### Documentation + +- Include information about multiple scripts support added in WP 6.1. ([44155](https://github.com/WordPress/gutenberg/pull/44155)) +- Popover: Write better documentation regarding the recent API changes. ([44195](https://github.com/WordPress/gutenberg/pull/44195)) +- RangeControl component: Clarify rail vs track difference. ([44057](https://github.com/WordPress/gutenberg/pull/44057)) +- Update links in the Development Platform document. ([44181](https://github.com/WordPress/gutenberg/pull/44181)) +- Updating versions in WP for 6.0.2. ([43826](https://github.com/WordPress/gutenberg/pull/43826)) + +### Code Quality + +- Backport template creation changes from Core. +- Enforce coding guideline regarding the usage of quotation marks. ([44305](https://github.com/WordPress/gutenberg/pull/44305)) +- Fix missing TS types for a11y in `packages/components`. ([44277](https://github.com/WordPress/gutenberg/pull/44277)) +- Preferences-persistence: Remove `@wordpress/compose` dependency. ([44202](https://github.com/WordPress/gutenberg/pull/44202)) +- Refactor style engine border styles. ([43594](https://github.com/WordPress/gutenberg/pull/43594)) +- Removes whitespace from l18n-strings. ([44314](https://github.com/WordPress/gutenberg/pull/44314)) +- Renaming functions to match their naming in Core. ([44266](https://github.com/WordPress/gutenberg/pull/44266)) + ([44299](https://github.com/WordPress/gutenberg/pull/44299)) +- `useInstanceId`: Convert to typescript. ([43790](https://github.com/WordPress/gutenberg/pull/43790)) + +#### Updating dependencies + +- Update `fast-average-color` library to latest version. ([44175](https://github.com/WordPress/gutenberg/pull/44175)) +- Update `json2php` to `v0.0.5`. ([44313](https://github.com/WordPress/gutenberg/pull/44313)) +- Remove `wp-editor` from `wp-edit-blocks` dependencies. ([33496](https://github.com/WordPress/gutenberg/pull/33496)) + +#### Block Library + +- Comments Pagination blocks: Rename for consistency. ([44287](https://github.com/WordPress/gutenberg/pull/44287)) +- Embed block: Remove incorrect comments about block variations. ([43834](https://github.com/WordPress/gutenberg/pull/43834)) + +#### Components + +- Refactor `NavigationMenu` to ignore `exhaustive-deps`. ([44090](https://github.com/WordPress/gutenberg/pull/44090)) +- Refactor `RangeControl` to pass `exhaustive-deps`. + ([44271](https://github.com/WordPress/gutenberg/pull/44271)) +- Popover: Add `anchor` prop which supersedes all previous anchor-related props. ([43691](https://github.com/WordPress/gutenberg/pull/43691)) +- UnitControl: Fix exhaustive-deps warnings. ([44161](https://github.com/WordPress/gutenberg/pull/44161)) +- UnitControl: Use `hideHTMLArrows` prop to hide spin buttons. ([43985](https://github.com/WordPress/gutenberg/pull/43985)) +- `useSetting`: Minor refactor. ([44267](https://github.com/WordPress/gutenberg/pull/44267)) + +#### Refactoring tests to use `@testing-library/react` + +- Refactor `BlockControls` tests to `@testing-library/react`. ([44122](https://github.com/WordPress/gutenberg/pull/44122)) +- Refactor `ColorPalette` tests to `@testing-library/react`. ([44108](https://github.com/WordPress/gutenberg/pull/44108)) +- Refactor `KeyboardShortcutHelpModal` tests to `@testing-library/react`. + ([44077](https://github.com/WordPress/gutenberg/pull/44077)) + +#### Work to remove dependency on `lodash` + +- Compose: Introduce in-house `compose` and `pipe` utils. ([44112](https://github.com/WordPress/gutenberg/pull/44112)) +- Compose: Introduce in-house `debounce()` utility, deprecate Lodash version. ([43943](https://github.com/WordPress/gutenberg/pull/43943)) +- Lodash: Remove dependency from `@wordpress/widgets` package. ([44201](https://github.com/WordPress/gutenberg/pull/44201)) +- Lodash: Refactor away from `_.flowRight()`. + ([44188](https://github.com/WordPress/gutenberg/pull/44188)) + +#### Testing + +- Storybook: Set up local visual regression testing. + ([43393](https://github.com/WordPress/gutenberg/pull/43393)) + +#### Tooling + +- Ensure all packages get minor/major version bump when requested. ([44317](https://github.com/WordPress/gutenberg/pull/44317)) + +## First time contributors + +The following PRs were merged by first time contributors: + +- @kkoppenhaver: Update RangeControl documentation to clarify rail vs track. ([44057](https://github.com/WordPress/gutenberg/pull/44057)) +- @pagelab: Enforce coding guideline regarding the usage of quotation marks. ([44305](https://github.com/WordPress/gutenberg/pull/44305)) + +## Contributors + +The following contributors merged PRs in this release: + +@aaronrobertshaw @ajlende @annezazu @apmatthews @aristath @c4rl0sbr4v0 @carolinan @chad1008 @ciampo @dcalhoun @draganescu @ellatrix @geriux @glendaviesnz @gziolo @jasmussen @jorgefilipecosta @kebbet @kkoppenhaver @Mamaduka @matiasbenedetto @mcsf @michalczaplinski @mirka @mtias @noisysocks @ntsekouras @pagelab @ramonjd @t-hamano @talldan @tellthemachines @tyxla @walbo @youknowriad + + + + = 14.1.1 = diff --git a/composer.json b/composer.json index b91bb973ade417..3f16ba495a94ca 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ "require-dev": { "dealerdirect/phpcodesniffer-composer-installer": "^0.7", "squizlabs/php_codesniffer": "^3.5", - "phpcompatibility/php-compatibility": "^9.3", + "phpcompatibility/phpcompatibility-wp": "^2.1.3", "wp-coding-standards/wpcs": "^2.2", "sirbrillig/phpcs-variable-analysis": "^2.8", "spatie/phpunit-watcher": "^1.23", diff --git a/docs/contributors/code/testing-overview.md b/docs/contributors/code/testing-overview.md index 85e4ff082e92af..fa17909b4cd376 100644 --- a/docs/contributors/code/testing-overview.md +++ b/docs/contributors/code/testing-overview.md @@ -21,7 +21,7 @@ When writing tests consider the following: Tests for JavaScript use [Jest](https://jestjs.io/) as the test runner and its API for [globals](https://jestjs.io/docs/en/api.html) (`describe`, `test`, `beforeEach` and so on) [assertions](https://jestjs.io/docs/en/expect.html), [mocks](https://jestjs.io/docs/en/mock-functions.html), [spies](https://jestjs.io/docs/en/jest-object.html#jestspyonobject-methodname) and [mock functions](https://jestjs.io/docs/en/mock-function-api.html). If needed, you can also use [React Testing Library](https://testing-library.com/docs/react-testing-library/intro) for React component testing. -_It should be noted that in the past, React components were unit tested with [Enzyme](https://github.com/airbnb/enzyme). However, React Testing Library (RTL) should be used for new tests instead, and over time old tests should be refactored to use RTL too (typically when working on code that touches an old test)._ +_It should be noted that in the past, React components were unit tested with [Enzyme](https://github.com/airbnb/enzyme). However, React Testing Library (RTL) is now used for all existing and new tests instead._ Assuming you've followed the [instructions](/docs/contributors/code/getting-started-with-code-contribution.md) to install Node and project dependencies, tests can be run from the command-line with NPM: diff --git a/docs/getting-started/create-block/author-experience.md b/docs/getting-started/create-block/author-experience.md index 140238eaff0f99..a52ea808f69658 100644 --- a/docs/getting-started/create-block/author-experience.md +++ b/docs/getting-started/create-block/author-experience.md @@ -79,6 +79,7 @@ All of this combined together, here's what the edit function looks like: ```jsx import { Placeholder, TextControl } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; +import { useBlockProps } from "@wordpress/block-editor"; export default function Edit( { attributes, isSelected, setAttributes } ) { return ( diff --git a/docs/how-to-guides/block-tutorial/extending-the-query-loop-block.md b/docs/how-to-guides/block-tutorial/extending-the-query-loop-block.md new file mode 100644 index 00000000000000..dbe21ff35aa339 --- /dev/null +++ b/docs/how-to-guides/block-tutorial/extending-the-query-loop-block.md @@ -0,0 +1,263 @@ +# Extending the Query Loop block + +The Query Loop block is a powerful tool that allows users to cycle through a determined list of posts and display a certain set of blocks that will inherit the context of each of the posts in the list. For example, it can be set to cycle through all the posts of a certain category and for each of those posts display their featured image. And much more, of course! + +But precisely because the Query Loop block is so powerful and allows for great customization, it can also be daunting. Most users wouldn't want to be presented with the full capabilities of the Query Loop block, as most users wouldn't be familiar with the concept of a “query” and its associated technical terms. Instead, most users will likely appreciate a pre-set version of the block, with fewer settings to adjust and clearer naming. The Post List variation offered by default is a good example of this practice: the user will be using the Query Loop block without being exposed to its technicalities, and will also be more likely to discover and understand the purpose of the block. + +In the same manner, a lot of extenders might need a way to present bespoke versions of the block, with their own presets, additional settings and without customization options which are irrelevant to their use-case (often, for example, their custom post type). The Query Loop block offers very powerful ways to create such variations. + +## Extending the block with variations + +By registering your own block variation with some specific Query Loop block settings, you can have finer control over how it is presented, while still being able to use the full capabilities which the Query Loop block offers underneath. If you are not familiar with block variations, learn more about them [here](/docs/reference-guides/block-api/block-variations.md). + +With the block variations API you can provide the default settings that make the most sense for your use-case. + +Let's go on a journey, for example, of setting up a variation for a plugin which registers a `book` [custom post type](https://developer.wordpress.org/plugins/post-types/). + +### Offer sensible defaults + +Your first step would be to create a variation which will be set up in such a way to provide a block variation which will display by default a list of books instead of blog posts. The full variation code will look something like this: + +```js +const MY_VARIATION_NAME = 'my-plugin/books-list'; + +registerBlockVariation( 'core/query', { + name: MY_VARIATION_NAME, + title: 'Books List', + description: 'Displays a list of books', + isActive: ( { namespace, query } ) => { + return ( + namespace === MY_VARIATION_NAME + && query.postType === 'book' + ); + }, + icon: /** An SVG icon can go here*/, + attributes: { + namespace: MY_VARIATION_NAME, + query: { + perPage: 6, + pages: 0, + offset: 0, + postType: 'book', + order: 'desc', + orderBy: 'date', + author: '', + search: '', + exclude: [], + sticky: '', + inherit: false, + }, + }, + scope: [ 'inserter' ], + } +); +``` + +If that sounds like a lot, don't fret, let's go through each of the properties here and see why they are there and what they are doing. + +Essentially, you would start with something like this: + +```js +registerBlockVariation( 'core/query', { + name: 'my-plugin/books-list', + attributes: { + query: { + /** ...more query settings if needed */ + postType: 'book', + }, + }, +} ); +``` + +In this way, the users won't have to choose the custom `postType` from the dropdown, and be already presented with the correct configuration. However, you might ask, how is a user going to find and insert this variation? Good question! To enable this, you should add: + +```js +{ + /** ...variation properties */ + scope: [ 'inserter' ], +} +``` + +In this way, your block will show up just like any other block while the user is in the editor and searching for it. At this point you might also want to add a custom icon, title and description to your variation, just like so: + +```js +{ + /** ...variation properties */ + title: 'Books List', + description: 'Displays a list of books', + icon: /* Your svg icon here */, +} +``` + +At this point, your custom variation will be virtually indistinguishable from a stand-alone block. Completely branded to your plugin, easy to discover and directly available to the user as a drop in. + +### Customize your variation layout + +Please note that the Query Loop block supports `'block'` as a string in the `scope` property. In theory, that's to allow the variation to be picked up after inserting the block itself. Read more about the Block Variation Picker [here](https://github.com/WordPress/gutenberg/blob/HEAD/packages/block-editor/src/components/block-variation-picker/README.md). + +However, it is unadvisable to use this currently, this is due to the Query Loop setup with patterns and `scope: [ 'block' ]` variations, all of the selected pattern's attributes will be used except for `postType` and `inherit` query properties, which will likely lead to conflicts and non-functional variations. + +To circumvent this, there two routes, the first one is to add your default `innerBlocks`, like so: + +```js +innerBlocks: [ + [ + 'core/post-template', + {}, + [ [ 'core/post-title' ], [ 'core/post-excerpt' ] ], + ], + [ 'core/query-pagination' ], + [ 'core/query-no-results' ], +], +``` + +The other would be to register patterns specific to your variation, we can cover that in another guide. + +### Making Gutenberg recognize your variation + +There is one slight problem you might have realized after implementing this variation: while it is transparent to the user as they are inserting it, Gutenberg will still recognize the variation as a Query Loop block at its core and so, after its insertion, it will show up as a Query Loop block in the tree view of the editor, for instance. + +We need a way to tell the editor that this block is indeed your specific variation. This is what the `isActive` property is made for: it's a way to determine whether a certain variation is active based on the block's attributes. You could use it like this: + +```js +{ + /** ...variation properties */ + isActive: ( { namespace, query } ) => { + return ( + namespace === MY_VARIATION_NAME + && query.postType === 'book' + ); + }, +} +``` + +You might have been tempted to only compare the `postType`: in this way, Gutenberg will recognize the block as your variation any time the `postType` matches `book`! That's awesome, but the problem is that now Gutenberg will recognize the block as your specific variation any time the `postType` is set to `book`, which is not what we want: other plugins might want to publish variations based on the `book` post type, or we might just not want the variation to be recognized every time the user sets the type to `book` manually through the editor settings. + +That's why the Query Loop block exposes a special attribute called `namespace`: it really doesn't do anything inside the block implementation, and it's used as an easy way for extenders to recognize and scope their own variation. In addition, `isActive` also accepts just an array of strings with the attributes to compare. Often, `namespace` would be sufficient, so you would use it like so: + +```js +{ + /** ...variation properties */ + attributes: { + /** ...variation attributes */ + namespace: 'my-plugin/books-list', + }, + isActive: [ 'namespace' ], +} +``` + +Like so, Gutenberg will know that it is your specific variation only in the case it matches your custom namespace! So convenient! + +## Extending the query + +Even with all of this, your custom post type might have unique requirements: it might support certain custom attributes that you might want to filter and query for, or some other query parameters might be irrelevant or even completely unsupported! We have build the Query Loop block with such use-cases in mind, so let's see how you can solve this problem. + +### Disabling irrelevant or unsupported query controls + +Let's say you don't use at all the `sticky` attribute in your books, so that would be totally irrelevant to the customization of your block. In order to not confuse the users as to what a setting might do, and only exposing a clear UX to them, we want this control to be unavailable. Furthermore, let's say that you don't use the `author` field at all, which generally indicates the person who has added that post to the database, instead you use a custom `bookAuthor` field. As such, not only keeping the `author` filter would be confusing, it would outright break your query. + +For this reason, the Query Loop block supports a property called `allowedControls` which accepts an array of keys of the controls we want to display on the inspector sidebar. By default, we accept all the controls, but as soon as we provide an array to this property, we want to be specific and specify only the controls which are going to be relevant for us! + +As of version 13.9, the following controls are available: + +- `inherit` - Shows the toggle switch for allowing the query to be inherited directly from the template. +- `postType` - Shows a dropdown of available post types. +- `order` - Shows a dropdown to select the order of the query. +- `sticky` - Shows a checkbox to only display sticky posts. +- `taxQuery` - Shows available taxonomies filters for the currently selected post type. +- `author` - Shows an input field to filter the query by author. +- `search` - Shows an input filed to filter the query by keywords. + +In our case, the property would look like this: + +```js +{ + /** ...variation properties */ + allowedControls: [ 'inherit', 'order', 'taxQuery', 'search' ], +} +``` + +Notice that we have also disabled the `postType` control: when the user selects our variation, why show them a confusing dropdown to change the post type? On top of that it might break the block as we can implement custom controls, as we'll see shortly. + +### Adding additional controls + +Because our plugin uses custom attributes that we need to query, we want to add our own controls to allow the users to select those instead of the ones we have just disabled from the core inspector controls. We can do this via a [React HOC](https://reactjs.org/docs/higher-order-components.html) hooked into a [block filter](https://developer.wordpress.org/block-editor/reference-guides/filters/block-filters/), like so: + +```jsx +export const withBookQueryControls = ( BlockEdit ) => ( props ) => { + // We only want to add these controls if it is our variation, + // so here we can implement a custom logic to check for that, similiar + // to the `isActive` function described above. + // The following assumes that you wrote a custom `isMyBooksVariation` + // function to handle that. + return isMyBooksVariation( props ) ? ( + <> + + + { /** Our custom component */ } + + + ) : ( + + ); +}; + +addFilter( 'editor.BlockEdit', 'core/query', withBookQueryControls ); +``` + +Of course, you'll be responsible for implementing the logic of your control (you might want to take a look at [`@wordpress/components`](https://www.npmjs.com/package/@wordpress/components) to make your controls fit seamlessly within the Gutenberg UI). Any extra parameter you assign within the `query` object inside the blocks attributes can be used to create a custom query according to your needs, with a little extra effort. + +Currently, you'll likely have to implement slightly different paths to make the query behave correctly both on the front-end side (i.e. on the end user's side) and to show the correct preview on the editor side. + +```js +{ + /** ...variation properties */ + attributes: { + /** ...variation attributes */ + query: { + /** ...more query settings if needed */ + postType: 'book', + /** Our custom query parameter */ + bookAuthor: 'J. R. R. Tolkien' + } + } +} +``` + +### Making your custom query work on the front-end side + +The Query Loop block functions mainly through the Post Template block which receives the attributes and builds the query from there. Other first-class children of the Query Loop block (such as the Pagination block) behave in the same way. They build their query and then expose the result via the filter [`query_loop_block_query_vars`](https://developer.wordpress.org/reference/hooks/query_loop_block_query_vars/). + +You can hook into that filter and modify your query accordingly. Just make sure you don't cause side-effects to other Query Loop blocks by at least checking that you apply the filter only to your variation! + +```php +if( 'my-plugin/books-list' === $block[ 'attrs' ][ 'namespace' ] ) { + add_filter( + 'query_loop_block_query_vars', + function( $query ) { + /** You can read your block custom query parameters here and build your query */ + }, + ); +} +``` + +(In the code above, we assume you have some way to access the block, for example within a [`pre_render_block`](https://developer.wordpress.org/reference/hooks/pre_render_block/) filter, but the specific solution can be different depending on the use-case, so this is not a firm recommendation). + +### Making your custom query work on the editor side + +To finish up our custom variation, we might want the editor to react to changes in our custom query and display an appropriate preview accordingly. This is not required for a functioning block, but it enables a fully integrated user experience for the consumers of your block. + +The Query Loop block fetches its posts to show the preview using the [WordPress REST API](https://developer.wordpress.org/rest-api/). Any extra parameter added to the `query` object will be passed as a query argument to the API. This means that these extra parameters should be either supported by the REST API, or be handled by custom filters such as the [`rest_{$this->post_type}_query`](https://developer.wordpress.org/reference/hooks/rest_this-post_type_query/) filter which allows you to hook into any API request for your custom post type. Like so: + +```php +add_filter( + 'rest_book_query', + function( $args, $request ) { + /** We can access our custom parameters from here */ + $book_author = $request->get_param( 'bookAuthor' ); + /** ...your custom query logic */ + } +); +``` + +And, just like that, you'll have created a fully functional variation of the Query Loop block! diff --git a/docs/manifest.json b/docs/manifest.json index 69d991c65d3da5..fa66c97044e1c4 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1482,7 +1482,7 @@ "parent": "packages" }, { - "title": "@wordpress/create-block External Template", + "title": "External Project Templates", "slug": "packages-create-block-external-template", "markdown_source": "../packages/create-block/docs/external-template.md", "parent": "packages-create-block" @@ -1607,6 +1607,12 @@ "markdown_source": "../packages/eslint-plugin/README.md", "parent": "packages" }, + { + "title": "@wordpress/experiments", + "slug": "packages-experiments", + "markdown_source": "../packages/experiments/README.md", + "parent": "packages" + }, { "title": "@wordpress/format-library", "slug": "packages-format-library", @@ -1829,6 +1835,12 @@ "markdown_source": "../packages/style-engine/README.md", "parent": "packages" }, + { + "title": "@wordpress/style-engine Using the Style Engine to generate block supports styles", + "slug": "using-the-style-engine-with-block-supports", + "markdown_source": "../packages/style-engine/docs/using-the-style-engine-with-block-supports.md", + "parent": "packages-style-engine" + }, { "title": "@wordpress/stylelint-config", "slug": "packages-stylelint-config", diff --git a/docs/reference-guides/block-api/block-metadata.md b/docs/reference-guides/block-api/block-metadata.md index e835db50c5e286..6ce54d8009dd75 100644 --- a/docs/reference-guides/block-api/block-metadata.md +++ b/docs/reference-guides/block-api/block-metadata.md @@ -473,7 +473,9 @@ See the [the variations documentation](/docs/reference-guides/block-api/block-va { "editorScript": "file:./index.js" } ``` -Block type editor script definition. It will only be enqueued in the context of the editor. +Block type editor scripts definition. They will only be enqueued in the context of the editor. + +It's possible to pass a script handle registered with the [`wp_register_script`](https://developer.wordpress.org/reference/functions/wp_register_script/) function, a path to a JavaScript file relative to the `block.json` file, or a list with a mix of both ([learn more](#wpdefinedasset)). _Note: An option to pass also an array of editor scripts exists since WordPress `6.1.0`._ @@ -488,7 +490,9 @@ _Note: An option to pass also an array of editor scripts exists since WordPress { "script": "file:./script.js" } ``` -Block type frontend and editor script definition. It will be enqueued both in the editor and when viewing the content on the front of the site. +Block type frontend and editor scripts definition. They will be enqueued both in the editor and when viewing the content on the front of the site. + +It's possible to pass a script handle registered with the [`wp_register_script`](https://developer.wordpress.org/reference/functions/wp_register_script/) function, a path to a JavaScript file relative to the `block.json` file, or a list with a mix of both ([learn more](#wpdefinedasset)). _Note: An option to pass also an array of scripts exists since WordPress `6.1.0`._ @@ -504,7 +508,9 @@ _Note: An option to pass also an array of scripts exists since WordPress `6.1.0` { "viewScript": [ "file:./view.js", "example-shared-view-script" ] } ``` -Block type frontend script definition. It will be enqueued only when viewing the content on the front of the site. +Block type frontend scripts definition. They will be enqueued only when viewing the content on the front of the site. + +It's possible to pass a script handle registered with the [`wp_register_script`](https://developer.wordpress.org/reference/functions/wp_register_script/) function, a path to a JavaScript file relative to the `block.json` file, or a list with a mix of both ([learn more](#wpdefinedasset)). _Note: An option to pass also an array of view scripts exists since WordPress `6.1.0`._ @@ -519,7 +525,9 @@ _Note: An option to pass also an array of view scripts exists since WordPress `6 { "editorStyle": "file:./index.css" } ``` -Block type editor style definition. It will only be enqueued in the context of the editor. +Block type editor styles definition. They will only be enqueued in the context of the editor. + +It's possible to pass a style handle registered with the [`wp_register_style`](https://developer.wordpress.org/reference/functions/wp_register_style/) function, a path to a CSS file relative to the `block.json` file, or a list with a mix of both ([learn more](#wpdefinedasset)). _Note: An option to pass also an array of editor styles exists since WordPress `5.9.0`._ @@ -534,7 +542,9 @@ _Note: An option to pass also an array of editor styles exists since WordPress ` { "style": [ "file:./style.css", "example-shared-style" ] } ``` -Block type frontend and editor style definition. It will be enqueued both in the editor and when viewing the content on the front of the site. +Block type frontend and editor styles definition. They will be enqueued both in the editor and when viewing the content on the front of the site. + +It's possible to pass a style handle registered with the [`wp_register_style`](https://developer.wordpress.org/reference/functions/wp_register_style/) function, a path to a CSS file relative to the `block.json` file, or a list with a mix of both ([learn more](#wpdefinedasset)). _Note: An option to pass also an array of styles exists since WordPress `5.9.0`._ diff --git a/docs/reference-guides/core-blocks.md b/docs/reference-guides/core-blocks.md index 745bd600aa7ff0..148988bbfcc3e8 100644 --- a/docs/reference-guides/core-blocks.md +++ b/docs/reference-guides/core-blocks.md @@ -203,7 +203,7 @@ Displays a list of page numbers for comments pagination. ([Source](https://githu - **Name:** core/comments-pagination-numbers - **Category:** theme -- **Supports:** typography (fontSize, lineHeight), ~~html~~, ~~reusable~~ +- **Supports:** color (background, gradients, ~~text~~), typography (fontSize, lineHeight), ~~html~~, ~~reusable~~ - **Attributes:** ## Comments Previous Page @@ -456,7 +456,7 @@ Display post author details such as name, avatar, and bio. ([Source](https://git - **Name:** core/post-author - **Category:** theme - **Supports:** color (background, gradients, link, text), spacing (margin, padding), typography (fontSize, lineHeight), ~~html~~ -- **Attributes:** avatarSize, byline, showAvatar, showBio, textAlign +- **Attributes:** avatarSize, byline, isLink, linkTarget, showAvatar, showBio, textAlign ## Post Author Biography @@ -572,7 +572,7 @@ Post terms. ([Source](https://github.com/WordPress/gutenberg/tree/trunk/packages - **Name:** core/post-terms - **Category:** theme -- **Supports:** color (background, gradients, link, text), typography (fontSize, lineHeight), ~~html~~ +- **Supports:** color (background, gradients, link, text), spacing (margin, padding), typography (fontSize, lineHeight), ~~html~~ - **Attributes:** prefix, separator, suffix, term, textAlign ## Post Title diff --git a/docs/reference-guides/data/data-core-annotations.md b/docs/reference-guides/data/data-core-annotations.md index f1d1d6918e5c5d..da86bf4b0dcf56 100644 --- a/docs/reference-guides/data/data-core-annotations.md +++ b/docs/reference-guides/data/data-core-annotations.md @@ -2,6 +2,10 @@ Namespace: `core/annotations`. +
+This package is still experimental. “Experimental” means this is an early implementation subject to drastic and breaking changes. +
+ ## Selectors diff --git a/docs/reference-guides/data/data-core-reusable-blocks.md b/docs/reference-guides/data/data-core-reusable-blocks.md index 3eeea29bd1f182..728281d0e48f3b 100644 --- a/docs/reference-guides/data/data-core-reusable-blocks.md +++ b/docs/reference-guides/data/data-core-reusable-blocks.md @@ -2,6 +2,9 @@ Namespace: `core/reusable-blocks`. +
+This package is still experimental. “Experimental” means this is an early implementation subject to drastic and breaking changes. +
## Selectors diff --git a/docs/reference-guides/theme-json-reference/theme-json-living.md b/docs/reference-guides/theme-json-reference/theme-json-living.md index 3b3d40ffd73359..8f18ed7631b826 100644 --- a/docs/reference-guides/theme-json-reference/theme-json-living.md +++ b/docs/reference-guides/theme-json-reference/theme-json-living.md @@ -139,10 +139,10 @@ Border styles. | radius | undefined | | | style | string | | | width | string | | -| top | undefined | | -| right | undefined | | -| bottom | undefined | | -| left | undefined | | +| top | object | color, style, width | +| right | object | color, style, width | +| bottom | object | color, style, width | +| left | object | color, style, width | --- diff --git a/gutenberg.php b/gutenberg.php index 167705186e72b3..503a7b249ae8cb 100644 --- a/gutenberg.php +++ b/gutenberg.php @@ -5,7 +5,7 @@ * Description: Printing since 1440. This is the development plugin for the new block editor in core. * Requires at least: 5.9 * Requires PHP: 5.6 - * Version: 14.2.0-rc.1 + * Version: 14.3.0-rc.1 * Author: Gutenberg Team * Text Domain: gutenberg * diff --git a/lib/block-supports/duotone.php b/lib/block-supports/duotone.php index 5cb7fb7cfc5ac6..4a9a23d7b5af3f 100644 --- a/lib/block-supports/duotone.php +++ b/lib/block-supports/duotone.php @@ -83,10 +83,10 @@ function gutenberg_tinycolor_rgb_to_rgb( $rgb_color ) { */ function gutenberg_tinycolor_hue_to_rgb( $p, $q, $t ) { if ( $t < 0 ) { - $t += 1; + ++$t; } if ( $t > 1 ) { - $t -= 1; + --$t; } if ( $t < 1 / 6 ) { return $p + ( $q - $p ) * 6 * $t; diff --git a/lib/block-supports/layout.php b/lib/block-supports/layout.php index eaca6d4c70cc8c..15a7a131f8a9b0 100644 --- a/lib/block-supports/layout.php +++ b/lib/block-supports/layout.php @@ -28,25 +28,33 @@ function gutenberg_register_layout_support( $block_type ) { /** * Generates the CSS corresponding to the provided layout. * - * @param string $selector CSS selector. - * @param array $layout Layout object. The one that is passed has already checked the existence of default block layout. - * @param boolean $has_block_gap_support Whether the theme has support for the block gap. - * @param string $gap_value The block gap value to apply. - * @param boolean $should_skip_gap_serialization Whether to skip applying the user-defined value set in the editor. - * @param string $fallback_gap_value The block gap value to apply. - * @param array $block_spacing Custom spacing set on the block. - * - * @return string CSS style. + * @param string $selector CSS selector. + * @param array $layout Layout object. The one that is passed has already checked + * the existence of default block layout. + * @param bool $has_block_gap_support Optional. Whether the theme has support for the block gap. Default false. + * @param string|string[]|null $gap_value Optional. The block gap value to apply. Default null. + * @param bool $should_skip_gap_serialization Optional. Whether to skip applying the user-defined value set in the editor. Default false. + * @param string $fallback_gap_value Optional. The block gap value to apply. Default '0.5em'. + * @param array|null $block_spacing Optional. Custom spacing set on the block. Default null. + * @return string CSS styles on success. Else, empty string. */ function gutenberg_get_layout_style( $selector, $layout, $has_block_gap_support = false, $gap_value = null, $should_skip_gap_serialization = false, $fallback_gap_value = '0.5em', $block_spacing = null ) { $layout_type = isset( $layout['type'] ) ? $layout['type'] : 'default'; $layout_styles = array(); + if ( 'default' === $layout_type ) { if ( $has_block_gap_support ) { if ( is_array( $gap_value ) ) { $gap_value = isset( $gap_value['top'] ) ? $gap_value['top'] : null; } if ( null !== $gap_value && ! $should_skip_gap_serialization ) { + // Get spacing CSS variable from preset value if provided. + if ( is_string( $gap_value ) && str_contains( $gap_value, 'var:preset|spacing|' ) ) { + $index_to_splice = strrpos( $gap_value, '|' ) + 1; + $slug = _wp_to_kebab_case( substr( $gap_value, $index_to_splice ) ); + $gap_value = "var(--wp--preset--spacing--$slug)"; + } + array_push( $layout_styles, array( @@ -110,8 +118,10 @@ function gutenberg_get_layout_style( $selector, $layout, $has_block_gap_support ) ); - // Handle negative margins for alignfull children of blocks with custom padding set. - // They're added separately because padding might only be set on one side. + /* + * Handle negative margins for alignfull children of blocks with custom padding set. + * They're added separately because padding might only be set on one side. + */ if ( isset( $block_spacing_values['declarations']['padding-right'] ) ) { $padding_right = $block_spacing_values['declarations']['padding-right']; $layout_styles[] = array( @@ -225,7 +235,7 @@ function gutenberg_get_layout_style( $selector, $layout, $has_block_gap_support } if ( 'horizontal' === $layout_orientation ) { - /** + /* * Add this style only if is not empty for backwards compatibility, * since we intend to convert blocks that had flex layout implemented * by custom css. @@ -263,13 +273,17 @@ function gutenberg_get_layout_style( $selector, $layout, $has_block_gap_support } if ( ! empty( $layout_styles ) ) { - // Add to the style engine store to enqueue and render layout styles. - // Return compiled layout styles to retain backwards compatibility. - // Since https://github.com/WordPress/gutenberg/pull/42452 we no longer call wp_enqueue_block_support_styles in this block supports file. + /* + * Add to the style engine store to enqueue and render layout styles. + * Return compiled layout styles to retain backwards compatibility. + * Since https://github.com/WordPress/gutenberg/pull/42452, + * wp_enqueue_block_support_styles is no longer called in this block supports file. + */ return gutenberg_style_engine_get_stylesheet_from_css_rules( $layout_styles, array( - 'context' => 'block-supports', + 'context' => 'block-supports', + 'prettify' => false, ) ); } @@ -323,10 +337,12 @@ function gutenberg_render_layout_support_flag( $block_content, $block ) { $class_names[] = 'has-global-padding'; } - // The following section was added to reintroduce a small set of layout classnames that were - // removed in the 5.9 release (https://github.com/WordPress/gutenberg/issues/38719). It is - // not intended to provide an extended set of classes to match all block layout attributes - // here. + /* + * The following section was added to reintroduce a small set of layout classnames that were + * removed in the 5.9 release (https://github.com/WordPress/gutenberg/issues/38719). It is + * not intended to provide an extended set of classes to match all block layout attributes + * here. + */ if ( ! empty( $block['attrs']['layout']['orientation'] ) ) { $class_names[] = 'is-' . sanitize_title( $block['attrs']['layout']['orientation'] ); } @@ -350,14 +366,19 @@ function gutenberg_render_layout_support_flag( $block_content, $block ) { $class_names[] = sanitize_title( $layout_classname ); } - // Only generate Layout styles if the theme has not opted-out. - // Attribute-based Layout classnames are output in all cases. + /* + * Only generate Layout styles if the theme has not opted-out. + * Attribute-based Layout classnames are output in all cases. + */ if ( ! current_theme_supports( 'disable-layout-styles' ) ) { $gap_value = _wp_array_get( $block, array( 'attrs', 'style', 'spacing', 'blockGap' ) ); - // Skip if gap value contains unsupported characters. - // Regex for CSS value borrowed from `safecss_filter_attr`, and used here - // because we only want to match against the value, not the CSS attribute. + + /* + * Skip if gap value contains unsupported characters. + * Regex for CSS value borrowed from `safecss_filter_attr`, and used here + * to only match against the value, not the CSS attribute. + */ if ( is_array( $gap_value ) ) { foreach ( $gap_value as $key => $value ) { $gap_value[ $key ] = $value && preg_match( '%[\\\(&=}]|/\*%', $value ) ? null : $value; @@ -369,10 +390,21 @@ function gutenberg_render_layout_support_flag( $block_content, $block ) { $fallback_gap_value = _wp_array_get( $block_type->supports, array( 'spacing', 'blockGap', '__experimentalDefault' ), '0.5em' ); $block_spacing = _wp_array_get( $block, array( 'attrs', 'style', 'spacing' ), null ); - // If a block's block.json skips serialization for spacing or spacing.blockGap, - // don't apply the user-defined value to the styles. + /* + * If a block's block.json skips serialization for spacing or spacing.blockGap, + * don't apply the user-defined value to the styles. + */ $should_skip_gap_serialization = gutenberg_should_skip_block_supports_serialization( $block_type, 'spacing', 'blockGap' ); - $style = gutenberg_get_layout_style( ".$block_classname.$container_class", $used_layout, $has_block_gap_support, $gap_value, $should_skip_gap_serialization, $fallback_gap_value, $block_spacing ); + + $style = gutenberg_get_layout_style( + ".$block_classname.$container_class", + $used_layout, + $has_block_gap_support, + $gap_value, + $should_skip_gap_serialization, + $fallback_gap_value, + $block_spacing + ); // Only add container class and enqueue block support styles if unique styles were generated. if ( ! empty( $style ) ) { @@ -380,8 +412,10 @@ function gutenberg_render_layout_support_flag( $block_content, $block ) { } } - // This assumes the hook only applies to blocks with a single wrapper. - // I think this is a reasonable limitation for that particular hook. + /* + * This assumes the hook only applies to blocks with a single wrapper. + * A limitation of this hook is that nested inner blocks wrappers are not yet supported. + */ $content = preg_replace( '/' . preg_quote( 'class="', '/' ) . '/', 'class="' . esc_attr( implode( ' ', $class_names ) ) . ' ', @@ -422,7 +456,7 @@ function gutenberg_restore_group_inner_container( $block_content, $block ) { if ( WP_Theme_JSON_Resolver_Gutenberg::theme_has_support() || 1 === preg_match( $group_with_inner_container_regex, $block_content ) || - ( isset( $block['attrs']['layout']['type'] ) && 'default' !== $block['attrs']['layout']['type'] ) + ( isset( $block['attrs']['layout']['type'] ) && 'flex' === $block['attrs']['layout']['type'] ) ) { return $block_content; } diff --git a/lib/block-supports/settings.php b/lib/block-supports/settings.php new file mode 100644 index 00000000000000..60900629c04cf1 --- /dev/null +++ b/lib/block-supports/settings.php @@ -0,0 +1,151 @@ +get_registered( $block['blockName'] ); + if ( ! block_has_support( $block_type, array( '__experimentalSettings' ), false ) ) { + return $block_content; + } + + // return early if no settings are found on the block attributes. + $block_settings = _wp_array_get( $block, array( 'attrs', 'settings' ), null ); + if ( empty( $block_settings ) ) { + return $block_content; + } + + $class_name = _gutenberg_get_presets_class_name( $block ); + + // Like the layout hook this assumes the hook only applies to blocks with a single wrapper. + // Retrieve the opening tag of the first HTML element. + $html_element_matches = array(); + preg_match( '/<[^>]+>/', $block_content, $html_element_matches, PREG_OFFSET_CAPTURE ); + $first_element = $html_element_matches[0][0]; + // If the first HTML element has a class attribute just add the new class + // as we do on layout and duotone. + if ( strpos( $first_element, 'class="' ) !== false ) { + $content = preg_replace( + '/' . preg_quote( 'class="', '/' ) . '/', + 'class="' . $class_name . ' ', + $block_content, + 1 + ); + } else { + // If the first HTML element has no class attribute we should inject the attribute before the attribute at the end. + $first_element_offset = $html_element_matches[0][1]; + $content = substr_replace( $block_content, ' class="' . $class_name . '"', $first_element_offset + strlen( $first_element ) - 1, 0 ); + } + + return $content; +} + +/** + * Render the block level presets stylesheet. + * + * @access private + * + * @param string|null $pre_render The pre-rendered content. Default null. + * @param array $block The block being rendered. + * + * @return null + */ +function _gutenberg_add_block_level_preset_styles( $pre_render, $block ) { + // Return early if the block has not support for descendent block styles. + $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block['blockName'] ); + if ( ! block_has_support( $block_type, array( '__experimentalSettings' ), false ) ) { + return null; + } + + // return early if no settings are found on the block attributes. + $block_settings = _wp_array_get( $block, array( 'attrs', 'settings' ), null ); + if ( empty( $block_settings ) ) { + return null; + } + + $class_name = '.' . _gutenberg_get_presets_class_name( $block ); + + // the root selector for preset variables needs to target every possible block selector + // in order for the general setting to override any bock specific setting of a parent block or + // the site root. + $variables_root_selector = '*,[class*="wp-block"]'; + $registry = WP_Block_Type_Registry::get_instance(); + $blocks = $registry->get_all_registered(); + foreach ( $blocks as $block_type ) { + if ( + isset( $block_type->supports['__experimentalSelector'] ) && + is_string( $block_type->supports['__experimentalSelector'] ) + ) { + $variables_root_selector .= ',' . $block_type->supports['__experimentalSelector']; + } + } + $variables_root_selector = WP_Theme_JSON_6_1::scope_selector( $class_name, $variables_root_selector ); + + // Remove any potentially unsafe styles. + $theme_json_shape = WP_Theme_JSON_Gutenberg::remove_insecure_properties( + array( + 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, + 'settings' => $block_settings, + ) + ); + $theme_json_object = new WP_Theme_JSON_Gutenberg( $theme_json_shape ); + + $styles = ''; + + // include preset css variables declaration on the stylesheet. + $styles .= $theme_json_object->get_stylesheet( + array( 'variables' ), + null, + array( + 'root_selector' => $variables_root_selector, + 'scope' => $class_name, + ) + ); + + // include preset css classes on the the stylesheet. + $styles .= $theme_json_object->get_stylesheet( + array( 'presets' ), + null, + array( + 'root_selector' => $class_name . ',' . $class_name . ' *', + 'scope' => $class_name, + ) + ); + + if ( ! empty( $styles ) ) { + gutenberg_enqueue_block_support_styles( $styles ); + } + + return null; +} + +add_filter( 'render_block', '_gutenberg_add_block_level_presets_class', 10, 2 ); +add_filter( 'pre_render_block', '_gutenberg_add_block_level_preset_styles', 10, 2 ); diff --git a/lib/client-assets.php b/lib/client-assets.php index b2e9fea929c5d2..905aef70cb4d29 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -203,7 +203,7 @@ function gutenberg_register_packages_scripts( $scripts ) { // Replace extension with `.asset.php` to find the generated dependencies file. $asset_file = substr( $path, 0, -( strlen( '.js' ) ) ) . '.asset.php'; $asset = file_exists( $asset_file ) - ? require( $asset_file ) + ? require $asset_file : null; $dependencies = isset( $asset['dependencies'] ) ? $asset['dependencies'] : array(); $version = isset( $asset['version'] ) ? $asset['version'] : $default_version; @@ -445,3 +445,96 @@ function gutenberg_register_packages_styles( $styles ) { $styles->add_data( 'wp-widgets', 'rtl', 'replace' ); } add_action( 'wp_default_styles', 'gutenberg_register_packages_styles' ); + +/** + * Fetches, processes and compiles stored core styles, then combines and renders them to the page. + * Styles are stored via the Style Engine API. + * + * This hook also exists, and should be backported to Core in future versions. + * However, it is envisaged that Gutenberg will continue to use the Style Engine's `gutenberg_*` functions and `_Gutenberg` classes to aid continuous development. + * + * See: https://developer.wordpress.org/block-editor/reference-guides/packages/packages-style-engine/ + * + * @param array $options { + * Optional. An array of options to pass to gutenberg_style_engine_get_stylesheet_from_context(). Default empty array. + * + * @type bool $optimize Whether to optimize the CSS output, e.g., combine rules. Default is `false`. + * @type bool $prettify Whether to add new lines and indents to output. Default is the test of whether the global constant `SCRIPT_DEBUG` is defined. + * } + * + * @since 6.1 + * + * @return void + */ +function gutenberg_enqueue_stored_styles( $options = array() ) { + $is_block_theme = wp_is_block_theme(); + $is_classic_theme = ! $is_block_theme; + + /* + * For block themes, print stored styles in the header. + * For classic themes, in the footer. + */ + if ( + ( $is_block_theme && doing_action( 'wp_footer' ) ) || + ( $is_classic_theme && doing_action( 'wp_enqueue_scripts' ) ) + ) { + return; + } + + $core_styles_keys = array( 'block-supports' ); + $compiled_core_stylesheet = ''; + $style_tag_id = 'core'; + foreach ( $core_styles_keys as $style_key ) { + // Adds comment if code is prettified to identify core styles sections in debugging. + $should_prettify = isset( $options['prettify'] ) ? true === $options['prettify'] : defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG; + if ( $should_prettify ) { + $compiled_core_stylesheet .= "/**\n * Core styles: $style_key\n */\n"; + } + // Chains core store ids to signify what the styles contain. + $style_tag_id .= '-' . $style_key; + $compiled_core_stylesheet .= gutenberg_style_engine_get_stylesheet_from_context( $style_key, $options ); + } + + // Combines Core styles. + if ( ! empty( $compiled_core_stylesheet ) ) { + wp_register_style( $style_tag_id, false, array(), true, true ); + wp_add_inline_style( $style_tag_id, $compiled_core_stylesheet ); + wp_enqueue_style( $style_tag_id ); + } + + // If there are any other stores registered by themes etc., print them out. + $additional_stores = WP_Style_Engine_CSS_Rules_Store_Gutenberg::get_stores(); + + /* + * Since the corresponding action hook in Core is removed below, + * this function should still honour any styles stored using the Core Style Engine store. + */ + if ( class_exists( 'WP_Style_Engine_CSS_Rules_Store' ) ) { + $additional_stores = array_merge( $additional_stores, WP_Style_Engine_CSS_Rules_Store::get_stores() ); + } + + foreach ( array_keys( $additional_stores ) as $store_name ) { + if ( in_array( $store_name, $core_styles_keys, true ) ) { + continue; + } + $styles = gutenberg_style_engine_get_stylesheet_from_context( $store_name, $options ); + if ( ! empty( $styles ) ) { + $key = "wp-style-engine-$store_name"; + wp_register_style( $key, false, array(), true, true ); + wp_add_inline_style( $key, $styles ); + wp_enqueue_style( $key ); + } + } +} + +/* + * Always remove the Core action hook while gutenberg_enqueue_stored_styles() exists to avoid styles being printed twice. + * This is also because gutenberg_enqueue_stored_styles uses the Style Engine's `gutenberg_*` functions and `_Gutenberg` classes, + * which are in continuous development and generally ahead of Core. + */ +remove_action( 'wp_enqueue_scripts', 'wp_enqueue_stored_styles' ); +remove_action( 'wp_footer', 'wp_enqueue_stored_styles', 1 ); + +// Enqueue stored styles. +add_action( 'wp_enqueue_scripts', 'gutenberg_enqueue_stored_styles' ); +add_action( 'wp_footer', 'gutenberg_enqueue_stored_styles', 1 ); diff --git a/lib/compat/wordpress-6.0/class-wp-theme-json-6-0.php b/lib/compat/wordpress-6.0/class-wp-theme-json-6-0.php index 878ac30c1b2c7d..07820ccbd1dbe2 100644 --- a/lib/compat/wordpress-6.0/class-wp-theme-json-6-0.php +++ b/lib/compat/wordpress-6.0/class-wp-theme-json-6-0.php @@ -592,7 +592,7 @@ public function get_data() { } $flattened_preset = array(); foreach ( $items as $slug => $value ) { - $flattened_preset[] = array_merge( array( 'slug' => $slug ), $value ); + $flattened_preset[] = array_merge( array( 'slug' => (string) $slug ), $value ); } _wp_array_set( $output, $path, $flattened_preset ); } diff --git a/lib/compat/wordpress-6.1/blocks.php b/lib/compat/wordpress-6.1/blocks.php index a33515632099b3..b22fb1fe18aee8 100644 --- a/lib/compat/wordpress-6.1/blocks.php +++ b/lib/compat/wordpress-6.1/blocks.php @@ -81,7 +81,7 @@ function gutenberg_block_type_metadata_view_script( $settings, $metadata ) { // Replace suffix and extension with `.asset.php` to find the generated dependencies file. $view_asset_file = substr( $view_script_path, 0, -( strlen( '.js' ) ) ) . '.asset.php'; - $view_asset = file_exists( $view_asset_file ) ? require( $view_asset_file ) : null; + $view_asset = file_exists( $view_asset_file ) ? require $view_asset_file : null; $view_script_dependencies = isset( $view_asset['dependencies'] ) ? $view_asset['dependencies'] : array(); $view_script_version = isset( $view_asset['version'] ) ? $view_asset['version'] : false; $result = wp_register_script( @@ -102,79 +102,6 @@ function gutenberg_block_type_metadata_view_script( $settings, $metadata ) { } add_filter( 'block_type_metadata_settings', 'gutenberg_block_type_metadata_view_script', 10, 2 ); -if ( ! function_exists( 'wp_enqueue_block_view_script' ) ) { - /** - * Enqueues a frontend script for a specific block. - * - * Scripts enqueued using this function will only get printed - * when the block gets rendered on the frontend. - * - * @since 6.1.0 - * - * @param string $block_name The block name, including namespace. - * @param array $args An array of arguments [handle,src,deps,ver,media,textdomain]. - * - * @return void - */ - function wp_enqueue_block_view_script( $block_name, $args ) { - $args = wp_parse_args( - $args, - array( - 'handle' => '', - 'src' => '', - 'deps' => array(), - 'ver' => false, - 'in_footer' => false, - - // Additional args to allow translations for the script's textdomain. - 'textdomain' => '', - ) - ); - - /** - * Callback function to register and enqueue scripts. - * - * @param string $content When the callback is used for the render_block filter, - * the content needs to be returned so the function parameter - * is to ensure the content exists. - * @return string Block content. - */ - $callback = static function( $content, $block ) use ( $args, $block_name ) { - - // Sanity check. - if ( empty( $block['blockName'] ) || $block_name !== $block['blockName'] ) { - return $content; - } - - // Register the stylesheet. - if ( ! empty( $args['src'] ) ) { - wp_register_script( $args['handle'], $args['src'], $args['deps'], $args['ver'], $args['in_footer'] ); - } - - // Enqueue the stylesheet. - wp_enqueue_script( $args['handle'] ); - - // If a textdomain is defined, use it to set the script translations. - if ( ! empty( $args['textdomain'] ) && in_array( 'wp-i18n', $args['deps'], true ) ) { - wp_set_script_translations( $args['handle'], $args['textdomain'], $args['domainpath'] ); - } - - return $content; - }; - - /* - * The filter's callback here is an anonymous function because - * using a named function in this case is not possible. - * - * The function cannot be unhooked, however, users are still able - * to dequeue the script registered/enqueued by the callback - * which is why in this case, using an anonymous function - * was deemed acceptable. - */ - add_filter( 'render_block', $callback, 10, 2 ); - } -} - /** * Allow multiple view scripts per block. * diff --git a/lib/compat/wordpress-6.1/class-wp-theme-json-6-1.php b/lib/compat/wordpress-6.1/class-wp-theme-json-6-1.php index 5c1639d3d2ce6b..bdce96e8ccf5db 100644 --- a/lib/compat/wordpress-6.1/class-wp-theme-json-6-1.php +++ b/lib/compat/wordpress-6.1/class-wp-theme-json-6-1.php @@ -25,6 +25,18 @@ class WP_Theme_JSON_6_1 extends WP_Theme_JSON_6_0 { 'button' => array( ':hover', ':focus', ':active', ':visited' ), ); + /** + * The sources of data this object can represent. + * + * @var string[] + */ + const VALID_ORIGINS = array( + 'default', + 'blocks', + 'theme', + 'custom', + ); + /** * Metadata for style properties. * @@ -119,6 +131,43 @@ class WP_Theme_JSON_6_1 extends WP_Theme_JSON_6_0 { 'typography' => 'typography', ); + /** + * Constructor. + * + * @since 5.8.0 + * + * @param array $theme_json A structure that follows the theme.json schema. + * @param string $origin Optional. What source of data this object represents. + * One of 'default', 'theme', or 'custom'. Default 'theme'. + */ + public function __construct( $theme_json = array(), $origin = 'theme' ) { + if ( ! in_array( $origin, static::VALID_ORIGINS, true ) ) { + $origin = 'theme'; + } + + $this->theme_json = WP_Theme_JSON_Schema::migrate( $theme_json ); + $registry = WP_Block_Type_Registry::get_instance(); + $valid_block_names = array_keys( $registry->get_all_registered() ); + $valid_element_names = array_keys( static::ELEMENTS ); + $theme_json = static::sanitize( $this->theme_json, $valid_block_names, $valid_element_names ); + $this->theme_json = static::maybe_opt_in_into_settings( $theme_json ); + + // Internally, presets are keyed by origin. + $nodes = static::get_setting_nodes( $this->theme_json ); + foreach ( $nodes as $node ) { + foreach ( static::PRESETS_METADATA as $preset_metadata ) { + $path = array_merge( $node['path'], $preset_metadata['path'] ); + $preset = _wp_array_get( $this->theme_json, $path, null ); + if ( null !== $preset ) { + // If the preset is not already keyed by origin. + if ( isset( $preset[0] ) || empty( $preset ) ) { + _wp_array_set( $this->theme_json, $path, array( $origin => $preset ) ); + } + } + } + } + } + /** * Given an element name, returns a class name. * @@ -445,7 +494,7 @@ protected static function get_blocks_metadata() { static::$blocks_metadata[ $block_name ]['features'] = $features; } - // Assign defaults, then overwrite those that the block sets by itself. + // Assign defaults, then override those that the block sets by itself. // If the block selector is compounded, will append the element to each // individual block selector. $block_selectors = explode( ',', static::$blocks_metadata[ $block_name ]['selector'] ); @@ -625,9 +674,12 @@ private static function get_block_nodes( $theme_json, $selectors = array() ) { * 'styles': only the styles section in theme.json. * 'presets': only the classes for the presets. * @param array $origins A list of origins to include. By default it includes VALID_ORIGINS. + * @param array $options An array of options for now used for internal purposes only (may change without notice). + * The options currently supported are 'scope' that makes sure all style are scoped to a given selector, + * and root_selector which overwrites and forces a given selector to be used on the root node. * @return string Stylesheet. */ - public function get_stylesheet( $types = array( 'variables', 'styles', 'presets' ), $origins = null ) { + public function get_stylesheet( $types = array( 'variables', 'styles', 'presets' ), $origins = null, $options = array() ) { if ( null === $origins ) { $origins = static::VALID_ORIGINS; } @@ -648,6 +700,27 @@ public function get_stylesheet( $types = array( 'variables', 'styles', 'presets' $style_nodes = static::get_style_nodes( $this->theme_json, $blocks_metadata ); $setting_nodes = static::get_setting_nodes( $this->theme_json, $blocks_metadata ); + $root_style_key = array_search( static::ROOT_BLOCK_SELECTOR, array_column( $style_nodes, 'selector' ), true ); + $root_settings_key = array_search( static::ROOT_BLOCK_SELECTOR, array_column( $setting_nodes, 'selector' ), true ); + + if ( ! empty( $options['scope'] ) ) { + foreach ( $setting_nodes as &$node ) { + $node['selector'] = static::scope_selector( $options['scope'], $node['selector'] ); + } + foreach ( $style_nodes as &$node ) { + $node['selector'] = static::scope_selector( $options['scope'], $node['selector'] ); + } + } + + if ( ! empty( $options['root_selector'] ) ) { + if ( false !== $root_settings_key ) { + $setting_nodes[ $root_settings_key ]['selector'] = $options['root_selector']; + } + if ( false !== $root_style_key ) { + $setting_nodes[ $root_style_key ]['selector'] = $options['root_selector']; + } + } + $stylesheet = ''; if ( in_array( 'variables', $types, true ) ) { @@ -655,23 +728,30 @@ public function get_stylesheet( $types = array( 'variables', 'styles', 'presets' } if ( in_array( 'styles', $types, true ) ) { - $root_block_key = array_search( static::ROOT_BLOCK_SELECTOR, array_column( $style_nodes, 'selector' ), true ); - - if ( false !== $root_block_key ) { - $stylesheet .= $this->get_root_layout_rules( static::ROOT_BLOCK_SELECTOR, $style_nodes[ $root_block_key ] ); + if ( false !== $root_style_key ) { + $stylesheet .= $this->get_root_layout_rules( $style_nodes[ $root_style_key ]['selector'], $style_nodes[ $root_style_key ] ); } $stylesheet .= $this->get_block_classes( $style_nodes ); } elseif ( in_array( 'base-layout-styles', $types, true ) ) { + $root_selector = static::ROOT_BLOCK_SELECTOR; + $columns_selector = '.wp-block-columns'; + if ( ! empty( $options['scope'] ) ) { + $root_selector = static::scope_selector( $options['scope'], $root_selector ); + $columns_selector = static::scope_selector( $options['scope'], $columns_selector ); + } + if ( ! empty( $options['root_selector'] ) ) { + $root_selector = $options['root_selector']; + } // Base layout styles are provided as part of `styles`, so only output separately if explicitly requested. // For backwards compatibility, the Columns block is explicitly included, to support a different default gap value. $base_styles_nodes = array( array( 'path' => array( 'styles' ), - 'selector' => static::ROOT_BLOCK_SELECTOR, + 'selector' => $root_selector, ), array( 'path' => array( 'styles', 'blocks', 'core/columns' ), - 'selector' => '.wp-block-columns', + 'selector' => $columns_selector, 'name' => 'core/columns', ), ); @@ -1017,7 +1097,7 @@ protected static function get_property_value( $styles, $path, $theme_json = null if ( is_array( $ref_value ) && array_key_exists( 'ref', $ref_value ) ) { $path_string = json_encode( $path ); $ref_value_string = json_encode( $ref_value ); - _doing_it_wrong( 'get_property_value', "Your theme.json file uses a dynamic value (${ref_value_string}) for the path at ${path_string}. However, the value at ${path_string} is also a dynamic value (pointing to ${ref_value['ref']}) and pointing to another dynamic value is not supported. Please update ${path_string} to point directly to ${ref_value['ref']}.", '6.1.0' ); + _doing_it_wrong( 'get_property_value', "Your theme.json file uses a dynamic value ({$ref_value_string}) for the path at {$path_string}. However, the value at {$path_string} is also a dynamic value (pointing to {$ref_value['ref']}) and pointing to another dynamic value is not supported. Please update {$path_string} to point directly to {$ref_value['ref']}.", '6.1.0' ); } } @@ -1151,15 +1231,6 @@ protected static function get_property_value( $styles, $path, $theme_json = null 'classes' => array(), 'properties' => array( 'padding', 'margin' ), ), - array( - 'path' => array( 'spacing', 'spacingScale' ), - 'prevent_override' => false, - 'use_default_names' => true, - 'value_key' => 'size', - 'css_vars' => '--wp--preset--spacing--$slug', - 'classes' => array(), - 'properties' => array( 'padding', 'margin' ), - ), ); /** @@ -1280,7 +1351,7 @@ public function set_spacing_sizes() { } if ( $below_midpoint_count < $steps_mid_point - 2 ) { - $x_small_count++; + ++$x_small_count; } $slug -= 10; @@ -1317,7 +1388,7 @@ public function set_spacing_sizes() { } if ( $above_midpoint_count > 1 ) { - $x_large_count++; + ++$x_large_count; } $slug += 10; @@ -1516,4 +1587,45 @@ protected function get_layout_styles( $block_metadata ) { } return $block_rules; } + + /** + * Function that scopes a selector with another one. This works a bit like + * SCSS nesting except the `&` operator isn't supported. + * + * + * $scope = '.a, .b .c'; + * $selector = '> .x, .y'; + * $merged = scope_selector( $scope, $selector ); + * // $merged is '.a > .x, .a .y, .b .c > .x, .b .c .y' + * + * + * @since 5.9.0 + * + * @param string $scope Selector to scope to. + * @param string $selector Original selector. + * @return string Scoped selector. + */ + public static function scope_selector( $scope, $selector ) { + $scopes = explode( ',', $scope ); + $selectors = explode( ',', $selector ); + + $selectors_scoped = array(); + foreach ( $scopes as $outer ) { + foreach ( $selectors as $inner ) { + $outer = trim( $outer ); + $inner = trim( $inner ); + if ( ! empty( $outer ) && ! empty( $inner ) ) { + $selectors_scoped[] = $outer . ' ' . $inner; + } elseif ( empty( $outer ) ) { + $selectors_scoped[] = $inner; + } elseif ( empty( $inner ) ) { + $selectors_scoped[] = $outer; + } + } + } + + $result = implode( ', ', $selectors_scoped ); + return $result; + } + } diff --git a/lib/compat/wordpress-6.1/get-global-styles-and-settings.php b/lib/compat/wordpress-6.1/get-global-styles-and-settings.php index ccccdad9a814b9..1837aa04dbb20d 100644 --- a/lib/compat/wordpress-6.1/get-global-styles-and-settings.php +++ b/lib/compat/wordpress-6.1/get-global-styles-and-settings.php @@ -92,14 +92,22 @@ function gutenberg_get_global_stylesheet( $types = array() ) { /* * If variables are part of the stylesheet, - * we add them for all origins (default, theme, user). + * we add them. + * * This is so themes without a theme.json still work as before 5.9: * they can override the default presets. * See https://core.trac.wordpress.org/ticket/54782 */ $styles_variables = ''; if ( in_array( 'variables', $types, true ) ) { - $styles_variables = $tree->get_stylesheet( array( 'variables' ) ); + /* + * We only use the default, theme, and custom origins. + * This is because styles for blocks origin are added + * at a later phase (render cycle) so we only render the ones in use. + * @see wp_add_global_styles_for_blocks + */ + $origins = array( 'default', 'theme', 'custom' ); + $styles_variables = $tree->get_stylesheet( array( 'variables' ), $origins ); $types = array_diff( $types, array( 'variables' ) ); } @@ -111,6 +119,12 @@ function gutenberg_get_global_stylesheet( $types = array() ) { */ $styles_rest = ''; if ( ! empty( $types ) ) { + /* + * We only use the default, theme, and custom origins. + * This is because styles for blocks origin are added + * at a later phase (render cycle) so we only render the ones in use. + * @see wp_add_global_styles_for_blocks + */ $origins = array( 'default', 'theme', 'custom' ); if ( ! $supports_theme_json ) { $origins = array( 'default' ); diff --git a/lib/compat/wordpress-6.1/persisted-preferences.php b/lib/compat/wordpress-6.1/persisted-preferences.php index 2a5211e12596ac..2263820e9911b4 100644 --- a/lib/compat/wordpress-6.1/persisted-preferences.php +++ b/lib/compat/wordpress-6.1/persisted-preferences.php @@ -21,11 +21,11 @@ function gutenberg_register_persisted_preferences_meta() { 'type' => 'object', 'single' => true, 'show_in_rest' => array( - 'name' => 'persisted_preferences', - 'type' => 'object', - 'context' => array( 'edit' ), - 'schema' => array( + 'name' => 'persisted_preferences', + 'type' => 'object', + 'schema' => array( 'type' => 'object', + 'context' => array( 'edit' ), 'properties' => array( '_modified' => array( 'description' => __( 'The date and time the preferences were updated.', 'default' ), @@ -72,7 +72,6 @@ function gutenberg_configure_persisted_preferences() { ), 'after' ); - } add_action( 'admin_init', 'gutenberg_configure_persisted_preferences' ); diff --git a/lib/compat/wordpress-6.1/script-loader.php b/lib/compat/wordpress-6.1/script-loader.php index 1d7b12c8fe8352..dc0d7e7dfe0f32 100644 --- a/lib/compat/wordpress-6.1/script-loader.php +++ b/lib/compat/wordpress-6.1/script-loader.php @@ -36,73 +36,6 @@ static function () use ( $style ) { ); } -/** - * Fetches, processes and compiles stored core styles, then combines and renders them to the page. - * Styles are stored via the style engine API. - * - * See: https://developer.wordpress.org/block-editor/reference-guides/packages/packages-style-engine/ - * - * @param array $options { - * Optional. An array of options to pass to gutenberg_style_engine_get_stylesheet_from_context(). Default empty array. - * - * @type bool $optimize Whether to optimize the CSS output, e.g., combine rules. Default is `false`. - * @type bool $prettify Whether to add new lines and indents to output. Default is the test of whether the global constant `SCRIPT_DEBUG` is defined. - * } - * - * @return void - */ -function gutenberg_enqueue_stored_styles( $options = array() ) { - $is_block_theme = wp_is_block_theme(); - $is_classic_theme = ! $is_block_theme; - - /* - * For block themes, print stored styles in the header. - * For classic themes, in the footer. - */ - if ( - ( $is_block_theme && doing_action( 'wp_footer' ) ) || - ( $is_classic_theme && doing_action( 'wp_enqueue_scripts' ) ) - ) { - return; - } - - $core_styles_keys = array( 'block-supports' ); - $compiled_core_stylesheet = ''; - $style_tag_id = 'core'; - foreach ( $core_styles_keys as $style_key ) { - // Adds comment if code is prettified to identify core styles sections in debugging. - $should_prettify = isset( $options['prettify'] ) ? true === $options['prettify'] : defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG; - if ( $should_prettify ) { - $compiled_core_stylesheet .= "/**\n * Core styles: $style_key\n */\n"; - } - // Chains core store ids to signify what the styles contain. - $style_tag_id .= '-' . $style_key; - $compiled_core_stylesheet .= gutenberg_style_engine_get_stylesheet_from_context( $style_key, $options ); - } - - // Combines Core styles. - if ( ! empty( $compiled_core_stylesheet ) ) { - wp_register_style( $style_tag_id, false, array(), true, true ); - wp_add_inline_style( $style_tag_id, $compiled_core_stylesheet ); - wp_enqueue_style( $style_tag_id ); - } - - // If there are any other stores registered by themes etc, print them out. - $additional_stores = WP_Style_Engine_CSS_Rules_Store_Gutenberg::get_stores(); - foreach ( array_keys( $additional_stores ) as $store_name ) { - if ( in_array( $store_name, $core_styles_keys, true ) ) { - continue; - } - $styles = gutenberg_style_engine_get_stylesheet_from_context( $store_name, $options ); - if ( ! empty( $styles ) ) { - $key = "wp-style-engine-$store_name"; - wp_register_style( $key, false, array(), true, true ); - wp_add_inline_style( $key, $styles ); - wp_enqueue_style( $key ); - } - } -} - /** * This applies a filter to the list of style nodes that comes from `get_style_nodes` in WP_Theme_JSON. * This particular filter removes all of the blocks from the array. @@ -179,5 +112,19 @@ function gutenberg_enqueue_global_styles() { // Enqueue global styles, and then block supports styles. add_action( 'wp_enqueue_scripts', 'gutenberg_enqueue_global_styles' ); add_action( 'wp_footer', 'gutenberg_enqueue_global_styles', 1 ); -add_action( 'wp_enqueue_scripts', 'gutenberg_enqueue_stored_styles' ); -add_action( 'wp_footer', 'gutenberg_enqueue_stored_styles', 1 ); + +/** + * Loads classic theme styles on classic themes. + * + * This is needed for backwards compatibility for button blocks specifically. + */ +function gutenberg_enqueue_classic_theme_styles() { + if ( ! wp_is_block_theme() ) { + wp_register_style( 'classic-theme-styles', gutenberg_url( 'build/block-library/classic.css' ), array(), true ); + wp_enqueue_style( 'classic-theme-styles' ); + } +} +// To load classic theme styles on the frontend. +add_action( 'wp_enqueue_scripts', 'gutenberg_enqueue_classic_theme_styles' ); +// To load classic theme styles in the the editor. +add_action( 'admin_enqueue_scripts', 'gutenberg_enqueue_classic_theme_styles' ); diff --git a/lib/experimental/blocks.php b/lib/experimental/blocks.php new file mode 100644 index 00000000000000..b789deaba6ed57 --- /dev/null +++ b/lib/experimental/blocks.php @@ -0,0 +1,79 @@ + '', + 'src' => '', + 'deps' => array(), + 'ver' => false, + 'in_footer' => false, + + // Additional args to allow translations for the script's textdomain. + 'textdomain' => '', + ) + ); + + /** + * Callback function to register and enqueue scripts. + * + * @param string $content When the callback is used for the render_block filter, + * the content needs to be returned so the function parameter + * is to ensure the content exists. + * @return string Block content. + */ + $callback = static function( $content, $block ) use ( $args, $block_name ) { + + // Sanity check. + if ( empty( $block['blockName'] ) || $block_name !== $block['blockName'] ) { + return $content; + } + + // Register the stylesheet. + if ( ! empty( $args['src'] ) ) { + wp_register_script( $args['handle'], $args['src'], $args['deps'], $args['ver'], $args['in_footer'] ); + } + + // Enqueue the stylesheet. + wp_enqueue_script( $args['handle'] ); + + // If a textdomain is defined, use it to set the script translations. + if ( ! empty( $args['textdomain'] ) && in_array( 'wp-i18n', $args['deps'], true ) ) { + wp_set_script_translations( $args['handle'], $args['textdomain'], $args['domainpath'] ); + } + + return $content; + }; + + /* + * The filter's callback here is an anonymous function because + * using a named function in this case is not possible. + * + * The function cannot be unhooked, however, users are still able + * to dequeue the script registered/enqueued by the callback + * which is why in this case, using an anonymous function + * was deemed acceptable. + */ + add_filter( 'render_block', $callback, 10, 2 ); + } +} diff --git a/lib/experimental/class-wp-theme-json-resolver-gutenberg.php b/lib/experimental/class-wp-theme-json-resolver-gutenberg.php index 643e7e5df9b321..ed36c236228d96 100644 --- a/lib/experimental/class-wp-theme-json-resolver-gutenberg.php +++ b/lib/experimental/class-wp-theme-json-resolver-gutenberg.php @@ -141,12 +141,10 @@ public static function get_block_data() { * * @param WP_Theme_JSON_Data_Gutenberg Class to access and update the underlying data. */ - $theme_json = apply_filters( 'theme_json_blocks', new WP_Theme_JSON_Data_Gutenberg( $config, 'core' ) ); + $theme_json = apply_filters( 'theme_json_blocks', new WP_Theme_JSON_Data_Gutenberg( $config, 'blocks' ) ); $config = $theme_json->get_data(); - // Core here means it's the lower level part of the styles chain. - // It can be a core or a third-party block. - return new WP_Theme_JSON_Gutenberg( $config, 'core' ); + return new WP_Theme_JSON_Gutenberg( $config, 'blocks' ); } /** diff --git a/lib/experimental/class-wp-webfonts.php b/lib/experimental/class-wp-webfonts.php index e422f51658befb..02083d50cabc15 100644 --- a/lib/experimental/class-wp-webfonts.php +++ b/lib/experimental/class-wp-webfonts.php @@ -244,7 +244,7 @@ public function validate_webfont( $webfont ) { } // Check the font-display. - if ( ! in_array( $webfont['font-display'], array( 'auto', 'block', 'fallback', 'swap' ), true ) ) { + if ( ! in_array( $webfont['font-display'], array( 'auto', 'block', 'fallback', 'swap', 'optional' ), true ) ) { $webfont['font-display'] = 'fallback'; } diff --git a/lib/experimental/editor-settings.php b/lib/experimental/editor-settings.php index c93214385752b9..2b46a401d4b0e9 100644 --- a/lib/experimental/editor-settings.php +++ b/lib/experimental/editor-settings.php @@ -70,7 +70,6 @@ function gutenberg_initialize_editor( $editor_name, $editor_script_handle, $sett 'wp-blocks', 'wp.blocks.unstable__bootstrapServerSideBlockDefinitions(' . wp_json_encode( get_block_editor_server_block_settings() ) . ');' ); - } /** diff --git a/lib/experimental/html/class-wp-html-attribute-token.php b/lib/experimental/html/class-wp-html-attribute-token.php new file mode 100644 index 00000000000000..7b3d5718723581 --- /dev/null +++ b/lib/experimental/html/class-wp-html-attribute-token.php @@ -0,0 +1,89 @@ +name = $name; + $this->value_starts_at = $value_start; + $this->value_length = $value_length; + $this->start = $start; + $this->end = $end; + $this->is_true = $is_true; + } +} diff --git a/lib/experimental/html/class-wp-html-tag-processor.php b/lib/experimental/html/class-wp-html-tag-processor.php new file mode 100644 index 00000000000000..e66b5dd415cb11 --- /dev/null +++ b/lib/experimental/html/class-wp-html-tag-processor.php @@ -0,0 +1,1466 @@ + "c" not " c" + * @TODO: Skip over `/` in attributes area, split attribute names by `/` + * @TODO: Decode HTML references/entities in class names when matching. + * E.g. match having class `1<"2` needs to recognize `class="1<"2"`. + * @TODO: Decode character references in `get_attribute()` + * @TODO: Properly escape attribute value in `set_attribute()` + * @TODO: Add slow mode to escape character entities in CSS class names? + * (This requires a custom decoder since `html_entity_decode()` + * doesn't handle attribute character reference decoding rules. + * + * @package WordPress + * @subpackage HTML + * @since 6.2.0 + */ + +/** + * Processes an input HTML document by applying a specified set + * of patches to that input. Tokenizes HTML but does not fully + * parse the input document. + * + * ## Usage + * + * Use of this class requires three steps: + * + * 1. Create a new class instance with your input HTML document. + * 2. Find the tag(s) you are looking for. + * 3. Request changes to the attributes in those tag(s). + * + * Example: + * ```php + * $tags = new WP_HTML_Tag_Processor( $html ); + * if ( $tags->next_tag( [ 'tag_name' => 'option' ] ) ) { + * $tags->set_attribute( 'selected', true ); + * } + * ``` + * + * ### Finding tags + * + * The `next_tag()` function moves the internal cursor through + * your input HTML document until it finds a tag meeting any of + * the supplied restrictions in the optional query argument. If + * no argument is provided then it will find the next HTML tag, + * regardless of what kind it is. + * + * If you want to _find whatever the next tag is_: + * ```php + * $tags->next_tag(); + * ``` + * + * | Goal | Query | + * |-----------------------------------------------------------|----------------------------------------------------------------------------| + * | Find any tag. | `$tags->next_tag();` | + * | Find next image tag. | `$tags->next_tag( [ 'tag_name' => 'img' ] );` | + * | Find next tag containing the `fullwidth` CSS class. | `$tags->next_tag( [ 'class_name' => 'fullwidth' ] );` | + * | Find next image tag containing the `fullwidth` CSS class. | `$tags->next_tag( [ 'tag_name' => 'img', 'class_name' => 'fullwidth' ] );` | + * + * If a tag was found meeting your criteria then `next_tag()` + * will return `true` and you can proceed to modify it. If it + * returns `false`, however, it failed to find the tag and + * moved the cursor to the end of the file. + * + * Once the cursor reaches the end of the file the processor + * is done and if you want to reach an earlier tag you will + * need to recreate the processor and start over. The internal + * cursor can only proceed forward, never backing up. + * + * #### Custom queries + * + * Sometimes it's necessary to further inspect an HTML tag than + * the query syntax here permits. In these cases one may further + * inspect the search results using the read-only functions + * provided by the processor or external state or variables. + * + * Example: + * ```php + * // Paint up to the first five DIV or SPAN tags marked with the "jazzy" style. + * $remaining_count = 5; + * while ( $remaining_count > 0 && $tags->next_tag() ) { + * if ( + * ( 'DIV' === $tags->get_tag() || 'SPAN' === $tags->get_tag() ) && + * 'jazzy' === $tags->get_attribute( 'data-style' ) + * ) { + * $tags->add_class( 'theme-style-everest-jazz' ); + * $remaining_count--; + * } + * } + * ``` + * + * `get_attribute()` will return `null` if the attribute wasn't present + * on the tag when it was called. It may return `""` (the empty string) + * in cases where the attribute was present but its value was empty. + * For boolean attributes, those whose name is present but no value is + * given, it will return `true` (the only way to set `false` for an + * attribute is to remove it). + * + * ### Modifying HTML attributes for a found tag + * + * Once you've found the start of an opening tag you can modify + * any number of the attributes on that tag. You can set a new + * value for an attribute, remove the entire attribute, or do + * nothing and move on to the next opening tag. + * + * Example: + * ```php + * if ( $tags->next_tag( [ 'class' => 'wp-group-block' ] ) ) { + * $tags->set_attribute( 'title', 'This groups the contained content.' ); + * $tags->remove_attribute( 'data-test-id' ); + * } + * ``` + * + * If `set_attribute()` is called for an existing attribute it will + * overwrite the existing value. Similarly, calling `remove_attribute()` + * for a non-existing attribute has no effect on the document. Both + * of these methods are safe to call without knowing if a given attribute + * exists beforehand. + * + * ### Modifying CSS classes for a found tag + * + * The tag processor treats the `class` attribute as a special case. + * Because it's a common operation to add or remove CSS classes you + * can do so using this interface. + * + * As with attribute values, adding or removing CSS classes is a safe + * operation that doesn't require checking if the attribute or class + * exists before making changes. If removing the only class then the + * entire `class` attribute will be removed. + * + * Example: + * ```php + * // from `Yippee!` + * // to `Yippee!` + * $tags->add_class( 'is-active' ); + * + * // from `Yippee!` + * // to `Yippee!` + * $tags->add_class( 'is-active' ); + * + * // from `Yippee!` + * // to `Yippee!` + * $tags->add_class( 'is-active' ); + * + * // from `` + * // to ` + * $tags->remove_class( 'rugby' ); + * + * // from `` + * // to ` + * $tags->remove_class( 'rugby' ); + * + * // from `` + * // to ` + * $tags->remove_class( 'rugby' ); + * ``` + * + * ## Design limitations + * + * @TODO: Expand this section + * + * - no nesting: cannot match open and close tag + * - only move forward, never backward + * - class names not decoded if they contain character references + * - only secures against HTML escaping issues; requires + * manually sanitizing or escaping values based on the needs of + * each individual attribute, since different attributes have + * different needs. + * + * @since 6.2.0 + */ +class WP_HTML_Tag_Processor { + + /** + * The HTML document to parse. + * + * @since 6.2.0 + * @var string + */ + private $html; + + /** + * The last query passed to next_tag(). + * + * @since 6.2.0 + * @var array|null + */ + private $last_query; + + /** + * The tag name this processor currently scans for. + * + * @since 6.2.0 + * @var string|null + */ + private $sought_tag_name; + + /** + * The CSS class name this processor currently scans for. + * + * @since 6.2.0 + * @var string|null + */ + private $sought_class_name; + + /** + * The match offset this processor currently scans for. + * + * @since 6.2.0 + * @var int|null + */ + private $sought_match_offset; + + /** + * The updated HTML document. + * + * @since 6.2.0 + * @var string + */ + private $updated_html = ''; + + /** + * How many bytes from the original HTML document were already read. + * + * @since 6.2.0 + * @var int + */ + private $parsed_bytes = 0; + + /** + * How many bytes from the original HTML document were already treated + * with the requested replacements. + * + * @since 6.2.0 + * @var int + */ + private $updated_bytes = 0; + + /** + * Byte offset in input document where current tag name starts. + * + * Example: + * ``` + *
... + * 01234 + * - tag name starts at 1 + * ``` + * + * @since 6.2.0 + * @var ?int + */ + private $tag_name_starts_at; + + /** + * Byte length of current tag name. + * + * Example: + * ``` + *
... + * 01234 + * --- tag name length is 3 + * ``` + * + * @since 6.2.0 + * @var ?int + */ + private $tag_name_length; + + /** + * Lazily-built index of attributes found within an HTML tag, keyed by the attribute name. + * + * Example: + * + * // supposing the parser is working through this content + * // and stops after recognizing the `id` attribute + * //
+ * // ^ parsing will continue from this point + * $this->attributes = [ + * 'id' => new WP_HTML_Attribute_Match( 'id', null, 6, 17 ) + * ]; + * + * // when picking up parsing again, or when asking to find the + * // `class` attribute we will continue and add to this array + * $this->attributes = [ + * 'id' => new WP_HTML_Attribute_Match( 'id', null, 6, 17 ), + * 'class' => new WP_HTML_Attribute_Match( 'class', 'outline', 18, 32 ) + * ]; + * + * // Note that only the `class` attribute value is stored in the index. + * // That's because it is the only value used by this class at the moment. + * + * + * @since 6.2.0 + * @var WP_HTML_Attribute_Token[] + */ + private $attributes = array(); + + /** + * Which class names to add or remove from a tag. + * + * These are tracked separately from attribute updates because they are + * semantically distinct, whereas this interface exists for the common + * case of adding and removing class names while other attributes are + * generally modified as with DOM `setAttribute` calls. + * + * When modifying an HTML document these will eventually be collapsed + * into a single lexical update to replace the `class` attribute. + * + * Example: + * + * // Add the `WP-block-group` class, remove the `WP-group` class. + * $class_changes = [ + * // Indexed by a comparable class name + * 'wp-block-group' => new WP_Class_Name_Operation( 'WP-block-group', WP_Class_Name_Operation::ADD ), + * 'wp-group' => new WP_Class_Name_Operation( 'WP-group', WP_Class_Name_Operation::REMOVE ) + * ]; + * + * + * @since 6.2.0 + * @var bool[] + */ + private $classname_updates = array(); + + const ADD_CLASS = true; + const REMOVE_CLASS = false; + const SKIP_CLASS = null; + + /** + * Lexical replacements to apply to input HTML document. + * + * HTML modifications collapse into lexical replacements in order to + * provide an efficient mechanism to update documents lazily and in + * order to support a variety of semantic modifications without + * building a complicated parsing machinery. That is, it's up to + * the calling class to generate the lexical modification from the + * semantic change requested. + * + * Example: + * + * // Replace an attribute stored with a new value, indices + * // sourced from the lazily-parsed HTML recognizer. + * $start = $attributes['src']->start; + * $end = $attributes['src']->end; + * $modifications[] = new WP_HTML_Text_Replacement( $start, $end, get_the_post_thumbnail_url() ); + * + * // Correspondingly, something like this + * // will appear in the replacements array. + * $replacements = [ + * WP_HTML_Text_Replacement( 14, 28, 'https://my-site.my-domain/wp-content/uploads/2014/08/kittens.jpg' ) + * ]; + * + * + * @since 6.2.0 + * @var WP_HTML_Text_Replacement[] + */ + private $attribute_updates = array(); + + /** + * Constructor. + * + * @since 6.2.0 + * + * @param string $html HTML to process. + */ + public function __construct( $html ) { + $this->html = $html; + } + + /** + * Finds the next tag matching the $query. + * + * @since 6.2.0 + * + * @param array|string $query { + * Which tag name to find, having which class, etc. + * + * @type string|null $tag_name Which tag to find, or `null` for "any tag." + * @type int|null $match_offset Find the Nth tag matching all search criteria. + * 0 for "first" tag, 2 for "third," etc. + * Defaults to first tag. + * @type string|null $class_name Tag must contain this whole class name to match. + * } + * @return boolean Whether a tag was matched. + */ + public function next_tag( $query = null ) { + $this->parse_query( $query ); + $already_found = 0; + + do { + /* + * Unfortunately we can't try to search for only the tag name we want because that might + * lead us to skip over other tags and lose track of our place. So we need to search for + * _every_ tag and then check after we find one if it's the one we are looking for. + */ + if ( false === $this->parse_next_tag() ) { + $this->parsed_bytes = strlen( $this->html ); + + return false; + } + + $this->parse_tag_opener_attributes(); + + if ( $this->matches() ) { + ++$already_found; + } + + // Avoid copying the tag name string when possible. + $t = $this->html[ $this->tag_name_starts_at ]; + if ( 's' === $t || 'S' === $t || 't' === $t || 'T' === $t ) { + $tag_name = $this->get_tag(); + + if ( 'SCRIPT' === $tag_name ) { + $this->skip_script_data(); + } elseif ( 'TEXTAREA' === $tag_name || 'TITLE' === $tag_name ) { + $this->skip_rcdata( $tag_name ); + } + } + } while ( $already_found < $this->sought_match_offset ); + + return true; + } + + /** + * Skips the contents of the title and textarea tags until an appropriate + * tag closer is found. + * + * @see https://html.spec.whatwg.org/multipage/parsing.html#rcdata-state + * @param string $tag_name – the lowercase tag name which will close the RCDATA region. + * @since 6.2.0 + */ + private function skip_rcdata( $tag_name ) { + $html = $this->html; + $doc_length = strlen( $html ); + $tag_length = strlen( $tag_name ); + + $at = $this->parsed_bytes; + + while ( true ) { + $at = strpos( $this->html, ' $doc_length ) { + $this->parsed_bytes = $doc_length; + return; + } + + $at += 2; + + /* + * We have to find a case-insensitive match to the tag name. + * Note also that since tag names are limited to US-ASCII + * characters we can ignore any kind of Unicode normalizing + * forms when comparing. If we get a non-ASCII character it + * will never be a match. + */ + for ( $i = 0; $i < $tag_length; $i++ ) { + $tag_char = $tag_name[ $i ]; + $html_char = $html[ $at + $i ]; + + if ( $html_char !== $tag_char && strtoupper( $html_char ) !== $tag_char ) { + $at += $i; + continue 2; + } + } + + $at += $tag_length; + $this->parsed_bytes = $at; + + /* + * Ensure we terminate the tag name, otherwise we might, + * for example, accidentally match the sequence + * "" for "". + */ + $c = $html[ $at ]; + if ( ' ' !== $c && "\t" !== $c && "\r" !== $c && "\n" !== $c && '/' !== $c && '>' !== $c ) { + continue; + } + + $this->skip_tag_closer_attributes(); + $at = $this->parsed_bytes; + + if ( '>' === $html[ $at ] || '/' === $html[ $at ] ) { + ++$this->parsed_bytes; + return; + } + } + } + + /** + * Skips the contents of
', + ); + + $examples['Simple uppercase script tag'] = array( + '
', + ); + + $examples['Script with a comment opener inside should end at the next script tag closer (dash dash escaped state)'] = array( + '
-->', + ); + + $examples['Script with a comment opener and a script tag opener inside should end two script tag closer later (double escaped state)'] = array( + '
-->', + ); + + $examples['Double escaped script with a tricky opener'] = array( + '">
', + ); + + $examples['Double escaped script with a tricky closer'] = array( + '">
', + ); + + $examples['Double escaped, then escaped, then double escaped'] = array( + '
', + ); + + $examples['Script with a commented a script tag opener inside should at the next tag closer (dash dash escaped state)'] = array( + '
-->', + ); + + $examples['Script closer with another script tag in closer attributes'] = array( + '
', + ); + + $examples['Script closer with attributes'] = array( + '
', + ); + + $examples['Script opener with title closer inside'] = array( + '
', + ); + + $examples['Complex script with many parsing states'] = array( + '-->
-->', + ); + return $examples; + } + + /** + * @ticket 56299 + * + * @covers next_tag + * + * @dataProvider data_rcdata_state + */ + public function test_next_tag_ignores_the_contents_of_a_rcdata_tag( $rcdata_then_div, $rcdata_tag ) { + $p = new WP_HTML_Tag_Processor( $rcdata_then_div ); + $p->next_tag(); + $this->assertSame( $rcdata_tag, $p->get_tag(), "The first found tag was not '$rcdata_tag'" ); + $p->next_tag(); + $this->assertSame( 'div', $p->get_tag(), "The second found tag was not 'div'" ); + } + + /** + * Data provider for test_ignores_contents_of_a_rcdata_tag(). + * + * @return array { + * @type array { + * @type string $rcdata_then_div The HTML snippet containing RCDATA and div tags. + * @type string $rcdata_tag The RCDATA tag. + * } + * } + */ + public function data_rcdata_state() { + $examples = array(); + $examples['Simple textarea'] = array( + '
', + 'textarea', + ); + + $examples['Simple title'] = array( + '<span class="d-none d-md-inline">Back to notifications</title</span>
', + 'title', + ); + + $examples['Comment opener inside a textarea tag should be ignored'] = array( + '
-->', + 'textarea', + ); + + $examples['Textarea closer with another textarea tag in closer attributes'] = array( + '
', + 'textarea', + ); + + $examples['Textarea closer with attributes'] = array( + '
', + 'textarea', + ); + + $examples['Textarea opener with title closer inside'] = array( + '
', + 'textarea', + ); + return $examples; + } + + /** + * @ticket 56299 + * + * @covers next_tag + * @covers set_attribute + * @covers __toString + */ + public function test_can_query_and_update_wrongly_nested_tags() { + $p = new WP_HTML_Tag_Processor( + '123

456789

' + ); + $p->next_tag( 'span' ); + $p->set_attribute( 'class', 'span-class' ); + $p->next_tag( 'p' ); + $p->set_attribute( 'class', 'p-class' ); + $this->assertSame( + '123

456789

', + (string) $p + ); + } + + /** + * @ticket 56299 + * + * @covers next_tag + * @covers remove_attribute + * @covers __toString + */ + public function test_removing_attributes_works_even_in_malformed_html() { + $p = new WP_HTML_Tag_Processor( self::HTML_MALFORMED ); + $p->next_tag( 'span' ); + $p->remove_attribute( 'Notifications<' ); + $this->assertSame( + '
Back to notifications
', + (string) $p + ); + } + + /** + * @ticket 56299 + * + * @covers next_Tag + * @covers set_attribute + * @covers __toString + */ + public function test_updating_attributes_works_even_in_malformed_html_1() { + $p = new WP_HTML_Tag_Processor( self::HTML_MALFORMED ); + $p->next_tag( 'span' ); + $p->set_attribute( 'id', 'first' ); + $p->next_tag( 'span' ); + $p->set_attribute( 'id', 'second' ); + $this->assertSame( + '
Back to notifications
', + (string) $p + ); + } + + /** + * @ticket 56299 + * + * @covers next_tag + * @covers set_attribute + * @covers add_class + * @covers __toString + * + * @dataProvider data_malformed_tag + */ + public function test_updating_attributes_works_even_in_malformed_html_2( $html_input, $html_expected ) { + $p = new WP_HTML_Tag_Processor( $html_input ); + $p->next_tag(); + $p->set_attribute( 'foo', 'bar' ); + $p->add_class( 'firstTag' ); + $p->next_tag(); + $p->add_class( 'secondTag' ); + $this->assertSame( + $html_expected, + (string) $p + ); + } + + /** + * Data provider for test_updates_when_malformed_tag(). + * + * @return array { + * @type array { + * @type string $html_input The input HTML snippet. + * @type string $html_expected The expected HTML snippet after processing. + * } + * } + */ + public function data_malformed_tag() { + $null_byte = chr( 0 ); + $examples = array(); + $examples['Invalid entity inside attribute value'] = array( + 'test', + 'test', + ); + + $examples['HTML tag opening inside attribute value'] = array( + '
This <is> a <strong is="true">thing.
test', + '
This <is> a <strong is="true">thing.
test', + ); + + $examples['HTML tag brackets in attribute values and data markup'] = array( + '
This <is> a <strong is="true">thing.
test', + '
This <is> a <strong is="true">thing.
test', + ); + + $examples['Single and double quotes in attribute value'] = array( + '

test', + '

test', + ); + + $examples['Unquoted attribute values'] = array( + '


test', + '
test', + ); + + $examples['Double-quotes escaped in double-quote attribute value'] = array( + '
test', + '
test', + ); + + $examples['Unquoted attribute value'] = array( + '
test', + '
test', + ); + + $examples['Unquoted attribute value with tag-like value'] = array( + '
>test', + '
>test', + ); + + $examples['Unquoted attribute value with tag-like value followed by tag-like data'] = array( + '
>test', + '
>test', + ); + + $examples['1'] = array( + '
test', + '
test', + ); + + $examples['2'] = array( + '
test', + '
test', + ); + + $examples['4'] = array( + '
test', + '
test', + ); + + $examples['5'] = array( + '
code>test', + '
code>test', + ); + + $examples['6'] = array( + '
test', + '
test', + ); + + $examples['7'] = array( + '
test', + '
test', + ); + + $examples['8'] = array( + '
id="test">test', + '
id="test">test', + ); + + $examples['9'] = array( + '
test', + '
test', + ); + + $examples['10'] = array( + 'test', + 'test', + ); + + $examples['11'] = array( + 'The applicative operator <* works well in Haskell; is what?test', + 'The applicative operator <* works well in Haskell; is what?test', + ); + + $examples['12'] = array( + '<3 is a heart but is a tag.test', + '<3 is a heart but is a tag.test', + ); + + $examples['13'] = array( + 'test', + 'test', + ); + + $examples['14'] = array( + 'test', + 'test', + ); + + $examples['15'] = array( + ' a HTML Tag]]>test', + ' a HTML Tag]]>test', + ); + + $examples['16'] = array( + '
test', + '
test', + ); + + $examples['17'] = array( + '
test', + '
test', + ); + + $examples['18'] = array( + '
test', + '
test', + ); + + $examples['19'] = array( + '
test', + '
test', + ); + + $examples['20'] = array( + '
test', + '
test', + ); + + $examples['21'] = array( + '
test', + '
test', + ); + + $examples['22'] = array( + '
test', + '
test', + ); + + $examples['23'] = array( + '
test', + '
test', + ); + + $examples['24'] = array( + '
test', + '
test', + ); + + $examples['25'] = array( + '
test', + '
test', + ); + + $examples['Multiple unclosed tags treated as a single tag'] = array( + '
+test', + '
+test', + ); + + $examples['27'] = array( + '
test', + '
test', + ); + + $examples['28'] = array( + '
test', + '
test', + ); + + return $examples; + } +} diff --git a/phpunit/html/wp-html-tag-processor-standalone-test.php b/phpunit/html/wp-html-tag-processor-standalone-test.php new file mode 100644 index 00000000000000..d0b5f8288986ad --- /dev/null +++ b/phpunit/html/wp-html-tag-processor-standalone-test.php @@ -0,0 +1,1268 @@ +Text
'; + const HTML_WITH_CLASSES = '
Text
'; + const HTML_MALFORMED = '
Back to notifications
'; + + /** + * @ticket 56299 + * + * @covers get_tag + */ + public function test_get_tag_returns_null_before_finding_tags() { + $p = new WP_HTML_Tag_Processor( '
Test
' ); + $this->assertNull( $p->get_tag() ); + } + + /** + * @ticket 56299 + * + * @covers next_tag + * @covers get_tag + */ + public function test_get_tag_returns_null_when_not_in_open_tag() { + $p = new WP_HTML_Tag_Processor( '
Test
' ); + $this->assertFalse( $p->next_tag( 'p' ), 'Querying a non-existing tag did not return false' ); + $this->assertNull( $p->get_tag(), 'Accessing a non-existing tag did not return null' ); + } + + /** + * @ticket 56299 + * + * @covers next_tag + * @covers get_tag + */ + public function test_get_tag_returns_open_tag_name() { + $p = new WP_HTML_Tag_Processor( '
Test
' ); + $this->assertTrue( $p->next_tag( 'div' ), 'Querying an existing tag did not return true' ); + $this->assertSame( 'DIV', $p->get_tag(), 'Accessing an existing tag name did not return "div"' ); + } + + /** + * @ticket 56299 + * + * @covers get_attribute + */ + public function test_get_attribute_returns_null_before_finding_tags() { + $p = new WP_HTML_Tag_Processor( '
Test
' ); + $this->assertNull( $p->get_attribute( 'class' ) ); + } + + /** + * @ticket 56299 + * + * @covers next_tag + * @covers get_attribute + */ + public function test_get_attribute_returns_null_when_not_in_open_tag() { + $p = new WP_HTML_Tag_Processor( '
Test
' ); + $this->assertFalse( $p->next_tag( 'p' ), 'Querying a non-existing tag did not return false' ); + $this->assertNull( $p->get_attribute( 'class' ), 'Accessing an attribute of a non-existing tag did not return null' ); + } + + /** + * @ticket 56299 + * + * @covers next_tag + * @covers get_attribute + */ + public function test_get_attribute_returns_null_when_attribute_missing() { + $p = new WP_HTML_Tag_Processor( '
Test
' ); + $this->assertTrue( $p->next_tag( 'div' ), 'Querying an existing tag did not return true' ); + $this->assertNull( $p->get_attribute( 'test-id' ), 'Accessing a non-existing attribute did not return null' ); + } + + /** + * @ticket 56299 + * + * @covers next_tag + * @covers get_attribute + */ + public function test_get_attribute_returns_attribute_value() { + $p = new WP_HTML_Tag_Processor( '
Test
' ); + $this->assertTrue( $p->next_tag( 'div' ), 'Querying an existing tag did not return true' ); + $this->assertSame( 'test', $p->get_attribute( 'class' ), 'Accessing a class="test" attribute value did not return "test"' ); + } + + /** + * @ticket 56299 + * + * @covers next_tag + * @covers get_attribute + */ + public function test_get_attribute_returns_true_for_boolean_attribute() { + $p = new WP_HTML_Tag_Processor( '
Test
' ); + $this->assertTrue( $p->next_tag( array( 'class_name' => 'test' ) ), 'Querying an existing tag did not return true' ); + $this->assertTrue( $p->get_attribute( 'enabled' ), 'Accessing a boolean "enabled" attribute value did not return true' ); + } + + /** + * @ticket 56299 + * + * @covers next_tag + * @covers get_attribute + */ + public function test_get_attribute_returns_string_for_truthy_attributes() { + $p = new WP_HTML_Tag_Processor( '' ); + $this->assertTrue( $p->next_tag( array() ), 'Querying an existing tag did not return true' ); + $this->assertSame( 'enabled', $p->get_attribute( 'enabled' ), 'Accessing a boolean "enabled" attribute value did not return true' ); + $this->assertSame( '1', $p->get_attribute( 'checked' ), 'Accessing a checked=1 attribute value did not return "1"' ); + $this->assertSame( 'true', $p->get_attribute( 'hidden' ), 'Accessing a hidden="true" attribute value did not return "true"' ); + } + + /** + * @ticket 56299 + * + * @covers WP_HTML_Tag_Processor::get_attribute + */ + public function test_get_attribute_decodes_html_character_references() { + $p = new WP_HTML_Tag_Processor( '
' ); + $p->next_tag(); + $this->assertSame( 'the "grande" is < 32oz†', $p->get_attribute( 'id' ), 'HTML Attribute value was returned without decoding character references' ); + } + + /** + * @ticket 56299 + * + * @covers next_tag + * @covers get_attribute + */ + public function test_attributes_parser_treats_slash_as_attribute_separator() { + $p = new WP_HTML_Tag_Processor( '
Test
' ); + $this->assertTrue( $p->next_tag( array() ), 'Querying an existing tag did not return true' ); + $this->assertTrue( $p->get_attribute( 'a' ), 'Accessing an existing attribute did not return true' ); + $this->assertTrue( $p->get_attribute( 'b' ), 'Accessing an existing attribute did not return true' ); + $this->assertTrue( $p->get_attribute( 'c' ), 'Accessing an existing attribute did not return true' ); + $this->assertTrue( $p->get_attribute( 'd' ), 'Accessing an existing attribute did not return true' ); + $this->assertSame( 'test', $p->get_attribute( 'e' ), 'Accessing an existing e="test" did not return "test"' ); + } + + /** + * @ticket 56299 + * + * @covers __toString + */ + public function test_tostring_applies_the_updates_so_far_and_keeps_the_processor_on_the_current_tag() { + $p = new WP_HTML_Tag_Processor( '
Test
' ); + $p->next_tag(); + $p->remove_attribute( 'id' ); + + $p->next_tag(); + $p->set_attribute( 'id', 'div-id-1' ); + $p->add_class( 'new_class_1' ); + $this->assertSame( + '
Test
', + (string) $p, + 'Calling __toString after updating the attributes of the second tag returned different HTML than expected' + ); + + $p->set_attribute( 'id', 'div-id-2' ); + $p->add_class( 'new_class_2' ); + $this->assertSame( + '
Test
', + (string) $p, + 'Calling __toString after updating the attributes of the second tag for the second time returned different HTML than expected' + ); + + $p->next_tag(); + $p->remove_attribute( 'id' ); + $this->assertSame( + '
Test
', + (string) $p, + 'Calling __toString after removing the id attribute of the third tag returned different HTML than expected' + ); + } + + /** + * @ticket 56299 + * + * @covers __toString + */ + public function test_tostring_without_updating_any_attributes_returns_the_original_html() { + $p = new WP_HTML_Tag_Processor( self::HTML_SIMPLE ); + $this->assertSame( self::HTML_SIMPLE, (string) $p ); + } + + /** + * @ticket 56299 + * + * @covers next_tag + */ + public function test_next_tag_with_no_arguments_should_find_the_next_existing_tag() { + $p = new WP_HTML_Tag_Processor( self::HTML_SIMPLE ); + $this->assertTrue( $p->next_tag(), 'Querying an existing tag did not return true' ); + } + + /** + * @ticket 56299 + * + * @covers next_tag + */ + public function test_next_tag_should_return_false_for_a_non_existing_tag() { + $p = new WP_HTML_Tag_Processor( self::HTML_SIMPLE ); + $this->assertFalse( $p->next_tag( 'p' ), 'Querying a non-existing tag did not return false' ); + } + + /** + * @ticket 56299 + * + * @covers next_tag + * @covers __toString + */ + public function test_set_attribute_on_a_non_existing_tag_does_not_change_the_markup() { + $p = new WP_HTML_Tag_Processor( self::HTML_SIMPLE ); + $this->assertFalse( $p->next_tag( 'p' ), 'Querying a non-existing tag did not return false' ); + $this->assertFalse( $p->next_tag( 'div' ), 'Querying a non-existing tag did not return false' ); + $p->set_attribute( 'id', 'primary' ); + $this->assertSame( + self::HTML_SIMPLE, + (string) $p, + 'Calling __toString after updating a non-existing tag returned an HTML that was different from the original HTML' + ); + } + + /** + * Passing a double quote inside of an attribute values could lead to an XSS attack as follows: + * + * + * $p = new WP_HTML_Tag_Processor( '
' ); + * $p->next_tag(); + * $p->set_attribute('class', '" onclick="alert'); + * echo $p; + * //
+ *
+ * + * To prevent it, `set_attribute` calls `esc_attr()` on its given values. + * + * + *
+ *
+ * + * @ticket 56299 + * + * @dataProvider data_set_attribute_escapable_values + * @covers set_attribute + */ + public function test_set_attribute_prevents_xss( $attribute_value ) { + $p = new WP_HTML_Tag_Processor( '
' ); + $p->next_tag(); + $p->set_attribute( 'test', $attribute_value ); + + /* + * Testing the escaping is hard using tools that properly parse + * HTML because they might interpret the escaped values. It's hard + * with tools that don't understand HTML because they might get + * confused by improperly-escaped values. + * + * For this test, since we control the input HTML we're going to + * do what looks like the opposite of what we want to be doing with + * this library but are only doing so because we have full control + * over the content and because we want to look at the raw values. + */ + $match = null; + preg_match( '~^
$~', (string) $p, $match ); + list( , $actual_value ) = $match; + + $this->assertEquals( $actual_value, '"' . esc_attr( $attribute_value ) . '"' ); + } + + /** + * Data provider with HTML attribute values that might need escaping. + */ + public function data_set_attribute_escapable_values() { + return array( + array( '"' ), + array( '"' ), + array( '&' ), + array( '&' ), + array( '€' ), + array( "'" ), + array( '<>' ), + array( '"";' ), + array( '" onclick="alert(\'1\');">' ), + ); + } + + /** + * @ticket 56299 + * + * @covers set_attribute + * @covers __toString + */ + public function test_set_attribute_with_a_non_existing_attribute_adds_a_new_attribute_to_the_markup() { + $p = new WP_HTML_Tag_Processor( self::HTML_SIMPLE ); + $p->next_tag(); + $p->set_attribute( 'test-attribute', 'test-value' ); + $this->assertSame( '
Text
', (string) $p ); + } + + /** + * According to HTML spec, only the first instance of an attribute counts. + * The other ones are ignored. + * + * @ticket 56299 + * + * @covers set_attribute + * @covers __toString + */ + public function test_update_first_when_duplicated_attribute() { + $p = new WP_HTML_Tag_Processor( '
Text
' ); + $p->next_tag(); + $p->set_attribute( 'id', 'updated-id' ); + $this->assertSame( '
Text
', (string) $p ); + } + + /** + * @ticket 56299 + * + * @covers set_attribute + * @covers __toString + */ + public function test_set_attribute_with_an_existing_attribute_name_updates_its_value_in_the_markup() { + $p = new WP_HTML_Tag_Processor( self::HTML_SIMPLE ); + $p->next_tag(); + $p->set_attribute( 'id', 'new-id' ); + $this->assertSame( '
Text
', (string) $p ); + } + + /** + * @ticket 56299 + * + * @covers set_attribute + * @covers __toString + */ + public function test_next_tag_and_set_attribute_in_a_loop_update_all_tags_in_the_markup() { + $p = new WP_HTML_Tag_Processor( self::HTML_SIMPLE ); + while ( $p->next_tag() ) { + $p->set_attribute( 'data-foo', 'bar' ); + } + + $this->assertSame( '
Text
', (string) $p ); + } + + /** + * Removing an attribute that's listed many times, e.g. `
` should remove + * all its instances and output just `
`. + * + * Today, however, WP_HTML_Tag_Processor only removes the first such attribute. It seems like a corner case + * and introducing additional complexity to correctly handle this scenario doesn't seem to be worth it. + * Let's revisit if and when this becomes a problem. + * + * This test is in place to confirm this behavior, while incorrect, is well-defined. + * + * @ticket 56299 + * + * @covers remove_attribute + * @covers __toString + */ + public function test_remove_first_when_duplicated_attribute() { + $p = new WP_HTML_Tag_Processor( '
Text
' ); + $p->next_tag(); + $p->remove_attribute( 'id' ); + $this->assertSame( '
Text
', (string) $p ); + } + + /** + * @ticket 56299 + * + * @covers remove_attribute + * @covers __toString + */ + public function test_remove_attribute_with_an_existing_attribute_name_removes_it_from_the_markup() { + $p = new WP_HTML_Tag_Processor( self::HTML_SIMPLE ); + $p->next_tag(); + $p->remove_attribute( 'id' ); + $this->assertSame( '
Text
', (string) $p ); + } + + /** + * @ticket 56299 + * + * @covers remove_attribute + * @covers __toString + */ + public function test_remove_attribute_with_a_non_existing_attribute_name_does_not_change_the_markup() { + $p = new WP_HTML_Tag_Processor( self::HTML_SIMPLE ); + $p->next_tag(); + $p->remove_attribute( 'no-such-attribute' ); + $this->assertSame( self::HTML_SIMPLE, (string) $p ); + } + + /** + * @ticket 56299 + * + * @covers add_class + * @covers __toString + */ + public function test_add_class_creates_a_class_attribute_when_there_is_none() { + $p = new WP_HTML_Tag_Processor( self::HTML_SIMPLE ); + $p->next_tag(); + $p->add_class( 'foo-class' ); + $this->assertSame( '
Text
', (string) $p ); + } + + /** + * @ticket 56299 + * + * @covers add_class + * @covers __toString + */ + public function test_calling_add_class_twice_creates_a_class_attribute_with_both_class_names_when_there_is_no_class_attribute() { + $p = new WP_HTML_Tag_Processor( self::HTML_SIMPLE ); + $p->next_tag(); + $p->add_class( 'foo-class' ); + $p->add_class( 'bar-class' ); + $this->assertSame( '
Text
', (string) $p ); + } + + /** + * @ticket 56299 + * + * @covers remove_class + * @covers __toString + */ + public function test_remove_class_does_not_change_the_markup_when_there_is_no_class_attribute() { + $p = new WP_HTML_Tag_Processor( self::HTML_SIMPLE ); + $p->next_tag(); + $p->remove_class( 'foo-class' ); + $this->assertSame( self::HTML_SIMPLE, (string) $p ); + } + + /** + * @ticket 56299 + * + * @covers add_class + * @covers __toString + */ + public function test_add_class_appends_class_names_to_the_existing_class_attribute_when_one_already_exists() { + $p = new WP_HTML_Tag_Processor( self::HTML_WITH_CLASSES ); + $p->next_tag(); + $p->add_class( 'foo-class' ); + $p->add_class( 'bar-class' ); + $this->assertSame( + '
Text
', + (string) $p + ); + } + + /** + * @ticket 56299 + * + * @covers remove_class + * @covers __toString + */ + public function test_remove_class_removes_a_single_class_from_the_class_attribute_when_one_exists() { + $p = new WP_HTML_Tag_Processor( self::HTML_WITH_CLASSES ); + $p->next_tag(); + $p->remove_class( 'main' ); + $this->assertSame( + '
Text
', + (string) $p + ); + } + + /** + * @ticket 56299 + * + * @covers remove_class + * @covers __toString + */ + public function test_calling_remove_class_with_all_listed_class_names_removes_the_existing_class_attribute_from_the_markup() { + $p = new WP_HTML_Tag_Processor( self::HTML_WITH_CLASSES ); + $p->next_tag(); + $p->remove_class( 'main' ); + $p->remove_class( 'with-border' ); + $this->assertSame( + '
Text
', + (string) $p + ); + } + + /** + * @ticket 56299 + * + * @covers add_class + * @covers __toString + */ + public function test_add_class_does_not_add_duplicate_class_names() { + $p = new WP_HTML_Tag_Processor( self::HTML_WITH_CLASSES ); + $p->next_tag(); + $p->add_class( 'with-border' ); + $this->assertSame( + '
Text
', + (string) $p + ); + } + + /** + * @ticket 56299 + * + * @covers add_class + * @covers __toString + */ + public function test_add_class_preserves_class_name_order_when_a_duplicate_class_name_is_added() { + $p = new WP_HTML_Tag_Processor( self::HTML_WITH_CLASSES ); + $p->next_tag(); + $p->add_class( 'main' ); + $this->assertSame( + '
Text
', + (string) $p + ); + } + + /** + * @ticket 56299 + * + * @covers add_class + * @covers __toString + */ + public function test_add_class_when_there_is_a_class_attribute_with_excessive_whitespaces() { + $p = new WP_HTML_Tag_Processor( + '
Text
' + ); + $p->next_tag(); + $p->add_class( 'foo-class' ); + $this->assertSame( + '
Text
', + (string) $p + ); + } + + /** + * @ticket 56299 + * + * @covers remove_class + * @covers __toString + */ + public function test_remove_class_preserves_whitespaces_when_there_is_a_class_attribute_with_excessive_whitespaces() { + $p = new WP_HTML_Tag_Processor( + '
Text
' + ); + $p->next_tag(); + $p->remove_class( 'with-border' ); + $this->assertSame( + '
Text
', + (string) $p + ); + } + + /** + * @ticket 56299 + * + * @covers remove_class + * @covers __toString + */ + public function test_removing_all_classes_removes_the_existing_class_attribute_from_the_markup_even_when_excessive_whitespaces_are_present() { + $p = new WP_HTML_Tag_Processor( + '
Text
' + ); + $p->next_tag(); + $p->remove_class( 'main' ); + $p->remove_class( 'with-border' ); + $this->assertSame( + '
Text
', + (string) $p + ); + } + + /** + * When both set_attribute('class', $value) and add_class( $different_value ) are called, + * the final class name should be $value. In other words, the `add_class` call should be ignored, + * and the `set_attribute` call should win. This holds regardless of the order in which these methods + * are called. + * + * @ticket 56299 + * + * @covers add_class + * @covers set_attribute + * @covers __toString + */ + public function test_set_attribute_takes_priority_over_add_class() { + $p = new WP_HTML_Tag_Processor( self::HTML_WITH_CLASSES ); + $p->next_tag(); + $p->add_class( 'add_class' ); + $p->set_attribute( 'class', 'set_attribute' ); + $this->assertSame( + '
Text
', + (string) $p, + 'Calling __toString after updating first tag\'s attributes did not return the expected HTML' + ); + + $p = new WP_HTML_Tag_Processor( self::HTML_WITH_CLASSES ); + $p->next_tag(); + $p->set_attribute( 'class', 'set_attribute' ); + $p->add_class( 'add_class' ); + $this->assertSame( + '
Text
', + (string) $p, + 'Calling __toString after updating second tag\'s attributes did not return the expected HTML' + ); + } + + /** + * @ticket 56299 + * + * @covers set_attribute + * @covers remove_attribute + * @covers add_class + * @covers remove_class + * @covers __toString + */ + public function test_advanced_use_case() { + $input = << +
+
+
+ + + + + + + +
+
+
+HTML; + + $expected_output = << +
+
+
+ + + + + + + +
+
+
+HTML; + + $p = new WP_HTML_Tag_Processor( $input ); + $this->assertTrue( $p->next_tag( 'div' ), 'Querying an existing tag did not return true' ); + $p->set_attribute( 'data-details', '{ "key": "value" }' ); + $p->add_class( 'is-processed' ); + $this->assertTrue( + $p->next_tag( + array( + 'tag_name' => 'div', + 'class_name' => 'BtnGroup', + ) + ), + 'Querying an existing tag did not return true' + ); + $p->remove_class( 'BtnGroup' ); + $p->add_class( 'button-group' ); + $p->add_class( 'Another-Mixed-Case' ); + $this->assertTrue( + $p->next_tag( + array( + 'tag_name' => 'div', + 'class_name' => 'BtnGroup', + ) + ), + 'Querying an existing tag did not return true' + ); + $p->remove_class( 'BtnGroup' ); + $p->add_class( 'button-group' ); + $p->add_class( 'Another-Mixed-Case' ); + $this->assertTrue( + $p->next_tag( + array( + 'tag_name' => 'button', + 'class_name' => 'btn', + 'match_offset' => 3, + ) + ), + 'Querying an existing tag did not return true' + ); + $p->remove_attribute( 'class' ); + $this->assertFalse( $p->next_tag( 'non-existent' ), 'Querying a non-existing tag did not return false' ); + $p->set_attribute( 'class', 'test' ); + $this->assertSame( $expected_output, (string) $p, 'Calling __toString after updating the attributes did not return the expected HTML' ); + } + + /** + * @ticket 56299 + * + * @covers remove_attribute + * @covers set_attribute + * @covers __toString + */ + public function test_correctly_parses_html_attributes_wrapped_in_single_quotation_marks() { + $p = new WP_HTML_Tag_Processor( + '
Text
' + ); + $p->next_tag( + array( + 'tag_name' => 'div', + 'id' => 'first', + ) + ); + $p->remove_attribute( 'id' ); + $p->next_tag( + array( + 'tag_name' => 'span', + 'id' => 'second', + ) + ); + $p->set_attribute( 'id', 'single-quote' ); + $this->assertSame( + '
Text
', + (string) $p + ); + } + + /** + * @ticket 56299 + * + * @covers set_attribute + * @covers __toString + */ + public function test_set_attribute_with_value_equals_to_true_adds_a_boolean_html_attribute_with_implicit_value() { + $p = new WP_HTML_Tag_Processor( + '
' + ); + $p->next_tag( 'input' ); + $p->set_attribute( 'checked', true ); + $this->assertSame( + '
', + (string) $p + ); + } + + /** + * @ticket 56299 + * + * @covers set_attribute + * @covers __toString + */ + public function test_setting_a_boolean_attribute_to_false_removes_it_from_the_markup() { + $p = new WP_HTML_Tag_Processor( + '
' + ); + $p->next_tag( 'input' ); + $p->set_attribute( 'checked', false ); + $this->assertSame( + '
', + (string) $p + ); + } + + /** + * @ticket 56299 + * + * @covers set_attribute + * @covers __toString + */ + public function test_setting_a_missing_attribute_to_false_does_not_change_the_markup() { + $html_input = '
'; + $p = new WP_HTML_Tag_Processor( $html_input ); + $p->next_tag( 'input' ); + $p->set_attribute( 'checked', false ); + $this->assertSame( $html_input, (string) $p ); + } + + /** + * @ticket 56299 + * + * @covers set_attribute + * @covers __toString + */ + public function test_setting_a_boolean_attribute_to_a_string_value_adds_explicit_value_to_the_markup() { + $p = new WP_HTML_Tag_Processor( + '
' + ); + $p->next_tag( 'input' ); + $p->set_attribute( 'checked', 'checked' ); + $this->assertSame( + '
', + (string) $p + ); + } + + /** + * @ticket 56299 + * + * @covers get_tag + * @covers next_tag + */ + public function test_unclosed_script_tag_should_not_cause_an_infinite_loop() { + $p = new WP_HTML_Tag_Processor( '
', + ); + + $examples['Simple uppercase script tag'] = array( + '
', + ); + + $examples['Script with a comment opener inside should end at the next script tag closer (dash dash escaped state)'] = array( + '
-->', + ); + + $examples['Script with a comment opener and a script tag opener inside should end two script tag closer later (double escaped state)'] = array( + '
-->', + ); + + $examples['Double escaped script with a tricky opener'] = array( + '">
', + ); + + $examples['Double escaped script with a tricky closer'] = array( + '">
', + ); + + $examples['Double escaped, then escaped, then double escaped'] = array( + '
', + ); + + $examples['Script with a commented a script tag opener inside should at the next tag closer (dash dash escaped state)'] = array( + '
-->', + ); + + $examples['Script closer with another script tag in closer attributes'] = array( + '
', + ); + + $examples['Script closer with attributes'] = array( + '
', + ); + + $examples['Script opener with title closer inside'] = array( + '
', + ); + + $examples['Complex script with many parsing states'] = array( + '-->
-->', + ); + return $examples; + } + + /** + * @ticket 56299 + * + * @covers next_tag + * + * @dataProvider data_rcdata_state + */ + public function test_next_tag_ignores_the_contents_of_a_rcdata_tag( $rcdata_then_div, $rcdata_tag ) { + $p = new WP_HTML_Tag_Processor( $rcdata_then_div ); + $p->next_tag(); + $this->assertSame( $rcdata_tag, $p->get_tag(), "The first found tag was not '$rcdata_tag'" ); + $p->next_tag(); + $this->assertSame( 'DIV', $p->get_tag(), "The second found tag was not 'div'" ); + } + + /** + * Data provider for test_ignores_contents_of_a_rcdata_tag(). + * + * @return array { + * @type array { + * @type string $rcdata_then_div The HTML snippet containing RCDATA and div tags. + * @type string $rcdata_tag The RCDATA tag. + * } + * } + */ + public function data_rcdata_state() { + $examples = array(); + $examples['Simple textarea'] = array( + '
', + 'TEXTAREA', + ); + + $examples['Simple title'] = array( + '<span class="d-none d-md-inline">Back to notifications</title</span>
', + 'TITLE', + ); + + $examples['Comment opener inside a textarea tag should be ignored'] = array( + '
-->', + 'TEXTAREA', + ); + + $examples['Textarea closer with another textarea tag in closer attributes'] = array( + '
', + 'TEXTAREA', + ); + + $examples['Textarea closer with attributes'] = array( + '
', + 'TEXTAREA', + ); + + $examples['Textarea opener with title closer inside'] = array( + '
', + 'TEXTAREA', + ); + return $examples; + } + + /** + * @ticket 56299 + * + * @covers next_tag + * @covers set_attribute + * @covers __toString + */ + public function test_can_query_and_update_wrongly_nested_tags() { + $p = new WP_HTML_Tag_Processor( + '123

456789

' + ); + $p->next_tag( 'span' ); + $p->set_attribute( 'class', 'span-class' ); + $p->next_tag( 'p' ); + $p->set_attribute( 'class', 'p-class' ); + $this->assertSame( + '123

456789

', + (string) $p + ); + } + + /** + * @ticket 56299 + * + * @covers next_tag + * @covers remove_attribute + * @covers __toString + */ + public function test_removing_attributes_works_even_in_malformed_html() { + $p = new WP_HTML_Tag_Processor( self::HTML_MALFORMED ); + $p->next_tag( 'span' ); + $p->remove_attribute( 'Notifications<' ); + $this->assertSame( + '
Back to notifications
', + (string) $p + ); + } + + /** + * @ticket 56299 + * + * @covers next_Tag + * @covers set_attribute + * @covers __toString + */ + public function test_updating_attributes_works_even_in_malformed_html_1() { + $p = new WP_HTML_Tag_Processor( self::HTML_MALFORMED ); + $p->next_tag( 'span' ); + $p->set_attribute( 'id', 'first' ); + $p->next_tag( 'span' ); + $p->set_attribute( 'id', 'second' ); + $this->assertSame( + '
Back to notifications
', + (string) $p + ); + } + + /** + * @ticket 56299 + * + * @covers next_tag + * @covers set_attribute + * @covers add_class + * @covers __toString + * + * @dataProvider data_malformed_tag + */ + public function test_updating_attributes_works_even_in_malformed_html_2( $html_input, $html_expected ) { + $p = new WP_HTML_Tag_Processor( $html_input ); + $p->next_tag(); + $p->set_attribute( 'foo', 'bar' ); + $p->add_class( 'firstTag' ); + $p->next_tag(); + $p->add_class( 'secondTag' ); + $this->assertSame( + $html_expected, + (string) $p + ); + } + + /** + * Data provider for test_updates_when_malformed_tag(). + * + * @return array { + * @type array { + * @type string $html_input The input HTML snippet. + * @type string $html_expected The expected HTML snippet after processing. + * } + * } + */ + public function data_malformed_tag() { + $null_byte = chr( 0 ); + $examples = array(); + $examples['Invalid entity inside attribute value'] = array( + 'test', + 'test', + ); + + $examples['HTML tag opening inside attribute value'] = array( + '
This <is> a <strong is="true">thing.
test', + '
This <is> a <strong is="true">thing.
test', + ); + + $examples['HTML tag brackets in attribute values and data markup'] = array( + '
This <is> a <strong is="true">thing.
test', + '
This <is> a <strong is="true">thing.
test', + ); + + $examples['Single and double quotes in attribute value'] = array( + '

test', + '

test', + ); + + $examples['Unquoted attribute values'] = array( + '


test', + '
test', + ); + + $examples['Double-quotes escaped in double-quote attribute value'] = array( + '
test', + '
test', + ); + + $examples['Unquoted attribute value'] = array( + '
test', + '
test', + ); + + $examples['Unquoted attribute value with tag-like value'] = array( + '
>test', + '
>test', + ); + + $examples['Unquoted attribute value with tag-like value followed by tag-like data'] = array( + '
>test', + '
>test', + ); + + $examples['1'] = array( + '
test', + '
test', + ); + + $examples['2'] = array( + '
test', + '
test', + ); + + $examples['4'] = array( + '
test', + '
test', + ); + + $examples['5'] = array( + '
code>test', + '
code>test', + ); + + $examples['6'] = array( + '
test', + '
test', + ); + + $examples['7'] = array( + '
test', + '
test', + ); + + $examples['8'] = array( + '
id="test">test', + '
id="test">test', + ); + + $examples['9'] = array( + '
test', + '
test', + ); + + $examples['10'] = array( + 'test', + 'test', + ); + + $examples['11'] = array( + 'The applicative operator <* works well in Haskell; is what?test', + 'The applicative operator <* works well in Haskell; is what?test', + ); + + $examples['12'] = array( + '<3 is a heart but is a tag.test', + '<3 is a heart but is a tag.test', + ); + + $examples['13'] = array( + 'test', + 'test', + ); + + $examples['14'] = array( + 'test', + 'test', + ); + + $examples['15'] = array( + ' a HTML Tag]]>test', + ' a HTML Tag]]>test', + ); + + $examples['16'] = array( + '
test', + '
test', + ); + + $examples['17'] = array( + '
test', + '
test', + ); + + $examples['18'] = array( + '
test', + '
test', + ); + + $examples['19'] = array( + '
test', + '
test', + ); + + $examples['20'] = array( + '
test', + '
test', + ); + + $examples['21'] = array( + '
test', + '
test', + ); + + $examples['22'] = array( + '
test', + '
test', + ); + + $examples['23'] = array( + '
test', + '
test', + ); + + $examples['24'] = array( + '
test', + '
test', + ); + + $examples['25'] = array( + '
test', + '
test', + ); + + $examples['Multiple unclosed tags treated as a single tag'] = array( + '
+test', + '
+test', + ); + + $examples['27'] = array( + '
test', + '
test', + ); + + $examples['28'] = array( + '
test', + '
test', + ); + + return $examples; + } +} diff --git a/phpunit/html/wp-html-tag-processor-wp-test.php b/phpunit/html/wp-html-tag-processor-wp-test.php new file mode 100644 index 00000000000000..6c3a30340b2677 --- /dev/null +++ b/phpunit/html/wp-html-tag-processor-wp-test.php @@ -0,0 +1,91 @@ + + * $p = new WP_HTML_Tag_Processor( '
' ); + * $p->next_tag(); + * $p->set_attribute('class', '" onclick="alert'); + * echo $p; + * //
+ * + * + * To prevent it, `set_attribute` calls `esc_attr()` on its given values. + * + * + *
+ *
+ * + * @ticket 56299 + * + * @dataProvider data_set_attribute_escapable_values + * @covers set_attribute + */ + public function test_set_attribute_prevents_xss( $value_to_set, $expected_result ) { + $p = new WP_HTML_Tag_Processor( '
' ); + $p->next_tag(); + $p->set_attribute( 'test', $value_to_set ); + + /* + * Testing the escaping is hard using tools that properly parse + * HTML because they might interpret the escaped values. It's hard + * with tools that don't understand HTML because they might get + * confused by improperly-escaped values. + * + * For this test, since we control the input HTML we're going to + * do what looks like the opposite of what we want to be doing with + * this library but are only doing so because we have full control + * over the content and because we want to look at the raw values. + */ + $match = null; + preg_match( '~^
$~', (string) $p, $match ); + list( , $actual_value ) = $match; + + $this->assertEquals( $actual_value, '"' . $expected_result . '"' ); + } + + /** + * Data provider with HTML attribute values that might need escaping. + */ + public function data_set_attribute_escapable_values() { + return array( + array( '"', '"' ), + array( '"', '"' ), + array( '&', '&' ), + array( '&', '&' ), + array( '€', '€' ), + array( "'", ''' ), + array( '<>', '<>' ), + array( '"";', '&quot";' ), + array( + '" onclick="alert(\'1\');">', + '" onclick="alert('1');"><span onclick=""></span><script>alert("1")</script>', + ), + ); + } + +} diff --git a/phpunit/navigation-page-test.php b/phpunit/navigation-page-test.php index 8cfb4170663d56..3209f77d824497 100644 --- a/phpunit/navigation-page-test.php +++ b/phpunit/navigation-page-test.php @@ -83,7 +83,7 @@ public function test_gutenberg_navigation_editor_preload_menus_initializes_creat $this->callback ->expects( $this->once() ) ->method( 'preload_menus_rest_pre_dispatch_callback' ) - ->willReturn( new $response ); + ->willReturn( $response ); gutenberg_navigation_editor_preload_menus(); diff --git a/schemas/json/theme.json b/schemas/json/theme.json index 62e27ff14611d9..947509c0377749 100644 --- a/schemas/json/theme.json +++ b/schemas/json/theme.json @@ -8,6 +8,7 @@ "reference": "https://developer.wordpress.org/block-editor/how-to-guides/themes/theme-json/" }, "settingsPropertiesAppearanceTools": { + "type": "object", "properties": { "appearanceTools": { "description": "Setting that enables the following UI tools:\n\n- border: color, radius, style, width\n- color: link\n- spacing: blockGap, margin, padding\n- typography: lineHeight", @@ -26,6 +27,7 @@ } }, "settingsPropertiesBorder": { + "type": "object", "properties": { "border": { "description": "Settings related to borders.", @@ -57,6 +59,7 @@ } }, "settingsPropertiesColor": { + "type": "object", "properties": { "color": { "description": "Settings related to colors.", @@ -186,6 +189,7 @@ } }, "settingsPropertiesLayout": { + "type": "object", "properties": { "layout": { "description": "Settings related to layout.", @@ -205,6 +209,7 @@ } }, "settingsPropertiesSpacing": { + "type": "object", "properties": { "spacing": { "description": "Settings related to spacing.", @@ -310,6 +315,7 @@ } }, "settingsPropertiesTypography": { + "type": "object", "properties": { "typography": { "description": "Settings related to typography.", @@ -379,18 +385,25 @@ }, "fluid": { "description": "Specifics the minimum and maximum font size value of a fluid font size. Set to `false` to bypass fluid calculations and use the static `size` value.", - "type": [ "object", "boolean" ], - "properties": { - "min": { - "description": "A min font size for fluid font size calculations in px, rem or em.", - "type": "string" + "oneOf": [ + { + "type": "object", + "properties": { + "min": { + "description": "A min font size for fluid font size calculations in px, rem or em.", + "type": "string" + }, + "max": { + "description": "A max font size for fluid font size calculations in px, rem or em.", + "type": "string" + } + }, + "additionalProperties": false }, - "max": { - "description": "A max font size for fluid font size calculations in px, rem or em.", - "type": "string" + { + "type": "boolean" } - }, - "additionalProperties": false + ] } }, "additionalProperties": false @@ -432,7 +445,6 @@ }, "fontWeight": { "description": "List of available font weights, separated by a space.", - "type": "string", "default": "400", "oneOf": [ { @@ -451,7 +463,8 @@ "auto", "block", "fallback", - "swap" + "swap", + "optional" ] }, "src": { @@ -520,6 +533,7 @@ } }, "settingsPropertiesCustom": { + "type": "object", "properties": { "custom": { "description": "Generate custom CSS custom properties of the form `--wp--custom--{key}--{nested-key}: {value};`. `camelCased` keys are transformed to `kebab-case` as to follow the CSS property naming schema. Keys at different depth levels are separated by `--`, so keys should not include `--` in the name.", @@ -562,6 +576,7 @@ "type": "object", "properties": { "core/archives": { + "type": "object", "description": "Archive block. Display a monthly archive of your posts. This block has no block-level settings", "additionalProperties": false }, @@ -575,11 +590,13 @@ "$ref": "#/definitions/settingsPropertiesComplete" }, "core/button": { + "type": "object", "allOf": [ { "$ref": "#/definitions/settingsPropertiesAppearanceTools" }, { + "type": "object", "properties": { "border": { "description": "Settings related to borders.\nGutenberg plugin required.", @@ -862,6 +879,7 @@ } }, "stylesProperties": { + "type": "object", "properties": { "border": { "description": "Border styles.", @@ -909,60 +927,76 @@ "type": "string" }, "top": { - "color": { - "description": "Sets the `border-top-color` CSS property.", - "type": "string" - }, - "style": { - "description": "Sets the `border-top-style` CSS property.", - "type": "string" + "type": "object", + "properties": { + "color": { + "description": "Sets the `border-top-color` CSS property.", + "type": "string" + }, + "style": { + "description": "Sets the `border-top-style` CSS property.", + "type": "string" + }, + "width": { + "description": "Sets the `border-top-width` CSS property.", + "type": "string" + } }, - "width": { - "description": "Sets the `border-top-width` CSS property.", - "type": "string" - } + "additionalProperties": false }, "right": { - "color": { - "description": "Sets the `border-right-color` CSS property.", - "type": "string" - }, - "style": { - "description": "Sets the `border-right-style` CSS property.", - "type": "string" + "type": "object", + "properties": { + "color": { + "description": "Sets the `border-right-color` CSS property.", + "type": "string" + }, + "style": { + "description": "Sets the `border-right-style` CSS property.", + "type": "string" + }, + "width": { + "description": "Sets the `border-right-width` CSS property.", + "type": "string" + } }, - "width": { - "description": "Sets the `border-right-width` CSS property.", - "type": "string" - } + "additionalProperties": false }, "bottom": { - "color": { - "description": "Sets the `border-bottom-color` CSS property.", - "type": "string" - }, - "style": { - "description": "Sets the `border-bottom-style` CSS property.", - "type": "string" + "type": "object", + "properties": { + "color": { + "description": "Sets the `border-bottom-color` CSS property.", + "type": "string" + }, + "style": { + "description": "Sets the `border-bottom-style` CSS property.", + "type": "string" + }, + "width": { + "description": "Sets the `border-bottom-width` CSS property.", + "type": "string" + } }, - "width": { - "description": "Sets the `border-bottom-width` CSS property.", - "type": "string" - } + "additionalProperties": false }, "left": { - "color": { - "description": "Sets the `border-left-color` CSS property.", - "type": "string" - }, - "style": { - "description": "Sets the `border-left-style` CSS property.", - "type": "string" + "type": "object", + "properties": { + "color": { + "description": "Sets the `border-left-color` CSS property.", + "type": "string" + }, + "style": { + "description": "Sets the `border-left-style` CSS property.", + "type": "string" + }, + "width": { + "description": "Sets the `border-left-width` CSS property.", + "type": "string" + } }, - "width": { - "description": "Sets the `border-left-width` CSS property.", - "type": "string" - } + "additionalProperties": false } }, "additionalProperties": false @@ -1145,7 +1179,33 @@ "type": "object", "properties": { "button": { - "$ref": "#/definitions/stylesPropertiesComplete" + "type": "object", + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "properties": { + "border": {}, + "color": {}, + "filter": {}, + "outline": {}, + "shadow": {}, + "spacing": {}, + "typography": {}, + ":hover": { + "$ref": "#/definitions/stylesPropertiesComplete" + }, + ":focus": { + "$ref": "#/definitions/stylesPropertiesComplete" + }, + ":active": { + "$ref": "#/definitions/stylesPropertiesComplete" + } + }, + "additionalProperties": false + } + ] }, "link": { "type": "object", diff --git a/test/e2e/specs/editor/blocks/paragraph.spec.js b/test/e2e/specs/editor/blocks/paragraph.spec.js index d4848234986f2b..6a043153320d83 100644 --- a/test/e2e/specs/editor/blocks/paragraph.spec.js +++ b/test/e2e/specs/editor/blocks/paragraph.spec.js @@ -1,3 +1,8 @@ +/** + * External dependencies + */ +const path = require( 'path' ); + /** * WordPress dependencies */ @@ -28,4 +33,160 @@ test.describe( 'Paragraph', () => { // style. expect( firstBlockTagName ).toBe( 'P' ); } ); + + test.describe( 'Empty paragraph', () => { + test.use( { + // Make the viewport large enough so that a scrollbar isn't displayed. + // Otherwise, the page scrolling can interfere with the test runner's + // ability to drop a block in the right location. + viewport: { + width: 960, + height: 1024, + }, + } ); + + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.deleteAllMedia(); + } ); + + test.afterEach( async ( { requestUtils } ) => { + await requestUtils.deleteAllMedia(); + } ); + + test( 'should allow dropping an image on en empty paragraph block', async ( { + editor, + page, + pageUtils, + } ) => { + await editor.insertBlock( { name: 'core/paragraph' } ); + + const testImageName = '10x10_e2e_test_image_z9T8jK.png'; + const testImagePath = path.join( + __dirname, + '../../../assets', + testImageName + ); + + const { dragOver, drop } = await pageUtils.dragFiles( + testImagePath + ); + + await dragOver( '[data-type="core/paragraph"]' ); + + await expect( + page.locator( 'data-testid=empty-paragraph-drop-zone' ) + ).toBeVisible(); + + await drop(); + + const imageBlock = page.locator( + 'role=document[name="Block: Image"i]' + ); + await expect( imageBlock ).toBeVisible(); + await expect( imageBlock.locator( 'role=img' ) ).toHaveAttribute( + 'src', + new RegExp( testImageName.replace( '.', '\\.' ) ) + ); + } ); + + test( 'should allow dropping blocks on en empty paragraph block', async ( { + editor, + page, + } ) => { + await editor.insertBlock( { + name: 'core/heading', + attributes: { content: 'My Heading' }, + } ); + await editor.insertBlock( { name: 'core/paragraph' } ); + await page.focus( 'text=My Heading' ); + await editor.showBlockToolbar(); + + const dragHandle = page.locator( + 'role=toolbar[name="Block tools"i] >> role=button[name="Drag"i][include-hidden]' + ); + await dragHandle.hover(); + await page.mouse.down(); + + const emptyParagraph = page.locator( + '[data-type="core/paragraph"][data-empty="true"]' + ); + const boundingBox = await emptyParagraph.boundingBox(); + // Call the move function twice to make sure the `dragOver` event is sent. + // @see https://github.com/microsoft/playwright/issues/17153 + for ( let i = 0; i < 2; i += 1 ) { + await page.mouse.move( boundingBox.x, boundingBox.y ); + } + + await expect( + page.locator( 'data-testid=empty-paragraph-drop-zone' ) + ).toBeVisible(); + + await page.mouse.up(); + + await expect.poll( editor.getEditedPostContent ) + .toBe( ` +

My Heading

+` ); + } ); + + test( 'should allow dropping HTML on en empty paragraph block', async ( { + editor, + page, + } ) => { + await editor.insertBlock( { name: 'core/paragraph' } ); + + // Insert a dummy draggable element on the page to simulate dragging + // HTML from other places. + await page.evaluate( () => { + const draggable = document.createElement( 'div' ); + draggable.draggable = true; + draggable.style.width = '10px'; + draggable.style.height = '10px'; + // Position it at the top left corner for convenience. + draggable.style.position = 'fixed'; + draggable.style.top = 0; + draggable.style.left = 0; + draggable.style.zIndex = 999999; + + draggable.addEventListener( + 'dragstart', + ( event ) => { + // Set the data transfer to some HTML on dragstart. + event.dataTransfer.setData( + 'text/html', + '

My Heading

' + ); + }, + { once: true } + ); + + document.body.appendChild( draggable ); + } ); + + // This is where the dummy draggable element is at. + await page.mouse.move( 0, 0 ); + await page.mouse.down(); + + const emptyParagraph = page.locator( + '[data-type="core/paragraph"][data-empty="true"]' + ); + const boundingBox = await emptyParagraph.boundingBox(); + // Call the move function twice to make sure the `dragOver` event is sent. + // @see https://github.com/microsoft/playwright/issues/17153 + for ( let i = 0; i < 2; i += 1 ) { + await page.mouse.move( boundingBox.x, boundingBox.y ); + } + + await expect( + page.locator( 'data-testid=empty-paragraph-drop-zone' ) + ).toBeVisible(); + + await page.mouse.up(); + + await expect.poll( editor.getEditedPostContent ) + .toBe( ` +

My Heading

+` ); + } ); + } ); } ); diff --git a/test/e2e/specs/editor/plugins/__snapshots__/Iframed-block-Should-save-the-changes-1-chromium.txt b/test/e2e/specs/editor/plugins/__snapshots__/Iframed-block-Should-save-the-changes-1-chromium.txt new file mode 100644 index 00000000000000..bcc47c713c778a --- /dev/null +++ b/test/e2e/specs/editor/plugins/__snapshots__/Iframed-block-Should-save-the-changes-1-chromium.txt @@ -0,0 +1,3 @@ + +

Iframed Block (saved)

+ \ No newline at end of file diff --git a/test/e2e/specs/editor/plugins/iframed-block.spec.js b/test/e2e/specs/editor/plugins/iframed-block.spec.js new file mode 100644 index 00000000000000..55b67cb70fe86e --- /dev/null +++ b/test/e2e/specs/editor/plugins/iframed-block.spec.js @@ -0,0 +1,46 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'Iframed block', () => { + test.beforeEach( async ( { requestUtils, admin } ) => { + await requestUtils.activatePlugin( 'gutenberg-test-iframed-block' ); + await admin.createNewPost( { postType: 'page' } ); + } ); + + test.afterEach( async ( { requestUtils } ) => { + await requestUtils.deactivatePlugin( 'gutenberg-test-iframed-block' ); + } ); + + test( 'Should save the changes', async ( { editor, page } ) => { + await editor.insertBlock( { name: 'test/iframed-block' } ); + expect( await editor.getEditedPostContent() ).toMatchSnapshot(); + + await expect( + page.locator( 'role=document[name="Block: Iframed Block"i]' ) + ).toContainText( 'Iframed Block (set with jQuery)' ); + + // open page from sidebar settings + await page.click( + 'role=region[name="Editor settings"i] >> role=button[name="Page"i]' + ); + + // Opens the template editor with a newly created template. + await page.click( 'role=button[name="Select template"i]' ); + await page.click( 'role=button[name="Add template"i]' ); + await page.fill( 'role=textbox[name="NAME"i]', 'Test template' ); + await page.click( 'role=button[name="Create"i]' ); + + // Expect iframe canvas to be visible + await expect( + page.locator( 'iframe[name="editor-canvas"]' ) + ).toBeVisible(); + + // Expect the script to load in the iframe, which replaces the block text. + const iframedText = page.frameLocator( 'iframe' ).locator( 'body' ); + await expect( iframedText ).toContainText( + 'Iframed Block (set with jQuery)' + ); + } ); +} ); diff --git a/test/e2e/specs/editor/various/autocomplete-and-mentions.spec.js b/test/e2e/specs/editor/various/autocomplete-and-mentions.spec.js new file mode 100644 index 00000000000000..67270fb2cc3570 --- /dev/null +++ b/test/e2e/specs/editor/various/autocomplete-and-mentions.spec.js @@ -0,0 +1,412 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +const userList = [ + { + username: 'testuser', + firstName: 'Jane', + lastName: 'Doe', + password: 'iLoVeE2EtEsTs', + }, + { + username: 'yourfather', + firstName: 'Darth', + lastName: 'Vader', + password: 'padme123', + }, + { + username: 'mockingjay', + firstName: 'Katniss', + lastName: 'Everdeen', + password: 'district12forlyfe', + }, + { + username: 'ringbearer', + firstName: 'Frodo', + lastName: 'Baggins', + password: 'ep1cburgl@r', + }, + { + username: 'thebetterhobbit', + firstName: 'Bilbo', + lastName: 'Baggins', + password: 'lostwithouts@m', + }, + { + username: 'makeitso', + firstName: 'Jean-Luc', + lastName: 'Picard', + password: 'engagE!1', + }, + { + username: 'buddytheelf', + firstName: 'Buddy', + lastName: 'Elf', + password: 'sm1lingsmyfavorite', + }, +]; +test.describe( 'Autocomplete', () => { + test.beforeAll( async ( { requestUtils } ) => { + await Promise.all( + userList.map( ( user ) => + requestUtils.createUser( { + email: `${ user.username }@example.com`, + ...user, + } ) + ) + ); + await requestUtils.activatePlugin( 'gutenberg-test-autocompleter' ); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.deleteAllUsers(); + await requestUtils.deactivatePlugin( 'gutenberg-test-autocompleter' ); + } ); + + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost(); + } ); + + test.afterEach( async ( { editor } ) => { + await editor.publishPost(); + } ); + + [ + [ 'User Mention', 'mention' ], + [ 'Custom Completer', 'option' ], + ].forEach( ( completerAndOptionType ) => { + const [ completer, type ] = completerAndOptionType; + test( `${ completer }: should insert ${ type }`, async ( { + page, + editor, + } ) => { + // Set up test data for each case + const testData = {}; + if ( type === 'mention' ) { + testData.triggerString = 'I am @da'; + testData.optionText = 'Darth Vader yourfather'; + testData.snapshot = ` +

I am @yourfather.

+`; + } else if ( type === 'option' ) { + testData.triggerString = 'I like ~s'; + testData.optionText = '🍓 Strawberry'; + testData.snapshot = ` +

I like 🍓.

+`; + } + + await page.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( testData.triggerString ); + await expect( + page.locator( `role=option[name="${ testData.optionText }"i]` ) + ).toBeVisible(); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '.' ); + + await expect + .poll( editor.getEditedPostContent ) + .toBe( testData.snapshot ); + } ); + + test( `${ completer }: should insert ${ type } between two other words`, async ( { + page, + editor, + pageUtils, + } ) => { + const testData = {}; + if ( type === 'mention' ) { + testData.triggerString = '@j'; + testData.optionText = 'Jane Doe testuser'; + testData.snapshot = ` +

Stuck in the middle with @testuser you.

+`; + } else if ( type === 'option' ) { + testData.triggerString = 'a ~m'; + testData.optionText = '🥭 Mango'; + testData.snapshot = ` +

Stuck in the middle with a 🥭 you.

+`; + } + + await page.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( 'Stuck in the middle with you.' ); + await pageUtils.pressKeyTimes( 'ArrowLeft', 'you.'.length ); + await page.keyboard.type( testData.triggerString ); + await expect( + page.locator( `role=option[name="${ testData.optionText }"i]` ) + ).toBeVisible(); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( ' ' ); + await expect + .poll( editor.getEditedPostContent ) + .toBe( testData.snapshot ); + } ); + + test( `${ completer }: should insert two subsequent ${ type }s`, async ( { + page, + editor, + } ) => { + const testData = {}; + if ( type === 'mention' ) { + testData.firstTriggerString = + 'The two greatest hobbits, in order: @bi'; + testData.secondTriggerString = ' @fr'; + testData.firstOptionText = 'Bilbo Baggins thebetterhobbit'; + testData.secondOptionText = 'Frodo Baggins ringbearer'; + testData.snapshot = ` +

The two greatest hobbits, in order: @thebetterhobbit @ringbearer.

+`; + } else if ( type === 'option' ) { + testData.firstTriggerString = 'An awesome combination: ~m'; + testData.secondTriggerString = ' ~b'; + testData.firstOptionText = '🥭 Mango'; + testData.secondOptionText = '🫐 Blueberry'; + testData.snapshot = ` +

An awesome combination: 🥭 🫐.

+`; + } + + await page.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( testData.firstTriggerString ); + await expect( + page.locator( + `role=option[name="${ testData.firstOptionText }"i]` + ) + ).toBeVisible(); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( testData.secondTriggerString ); + await expect( + page.locator( + `role=option[name="${ testData.secondOptionText }"i]` + ) + ).toBeVisible(); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '.' ); + await expect + .poll( editor.getEditedPostContent ) + .toBe( testData.snapshot ); + } ); + + test( `${ completer }: should allow ${ type } selection via click event`, async ( { + page, + editor, + } ) => { + const testData = {}; + if ( type === 'mention' ) { + testData.triggerString = '@'; + testData.optionText = 'Katniss Everdeen mockingjay'; + testData.snapshot = ` +

@mockingjay

+`; + } else if ( type === 'option' ) { + testData.triggerString = '~'; + testData.optionText = '🍓 Strawberry'; + testData.snapshot = ` +

🍓

+`; + } + + await page.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( testData.triggerString ); + await expect( + page.locator( `role=option[name="${ testData.optionText }"i]` ) + ).toBeVisible(); + await page; + page.locator( + `role=option[name="${ testData.optionText }"i]` + ).click(); + + await expect + .poll( editor.getEditedPostContent ) + .toBe( testData.snapshot ); + } ); + + test( `${ completer }: should allow ${ type } selection via keypress event`, async ( { + page, + editor, + pageUtils, + } ) => { + const testData = {}; + // Jean-Luc is the target because user mentions will be listed alphabetically by first + last name + // This may seem off by one, but that's only because the test site adds an `admin` user that ends up at the top of the list + // 🍒 is the target because options are listed in the order they appear in the custom completer + if ( type === 'mention' ) { + testData.triggerString = '@'; + testData.optionText = 'Jean-Luc Picard makeitso'; + testData.snapshot = ` +

@makeitso

+`; + } else if ( type === 'option' ) { + testData.triggerString = '~'; + testData.optionText = '🍒 Cherry'; + testData.snapshot = ` +

🍒

+`; + } + + await page.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( testData.triggerString ); + await expect( + page.locator( `role=option[name="${ testData.optionText }"i]` ) + ).toBeVisible(); + await pageUtils.pressKeyTimes( 'ArrowDown', 6 ); + await page.keyboard.press( 'Enter' ); + + await expect + .poll( editor.getEditedPostContent ) + .toBe( testData.snapshot ); + } ); + + test( `${ completer }: should cancel ${ type } selection via \`Escape\` keypress event`, async ( { + page, + editor, + } ) => { + const testData = {}; + if ( type === 'mention' ) { + testData.triggerString = 'My name is @j'; + testData.optionText = 'Jane Doe testuser'; + testData.postCompleterInput = ' ...a secret.'; + testData.snapshot = ` +

My name is @j ...a secret.

+`; + } else if ( type === 'option' ) { + testData.triggerString = 'My favorite fruit is ~a'; + testData.optionText = '🍎 Apple'; + testData.postCompleterInput = + " ...no I changed my mind. It's mango."; + testData.snapshot = ` +

My favorite fruit is ~a ...no I changed my mind. It's mango.

+`; + } + + await page.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( testData.triggerString ); + await expect( + page.locator( `role=option[name="${ testData.optionText }"i]` ) + ).toBeVisible(); + await page.keyboard.press( 'Escape' ); + await page.keyboard.type( testData.postCompleterInput ); + // The characters before `Escape` should remain (i.e. `~app`) + await expect + .poll( editor.getEditedPostContent ) + .toBe( testData.snapshot ); + } ); + + // This test does not apply to user mentions, because they don't get disabled. + if ( type !== 'mention' ) { + test( `${ completer }: should not insert disabled ${ type }s`, async ( { + page, + editor, + } ) => { + await page.click( 'role=button[name="Add default block"i]' ); + // The 'Grapes' option is disabled in our test plugin, so it should not insert the grapes emoji + await page.keyboard.type( 'Sorry, we are all out of ~g' ); + await expect( + page.locator( 'role=option', { hasText: '🍇 Grape' } ) + ).toBeVisible(); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( ' grapes.' ); + await expect.poll( editor.getEditedPostContent ) + .toBe( ` +

Sorry, we are all out of ~g grapes.

+` ); + } ); + } + + test( `${ completer }: should allow newlines after multiple ${ type } completions`, async ( { + page, + editor, + } ) => { + const testData = {}; + if ( type === 'mention' ) { + testData.triggerString = '@bu'; + testData.optionText = 'Buddy Elf buddytheelf'; + testData.snapshot = ` +

@buddytheelf test

+ + + +

@buddytheelf test

+ + + +

@buddytheelf test

+ + + +

@buddytheelf test

+ + + +

+`; + } else if ( type === 'option' ) { + testData.triggerString = '~b'; + testData.optionText = '🫐 Blueberry'; + testData.snapshot = ` +

🫐 test

+ + + +

🫐 test

+ + + +

🫐 test

+ + + +

🫐 test

+ + + +

+`; + } + + await page.click( 'role=button[name="Add default block"i]' ); + + for ( let i = 0; i < 4; i++ ) { + await page.keyboard.type( testData.triggerString ); + await expect( + page.locator( + `role=option[name="${ testData.optionText }"i]` + ) + ).toBeVisible(); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( ' test' ); + await page.keyboard.press( 'Enter' ); + } + + await expect + .poll( editor.getEditedPostContent ) + .toBe( testData.snapshot ); + } ); + } ); + + // The following test concerns an infinite loop regression (https://github.com/WordPress/gutenberg/issues/41709). + // When present, the regression will cause this test to time out. + test( 'should insert elements from multiple completers in a single block', async ( { + page, + editor, + } ) => { + await page.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( '@fr' ); + await expect( + page.locator( 'role=option', { hasText: 'Frodo Baggins' } ) + ).toBeVisible(); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( ' +bi' ); + await expect( + page.locator( 'role=option', { hasText: 'Bilbo Baggins' } ) + ).toBeVisible(); + await page.keyboard.press( 'Enter' ); + await expect.poll( editor.getEditedPostContent ) + .toBe( ` +

@ringbearer +thebetterhobbit

+` ); + } ); +} ); diff --git a/test/e2e/specs/editor/various/writing-flow.spec.js b/test/e2e/specs/editor/various/writing-flow.spec.js index 5e828bce2bf7da..05bca4285eb414 100644 --- a/test/e2e/specs/editor/various/writing-flow.spec.js +++ b/test/e2e/specs/editor/various/writing-flow.spec.js @@ -700,7 +700,7 @@ test.describe( 'Writing Flow', () => { const x = paragraphRect.x + ( 2 * paragraphRect.width ) / 3; const y = paragraphRect.y + paragraphRect.height + 1; - // The typing observer requires two mouse moves to dectect and actual + // The typing observer requires two mouse moves to detect an actual // move. await page.mouse.move( x - 1, y - 1 ); await page.mouse.move( x, y ); @@ -959,6 +959,40 @@ test.describe( 'Writing Flow', () => { page.locator( 'role=document[name="Paragraph block"i]' ) ).toHaveText( /^a+\.a$/ ); } ); + + test( 'should vertically move the caret when pressing Alt', async ( { + page, + pageUtils, + } ) => { + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( 'a' ); + + async function getHeight() { + return await page.evaluate( + () => document.activeElement.offsetHeight + ); + } + + const height = await getHeight(); + + // Keep typing until the height of the element increases. We need two + // lines. + while ( height === ( await getHeight() ) ) { + await page.keyboard.type( 'a' ); + } + + // Create a new paragraph. + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( 'b' ); + await page.keyboard.press( 'ArrowLeft' ); + await pageUtils.pressKeyWithModifier( 'alt', 'ArrowUp' ); + await page.keyboard.type( '.' ); + + // Expect the "." to be added at the start of the paragraph + await expect( + page.locator( 'role=document[name="Paragraph block"i] >> nth = 0' ) + ).toHaveText( /^.a+$/ ); + } ); } ); class WritingFlowUtils { diff --git a/test/integration/blocks-schema.test.js b/test/integration/blocks-schema.test.js index 8e4ee17b48ff51..1bc75555d6b880 100644 --- a/test/integration/blocks-schema.test.js +++ b/test/integration/blocks-schema.test.js @@ -21,6 +21,16 @@ describe( 'block.json schema', () => { ] ); const ajv = new Ajv(); + test( 'strictly adheres to the draft-04 meta schema', () => { + // Use ajv.compile instead of ajv.validateSchema to validate the schema + // because validateSchema only checks syntax, whereas, compile checks + // if the schema is semantically correct with strict mode. + // See https://github.com/ajv-validator/ajv/issues/1434#issuecomment-822982571 + const result = ajv.compile( blockSchema ); + + expect( result.errors ).toBe( null ); + } ); + test( 'found block folders', () => { expect( blockFolders.length ).toBeGreaterThan( 0 ); } ); diff --git a/test/integration/fixtures/blocks/core__post-author.json b/test/integration/fixtures/blocks/core__post-author.json index c98f6db8560061..7f7871c9fb7056 100644 --- a/test/integration/fixtures/blocks/core__post-author.json +++ b/test/integration/fixtures/blocks/core__post-author.json @@ -4,7 +4,9 @@ "isValid": true, "attributes": { "avatarSize": 48, - "showAvatar": true + "showAvatar": true, + "isLink": false, + "linkTarget": "_self" }, "innerBlocks": [] } diff --git a/test/integration/theme-schema.test.js b/test/integration/theme-schema.test.js new file mode 100644 index 00000000000000..0e5ed88a1441eb --- /dev/null +++ b/test/integration/theme-schema.test.js @@ -0,0 +1,27 @@ +/** + * External dependencies + */ +import Ajv from 'ajv-draft-04'; + +/** + * Internal dependencies + */ +import themeSchema from '../../schemas/json/theme.json'; + +describe( 'theme.json schema', () => { + const ajv = new Ajv( { + // Used for matching unknown blocks without repeating core blocks names + // with patternProperties in settings.blocks and settings.styles + allowMatchingProperties: true, + } ); + + it( 'strictly adheres to the draft-04 meta schema', () => { + // Use ajv.compile instead of ajv.validateSchema to validate the schema + // because validateSchema only checks syntax, whereas, compile checks + // if the schema is semantically correct with strict mode. + // See https://github.com/ajv-validator/ajv/issues/1434#issuecomment-822982571 + const result = ajv.compile( themeSchema ); + + expect( result.errors ).toBe( null ); + } ); +} ); diff --git a/test/unit/jest.config.js b/test/unit/jest.config.js index c430aae0ed244c..77d7818bacc852 100644 --- a/test/unit/jest.config.js +++ b/test/unit/jest.config.js @@ -37,7 +37,6 @@ module.exports = { '^.+\\.[jt]sx?$': '/test/unit/scripts/babel-transformer.js', }, snapshotSerializers: [ - 'enzyme-to-json/serializer', '@emotion/jest/serializer', 'snapshot-diff/serializer', ], diff --git a/tsconfig.json b/tsconfig.json index be2b2c31082485..7618b05fc70f7d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -33,6 +33,7 @@ { "path": "packages/project-management-automation" }, { "path": "packages/react-i18n" }, { "path": "packages/report-flaky-tests" }, + { "path": "packages/style-engine" }, { "path": "packages/token-list" }, { "path": "packages/url" }, { "path": "packages/warning" },