From b50133e610512377e249da47359e4eb05f05dd5e Mon Sep 17 00:00:00 2001 From: Gutenberg Repository Automation Date: Wed, 10 May 2023 15:53:07 +0000 Subject: [PATCH] Merge changes published in the Gutenberg plugin "release/15.8" branch --- .eslintrc.js | 1 + .github/CODEOWNERS | 4 +- .../workflows/check-components-changelog.yml | 4 +- bin/packages/build-worker.js | 8 +- bin/plugin/commands/changelog.js | 26 +- bin/plugin/commands/performance.js | 21 +- .../test/__snapshots__/changelog.js.snap | 33 +- bin/plugin/commands/test/changelog.js | 16 + changelog.txt | 233 ++++ docs/contributors/code/release.md | 24 +- docs/contributors/documentation/README.md | 16 +- docs/explanations/architecture/performance.md | 18 +- docs/explanations/faq.md | 2 +- .../curating-the-editor-experience.md | 69 +- docs/how-to-guides/themes/theme-json.md | 2 +- docs/manifest.json | 18 + .../block-api/block-deprecation.md | 4 +- .../block-api/block-registration.md | 6 +- docs/reference-guides/core-blocks.md | 28 +- docs/reference-guides/data/data-core.md | 44 +- gutenberg.php | 4 +- lib/block-supports/layout.php | 5 +- lib/block-supports/typography.php | 48 +- lib/blocks.php | 35 +- lib/class-wp-theme-json-gutenberg.php | 63 +- lib/client-assets.php | 17 +- .../wordpress-6.1/block-editor-settings.php | 177 --- .../wordpress-6.1/block-template-utils.php | 598 -------- lib/compat/wordpress-6.1/blocks.php | 185 --- ...erg-rest-block-patterns-controller-6-1.php | 133 -- ...ss-gutenberg-rest-templates-controller.php | 303 ---- lib/compat/wordpress-6.1/date-settings.php | 82 -- lib/compat/wordpress-6.1/edit-form-blocks.php | 23 - .../get-global-styles-and-settings.php | 56 - .../wordpress-6.1/persisted-preferences.php | 104 -- lib/compat/wordpress-6.1/rest-api.php | 70 - lib/compat/wordpress-6.1/script-loader.php | 125 -- .../wordpress-6.1/template-parts-screen.php | 217 --- lib/compat/wordpress-6.1/theme.php | 47 - .../wp-theme-get-post-templates.php | 44 - .../wordpress-6.2/block-editor-settings.php | 3 + ...erg-rest-block-patterns-controller-6-2.php | 2 +- lib/compat/wordpress-6.2/default-filters.php | 15 + .../html-api/class-wp-html-tag-processor.php | 373 +++-- lib/compat/wordpress-6.2/script-loader.php | 27 +- ...utenberg-rest-templates-controller-6-3.php | 2 +- .../get-global-styles-and-settings.php | 34 + ...class-gutenberg-html-tag-processor-6-3.php | 373 +++-- lib/compat/wordpress-6.3/script-loader.php | 2 +- lib/compat/wordpress-6.3/theme-previews.php | 129 ++ .../block-editor-settings-mobile.php | 2 +- lib/experimental/block-editor-settings.php | 2 +- ...est-global-styles-revisions-controller.php | 75 +- lib/experimental/editor-settings.php | 10 + .../class-gutenberg-fonts-api-bc-layer.php | 96 ++ .../class-wp-web-fonts.php | 0 .../class-wp-webfonts-provider-local.php | 0 .../class-wp-webfonts-provider.php | 0 .../class-wp-webfonts-utils.php | 0 .../class-wp-webfonts.php | 209 +-- .../webfonts-deprecations.php | 12 +- lib/experimental/fonts-api/class-wp-fonts.php | 2 +- lib/experimental/fonts-api/fonts-api.php | 63 +- .../navigation-block-interactivity.php | 240 ++++ .../interactivity-api/script-loader.php | 49 + lib/experimental/navigation-fallback.php | 39 + lib/experiments-page.php | 26 +- lib/load.php | 40 +- package-lock.json | 103 +- package.json | 14 +- packages/api-fetch/README.md | 2 +- packages/api-fetch/src/index.js | 2 + .../src/middlewares/theme-preview.js | 35 + packages/base-styles/_mixins.scss | 24 +- packages/base-styles/_variables.scss | 3 +- packages/base-styles/_z-index.scss | 3 + .../downloadable-block-list-item/style.scss | 2 +- packages/block-editor/CHANGELOG.md | 2 + packages/block-editor/README.md | 2 +- .../components/block-breadcrumb/style.scss | 3 +- .../src/components/block-controls/slot.js | 12 +- .../components/block-controls/slot.native.js | 7 +- .../src/components/block-draggable/index.js | 16 +- .../use-scroll-when-dragging.js | 10 +- .../block-invalid-warning.native.js | 26 +- .../block-list/block-list-item-cell.native.js | 11 +- .../block-list/block-list-item.native.js | 388 +++--- .../block-list/block-outline.native.js | 58 + .../src/components/block-list/block.native.js | 1087 ++++++++------- .../src/components/block-list/content.scss | 1 - .../src/components/block-list/index.native.js | 527 +++---- .../block-list/insertion-point.native.js | 4 +- .../test/block-invalid-warning.native.js | 48 + .../block-list/test/index.native.js | 205 +++ .../block-actions-menu.native.js | 4 +- .../components/block-pattern-setup/style.scss | 5 +- .../components/block-patterns-list/style.scss | 5 +- .../block-settings-menu-controls/index.js | 28 +- .../block-settings-dropdown.js | 27 +- .../src/components/block-styles/style.scss | 8 +- .../block-tools/block-contextual-toolbar.js | 90 +- .../block-tools/selected-block-popover.js | 8 +- .../src/components/block-tools/style.scss | 8 + ...se-multiple-origin-colors-and-gradients.js | 18 +- .../global-styles/advanced-panel.js | 82 ++ .../components/global-styles/color-panel.js | 7 +- .../global-styles/dimensions-panel.js | 12 +- .../src/components/global-styles/hooks.js | 6 +- .../src/components/global-styles/index.js | 7 +- .../src/components/global-styles/style.scss | 14 + .../components/global-styles/test/utils.js | 58 +- .../global-styles/typography-panel.js | 2 +- .../global-styles/use-global-styles-output.js | 24 +- .../src/components/global-styles/utils.js | 27 + .../components/image-editor/use-save-image.js | 29 +- .../inserter-draggable-blocks/index.js | 4 + .../src/components/inspector-controls/fill.js | 2 +- .../inspector-controls/fill.native.js | 2 +- .../src/components/inspector-controls/slot.js | 13 +- .../inspector-controls/slot.native.js | 2 +- .../components/line-height-control/index.js | 9 +- .../line-height-control/stories/index.js | 2 +- .../src/components/link-control/test/index.js | 42 + .../link-control/use-internal-input-value.js | 15 +- .../components/list-view/block-contents.js | 46 +- .../list-view/block-select-button.js | 6 +- .../src/components/list-view/block.js | 7 +- .../src/components/list-view/index.js | 40 +- .../src/components/list-view/style.scss | 7 +- .../list-view/test/use-list-view-drop-zone.js | 100 +- .../list-view/use-list-view-drop-zone.js | 205 ++- .../src/components/media-placeholder/index.js | 75 +- .../multi-selection-inspector/index.js | 4 +- .../off-canvas-editor/leaf-more-menu.js | 3 +- .../src/components/preview-options/index.js | 11 +- .../spacing-input-control.js | 1 + .../src/components/url-input/index.js | 3 +- packages/block-editor/src/hooks/align.js | 3 +- packages/block-editor/src/hooks/border.js | 3 +- packages/block-editor/src/hooks/color.js | 3 +- .../block-editor/src/hooks/content-lock-ui.js | 18 +- packages/block-editor/src/hooks/duotone.js | 1 + .../block-editor/src/hooks/index.native.js | 1 + packages/block-editor/src/hooks/layout.js | 6 +- packages/block-editor/src/hooks/position.js | 3 +- packages/block-editor/src/hooks/style.js | 3 +- .../test/use-editor-wrapper-styles.native.js | 282 ++++ .../src/hooks/test/use-typography-props.js | 49 +- .../hooks/use-editor-wrapper-styles.native.js | 250 ++++ .../use-editor-wrapper-styles.native.scss | 11 + .../src/hooks/use-typography-props.js | 21 +- packages/block-editor/src/index.native.js | 6 + .../use-should-contextual-toolbar-show.js | 28 +- packages/block-editor/tsconfig.json | 1 + packages/block-library/package.json | 6 +- .../src/comment-author-name/edit.js | 2 +- .../src/comment-edit-link/edit.js | 2 +- .../src/comment-template/index.php | 15 +- packages/block-library/src/cover/block.json | 2 +- .../src/cover/edit/inspector-controls.js | 110 +- packages/block-library/src/cover/style.scss | 5 + packages/block-library/src/cover/test/edit.js | 58 +- .../block-library/src/cover/variations.js | 4 +- .../src/details-content/block.json | 50 - .../block-library/src/details-content/edit.js | 29 - .../src/details-content/index.js | 23 - .../block-library/src/details-content/save.js | 12 - .../src/details-summary/block.json | 53 - .../block-library/src/details-summary/edit.js | 27 - .../src/details-summary/editor.scss | 3 - .../src/details-summary/index.js | 23 - .../block-library/src/details-summary/save.js | 13 - .../src/details-summary/style.scss | 3 - packages/block-library/src/details/block.json | 14 +- packages/block-library/src/details/edit.js | 32 +- .../block-library/src/details/editor.scss | 3 + packages/block-library/src/details/index.js | 15 +- packages/block-library/src/details/save.js | 6 +- packages/block-library/src/details/style.scss | 16 + packages/block-library/src/editor.scss | 2 +- .../block-library/src/embed/variations.js | 4 +- packages/block-library/src/file/view.js | 5 +- packages/block-library/src/gallery/edit.js | 13 +- .../block-library/src/gallery/editor.scss | 6 - .../src/gallery/test/index.native.js | 51 +- .../src/image/test/edit.native.js | 54 +- packages/block-library/src/index.js | 4 - packages/block-library/src/index.native.js | 1 + .../block-library/src/loginout/block.json | 12 +- .../block-library/src/navigation-link/edit.js | 2 +- .../src/navigation-submenu/edit.js | 2 +- .../block-library/src/navigation/constants.js | 16 + .../src/navigation/edit/index.js | 154 +- .../src/navigation/edit/inner-blocks.js | 17 +- .../navigation/edit/unsaved-inner-blocks.js | 17 +- .../use-convert-classic-menu-to-block-menu.js | 2 +- .../block-library/src/navigation/index.php | 412 +++--- .../src/navigation/interactivity.js | 144 ++ .../block-library/src/paragraph/block.json | 6 +- .../src/post-author-name/edit.js | 2 +- .../src/post-featured-image/edit.js | 2 +- .../src/post-featured-image/index.php | 2 +- .../src/post-featured-image/overlay.js | 4 + .../block-library/src/post-terms/index.php | 4 +- packages/block-library/src/post-title/edit.js | 9 +- .../src/preformatted/edit.native.js | 4 +- .../block-library/src/quote/transforms.js | 6 - packages/block-library/src/read-more/edit.js | 2 +- packages/block-library/src/search/edit.js | 13 +- packages/block-library/src/search/editor.scss | 1 + .../block-library/src/site-logo/editor.scss | 3 +- .../src/site-title/edit/index.js | 2 +- .../block-library/src/social-links/edit.js | 64 +- packages/block-library/src/style.scss | 1 - .../block-library/src/template-part/index.php | 25 +- .../src/utils/interactivity/constants.js | 1 + .../src/utils/interactivity/directives.js | 179 +++ .../src/utils/interactivity/hooks.js | 76 + .../src/utils/interactivity/hydration.js | 22 + .../src/utils/interactivity/index.js | 17 + .../src/utils/interactivity/store.js | 45 + .../src/utils/interactivity/utils.js | 66 + .../src/utils/interactivity/vdom.js | 94 ++ packages/block-library/tsconfig.json | 1 + packages/blocks/README.md | 2 +- .../src/api/parser/get-block-attributes.js | 20 +- packages/blocks/src/api/raw-handling/utils.js | 35 +- packages/blocks/src/store/reducer.js | 83 +- .../commands/src/components/command-menu.js | 35 +- packages/commands/src/components/style.scss | 3 + packages/commands/src/hooks/use-command.js | 2 + packages/commands/src/private-apis.js | 2 + packages/commands/src/store/actions.js | 22 + packages/commands/src/store/reducer.js | 20 + packages/commands/src/store/selectors.js | 5 + packages/components/CHANGELOG.md | 34 + packages/components/CONTRIBUTING.md | 66 +- packages/components/README.md | 4 +- packages/components/src/CONTRIBUTING.md | 78 -- packages/components/src/README.md | 20 - .../components/src/autocomplete/README.md | 130 +- .../src/autocomplete/autocompleter-ui.tsx | 2 - .../components/src/autocomplete/index.tsx | 3 +- packages/components/src/autocomplete/types.ts | 22 +- packages/components/src/button/index.tsx | 2 + packages/components/src/button/style.scss | 19 +- packages/components/src/button/types.ts | 7 + .../components/src/card/card-media/README.md | 2 +- .../src/card/card-media/component.tsx | 3 +- .../components/src/card/stories/index.tsx | 73 +- .../src/checkbox-control/style.scss | 5 +- .../components/src/combobox-control/index.tsx | 42 +- .../src/combobox-control/stories/index.tsx | 1 - .../components/src/combobox-control/styles.ts | 8 +- .../components/src/combobox-control/types.ts | 9 +- .../src/custom-gradient-picker/style.scss | 4 +- .../src/dimension-control/index.tsx | 2 +- packages/components/src/draggable/index.tsx | 10 +- .../components/src/form-toggle/style.scss | 6 +- .../components/src/form-token-field/index.tsx | 10 +- .../components/src/form-token-field/styles.ts | 8 +- .../components/src/form-token-field/types.ts | 9 +- .../global-styles-context/index.native.js | 13 +- .../link-picker/link-picker-results.native.js | 3 + packages/components/src/modal/index.tsx | 7 +- packages/components/src/modal/style.scss | 2 +- .../src/navigable-container/README.md | 37 +- .../{container.js => container.tsx} | 84 +- .../{index.js => index.tsx} | 1 - .../src/navigable-container/menu.js | 62 - .../src/navigable-container/menu.tsx | 100 ++ .../{navigable-menu.js => navigable-menu.tsx} | 25 +- ...le-container.js => tabbable-container.tsx} | 21 +- .../src/navigable-container/tabbable.js | 46 - .../src/navigable-container/tabbable.tsx | 92 ++ .../{navigable-menu.js => navigable-menu.tsx} | 4 +- ...le-container.js => tababble-container.tsx} | 77 +- .../src/navigable-container/types.ts | 76 + .../components/src/palette-edit/index.tsx | 52 +- .../src/palette-edit/stories/index.tsx | 4 + packages/components/src/palette-edit/types.ts | 11 + .../components/src/sandbox/index.native.js | 4 + .../src/slot-fill/bubbles-virtually/fill.js | 3 +- .../bubbles-virtually/slot-fill-provider.js | 106 +- .../slot-fill/bubbles-virtually/use-slot.js | 55 +- packages/components/src/slot-fill/fill.js | 30 +- packages/components/src/slot-fill/index.js | 4 +- packages/components/src/slot-fill/provider.js | 6 - packages/components/src/slot-fill/slot.js | 5 - packages/components/src/style.scss | 6 + packages/components/src/tab-panel/index.tsx | 2 +- .../components/src/theme/color-algorithms.ts | 2 +- .../components/src/theme/stories/index.tsx | 2 +- .../src/theme/test/color-algorithms.ts | 4 +- .../test/__snapshots__/index.tsx.snap | 8 +- .../components/src/toolbar/stories/index.tsx | 50 +- .../src/toolbar/toolbar-button/index.tsx | 23 +- .../toolbar-item/{index.js => index.tsx} | 15 +- packages/components/src/tree-grid/README.md | 18 + packages/components/src/tree-grid/types.ts | 7 + .../components/src/utils/colors-values.js | 6 +- .../components/src/utils/theme-variables.scss | 8 +- .../src/utils/use-deprecated-props.ts | 29 + packages/components/tsconfig.json | 1 + packages/core-commands/.npmrc | 1 + packages/core-commands/CHANGELOG.md | 5 + packages/core-commands/README.md | 31 + packages/core-commands/package.json | 46 + .../src/add-post-type-commands.js | 32 + packages/core-commands/src/index.js | 1 + packages/core-commands/src/lock-unlock.js | 10 + packages/core-commands/src/private-apis.js | 16 + .../src/site-editor-navigation-commands.js} | 41 +- packages/core-data/README.md | 44 +- packages/core-data/src/actions.js | 33 + packages/core-data/src/reducer.js | 31 + packages/core-data/src/resolvers.js | 90 +- packages/core-data/src/selectors.ts | 42 +- .../block-templates/render.php.mustache | 5 + .../lib/templates/block/render.php.mustache | 5 + .../lib/templates/es5/render.php.mustache | 5 + packages/data/README.md | 8 +- packages/data/src/dispatch.ts | 35 + packages/data/src/index.js | 49 +- .../src/redux-store/metadata/selectors.js | 15 + .../redux-store/metadata/test/selectors.js | 32 + packages/data/src/redux-store/test/index.js | 1 + packages/data/src/select.ts | 30 + packages/date/README.md | 13 + packages/date/src/index.js | 14 + packages/date/src/test/index.js | 24 + .../e2e-test-utils-playwright/CHANGELOG.md | 2 +- packages/e2e-test-utils-playwright/README.md | 4 + .../e2e-test-utils-playwright/package.json | 3 +- .../editor/open-document-settings-sidebar.ts | 2 +- .../src/editor/site-editor.ts | 9 +- .../src/request-utils/index.ts | 12 +- .../src/request-utils/themes.ts | 52 +- packages/e2e-test-utils/src/site-editor.js | 2 + .../plugins-api/annotations-sidebar.js | 4 +- .../e2e-tests/plugins/plugins-api/sidebar.js | 4 +- .../specs/editor/blocks/pullquote.test.js | 47 - .../__snapshots__/plugins-api.test.js.snap | 4 +- .../specs/editor/plugins/annotations.test.js | 8 +- .../plugins/iframed-inline-styles.test.js | 2 +- .../specs/editor/plugins/plugins-api.test.js | 12 +- .../adding-patterns.test.js.snap | 11 - ...ep-styles-on-block-transforms.test.js.snap | 29 - .../various/__snapshots__/undo.test.js.snap | 31 - .../editor/various/adding-patterns.test.js | 22 - .../keep-styles-on-block-transforms.test.js | 64 - .../specs/editor/various/preferences.test.js | 2 +- .../specs/editor/various/undo.test.js | 444 ------ .../site-editor/multi-entity-saving.test.js | 2 +- packages/edit-post/package.json | 2 + .../components/header/header-toolbar/index.js | 25 +- .../edit-post/src/components/header/index.js | 2 + .../test/__snapshots__/index.js.snap | 4 +- .../components/keyboard-shortcuts/index.js | 2 +- .../src/components/layout/style.scss | 1 - .../test/__snapshots__/index.js.snap | 14 +- .../plugin-document-setting-panel/index.js | 2 +- .../sidebar/plugin-post-status-info/index.js | 2 +- .../components/sidebar/post-status/index.js | 16 +- .../components/sidebar/post-trash/index.js | 6 +- .../sidebar/settings-sidebar/index.js | 2 +- .../components/start-page-options/style.scss | 2 +- .../src/components/view-link/index.js | 37 + .../components/visual-editor/header.native.js | 16 +- .../src/components/visual-editor/style.scss | 2 +- packages/edit-post/src/editor.js | 7 + packages/edit-site/package.json | 4 +- .../add-custom-template-modal.js | 39 +- .../add-new-template/new-template-part.js | 5 +- .../add-new-template/new-template.js | 4 +- .../components/add-new-template/style.scss | 100 +- .../edit-site/src/components/app/index.js | 9 +- .../components/block-editor/back-button.js | 5 +- .../components/block-editor/editor-canvas.js | 44 +- .../src/components/block-editor/style.scss | 23 +- .../editor-canvas-container/index.js | 47 +- .../edit-site/src/components/editor/index.js | 55 +- .../global-styles/block-preview-panel.js | 2 +- .../components/global-styles/border-panel.js | 4 +- .../global-styles/color-palette-panel.js | 9 + .../components/global-styles/context-menu.js | 175 --- .../components/global-styles/custom-css.js | 131 -- .../global-styles/dimensions-panel.js | 30 +- .../components/global-styles/effects-panel.js | 40 - .../components/global-styles/filters-panel.js | 39 - .../global-styles/gradients-palette-panel.js | 8 + .../src/components/global-styles/root-menu.js | 66 + .../global-styles/screen-block-list.js | 4 +- .../components/global-styles/screen-block.js | 191 ++- .../components/global-styles/screen-border.js | 35 - .../components/global-styles/screen-colors.js | 25 +- .../components/global-styles/screen-css.js | 42 +- .../global-styles/screen-effects.js | 35 - .../global-styles/screen-filters.js | 27 - .../components/global-styles/screen-layout.js | 14 +- .../global-styles/screen-revisions/index.js | 177 +++ .../screen-revisions/revisions-buttons.js | 131 ++ .../global-styles/screen-revisions/style.scss | 99 ++ .../test/use-global-styles-revisions.js | 125 ++ .../use-global-styles-revisions.js | 103 ++ .../components/global-styles/screen-root.js | 4 +- .../screen-typography-element.js | 4 +- .../global-styles/screen-typography.js | 123 +- .../global-styles/screen-variations.js | 46 - .../style-variations-container.js | 14 +- .../src/components/global-styles/style.scss | 24 +- .../global-styles/typography-panel.js | 25 +- .../src/components/global-styles/ui.js | 173 +-- .../global-styles/variations-panel.js | 29 +- .../src/components/header-edit-mode/index.js | 7 +- .../components/keyboard-shortcuts/register.js | 2 +- .../edit-site/src/components/layout/index.js | 10 +- .../src/components/layout/style.scss | 6 +- .../edit-site/src/components/list/index.js | 5 +- .../edit-site/src/components/list/style.scss | 4 + .../edit-site/src/components/list/table.js | 4 +- .../plugin-template-setting-panel/index.js | 33 + .../src/components/revisions/index.js | 104 ++ .../edit-site/src/components/routes/link.js | 17 +- .../src/components/routes/use-title.js | 5 +- .../src/components/save-button/index.js | 33 +- .../src/components/save-hub/index.js | 52 +- .../src/components/save-panel/index.js | 18 +- .../secondary-sidebar/list-view-sidebar.js | 8 +- .../global-styles-sidebar.js | 16 +- .../src/components/sidebar-edit-mode/index.js | 23 +- .../sidebar-edit-mode/sidebar-fixed-bottom.js | 26 + .../components/sidebar-edit-mode/style.scss | 10 + .../sidebar-edit-mode/template-card/index.js | 10 +- .../index.js} | 0 .../sidebar-navigation-item/index.js | 27 +- .../sidebar-navigation-item/style.scss | 9 +- .../sidebar-navigation-screen-main/index.js | 65 +- .../index.js | 12 +- .../index.js | 14 +- .../navigation-menu-content.js | 6 +- .../sidebar-navigation-screen-page/index.js | 59 + .../sidebar-navigation-screen-pages/index.js | 82 ++ .../style.scss | 4 + .../index.js | 6 +- .../sidebar-navigation-screen/index.js | 31 +- .../sidebar-navigation-subtitle/index.js | 5 + .../sidebar-navigation-subtitle/style.scss | 7 + .../edit-site/src/components/sidebar/index.js | 13 +- .../src/components/site-hub/index.js | 147 +- .../src/components/site-hub/style.scss | 8 + .../start-template-options/index.js | 46 +- .../start-template-options/style.scss | 50 +- .../use-init-edited-entity-from-url.js | 5 +- .../use-sync-canvas-mode-with-url.js | 4 +- .../use-sync-path-with-url.js | 15 +- .../template-details/template-areas.js | 5 +- .../edit-site/src/hooks/commands/index.js | 10 - .../hooks/commands/use-wp-admin-commands.js | 79 -- .../edit-site/src/hooks/template-part-edit.js | 5 +- packages/edit-site/src/index.js | 1 + packages/edit-site/src/store/test/actions.js | 20 +- packages/edit-site/src/style.scss | 5 +- .../src/utils/is-previewing-theme.js | 18 + .../edit-site/src/utils/use-activate-theme.js | 38 + .../src/components/header/index.js | 18 + .../src/components/header/style.scss | 1 + .../src/components/layout/style.scss | 12 + .../secondary-sidebar/list-view-sidebar.js | 9 +- .../src/components/sidebar/index.js | 2 +- .../components/entities-saved-states/index.js | 72 +- .../components/post-featured-image/index.js | 61 +- .../components/post-featured-image/style.scss | 52 +- .../test/__snapshots__/index.js.snap | 2 +- .../src/components/post-saved-state/index.js | 7 - .../test/__snapshots__/index.js.snap | 9 - .../components/post-saved-state/test/index.js | 10 - .../post-switch-to-draft-button/index.js | 13 +- .../src/components/post-taxonomies/style.scss | 8 +- .../src/components/post-trash/style.scss | 4 +- packages/element/src/react.js | 7 + packages/env/CHANGELOG.md | 34 + packages/env/README.md | 152 +- .../env/lib/build-docker-compose-config.js | 163 +-- packages/env/lib/cache.js | 1 + packages/env/lib/cli.js | 19 +- packages/env/lib/commands/clean.js | 15 +- packages/env/lib/commands/destroy.js | 49 +- packages/env/lib/commands/index.js | 1 + packages/env/lib/commands/install-path.js | 1 + packages/env/lib/commands/logs.js | 1 + packages/env/lib/commands/run.js | 62 +- packages/env/lib/commands/start.js | 38 +- packages/env/lib/commands/stop.js | 1 + .../env/lib/config/add-or-replace-port.js | 15 +- packages/env/lib/config/config.js | 353 ----- packages/env/lib/config/db-env.js | 1 + .../env/lib/config/detect-directory-type.js | 2 +- .../env/lib/config/get-cache-directory.js | 39 + .../get-config-from-environment-vars.js | 86 ++ packages/env/lib/config/index.js | 12 +- packages/env/lib/config/load-config.js | 92 ++ packages/env/lib/config/merge-configs.js | 104 ++ packages/env/lib/config/parse-config.js | 575 +++++--- .../env/lib/config/parse-source-string.js | 164 +++ .../env/lib/config/post-process-config.js | 202 +++ .../env/lib/config/read-raw-config-file.js | 40 +- .../__snapshots__/config-integration.js.snap | 271 ++++ .../config/test/__snapshots__/config.js.snap | 83 -- .../lib/config/test/add-or-replace-port.js | 30 +- .../env/lib/config/test/config-integration.js | 143 ++ packages/env/lib/config/test/config.js | 1241 ----------------- .../lib/config/test/get-cache-directory.js | 57 + packages/env/lib/config/test/merge-configs.js | 111 ++ packages/env/lib/config/test/parse-config.js | 342 +++++ .../lib/config/test/parse-source-string.js | 154 ++ .../lib/config/test/post-process-config.js | 295 ++++ .../lib/config/test/read-raw-config-file.js | 82 +- .../env/lib/config/test/validate-config.js | 305 ++++ packages/env/lib/config/validate-config.js | 147 +- packages/env/lib/env.js | 2 + packages/env/lib/execute-after-setup.js | 51 + packages/env/lib/get-host-user.js | 31 + packages/env/lib/init-config.js | 244 +++- packages/env/lib/md5.js | 1 + packages/env/lib/parse-xdebug-mode.js | 1 + packages/env/lib/retry.js | 1 + .../{ => lib}/test/__snapshots__/md5.js.snap | 0 .../test/build-docker-compose-config.js | 63 +- packages/env/{ => lib}/test/cache.js | 3 +- packages/env/{ => lib}/test/cli.js | 22 +- packages/env/lib/test/execute-after-setup.js | 66 + packages/env/{ => lib}/test/md5.js | 3 +- .../env/{ => lib}/test/parse-xdebug-mode.js | 3 +- packages/env/lib/wordpress.js | 23 +- packages/env/test/parse-config.js | 61 - packages/eslint-plugin/CHANGELOG.md | 4 + packages/eslint-plugin/configs/react.js | 7 +- packages/icons/src/library/details.js | 10 +- packages/icons/src/library/level-up.js | 2 +- packages/private-apis/src/implementation.js | 2 + packages/react-native-aztec/package.json | 2 +- .../ios/GutenbergBridgeDelegate.swift | 2 + .../ios/RNReactNativeGutenbergBridge.swift | 2 + .../react-native-bridge/ios/SourceFile.swift | 2 +- packages/react-native-bridge/package.json | 2 +- packages/react-native-editor/CHANGELOG.md | 5 + .../gutenberg-editor-drag-and-drop.test.js | 29 +- ...enberg-editor-media-blocks-@canary.test.js | 9 + .../__device-tests__/pages/editor-page.js | 45 +- packages/react-native-editor/ios/Podfile.lock | 8 +- packages/react-native-editor/package.json | 2 +- .../src/api-fetch-setup.js | 10 +- .../src/test/api-fetch-setup.test.js | 13 + .../__tests__/__snapshots__/run.test.ts.snap | 62 - .../src/__tests__/run.test.ts | 12 +- packages/report-flaky-tests/src/run.ts | 77 +- packages/rich-text/README.md | 16 +- packages/rich-text/package.json | 1 + packages/rich-text/src/apply-format.js | 4 +- .../rich-text/src/component/use-anchor-ref.js | 11 +- .../rich-text/src/component/use-anchor.js | 16 +- packages/rich-text/src/concat.js | 2 +- packages/rich-text/src/create.js | 20 +- packages/rich-text/src/get-active-format.js | 4 +- packages/rich-text/src/get-active-formats.js | 4 +- packages/rich-text/src/get-active-object.js | 4 +- packages/rich-text/src/get-text-content.js | 2 +- packages/rich-text/src/{index.js => index.ts} | 6 + .../rich-text/src/insert-line-separator.js | 2 +- packages/rich-text/src/insert-object.js | 4 +- packages/rich-text/src/insert.js | 2 +- .../src/{is-collapsed.js => is-collapsed.ts} | 18 +- packages/rich-text/src/is-empty.js | 2 +- packages/rich-text/src/is-format-equal.js | 2 +- packages/rich-text/src/join.js | 2 +- packages/rich-text/src/normalise-formats.js | 2 +- packages/rich-text/src/remove-format.js | 2 +- .../rich-text/src/remove-line-separator.js | 2 +- packages/rich-text/src/remove.js | 2 +- packages/rich-text/src/replace.js | 2 +- packages/rich-text/src/slice.js | 2 +- packages/rich-text/src/split.js | 2 +- packages/rich-text/src/to-dom.js | 2 +- packages/rich-text/src/to-html-string.js | 2 +- packages/rich-text/src/toggle-format.js | 4 +- packages/rich-text/src/types.ts | 31 + .../rich-text/src/unregister-format-type.js | 4 +- packages/rich-text/src/update-formats.js | 2 +- packages/rich-text/tsconfig.json | 20 + packages/router/.npmrc | 0 packages/router/CHANGELOG.md | 5 + packages/router/README.md | 31 + packages/router/package.json | 40 + .../src/utils => router/src}/history.js | 0 packages/router/src/index.js | 1 + packages/router/src/private-apis.js | 22 + .../routes/index.js => router/src/router.js} | 4 +- ...class-wp-style-engine-css-declarations.php | 32 - phpunit/block-supports/typography-test.php | 2 +- ...lobal-styles-revisions-controller-test.php | 68 +- ...tenberg-rest-templates-controller-test.php | 56 +- phpunit/class-override-script-test.php | 6 +- ...ock-pattern-categories-controller-test.php | 48 +- .../class-wp-test-rest-users-controller.php | 70 +- phpunit/class-wp-theme-json-test.php | 65 + .../theme.json | 3 + .../bc-layer/bc-layer-tests-dataset.php | 483 +++++++ .../bc-layer/fonts-bc-layer-testcase.php | 46 + .../isDeprecatedStructure-test.php | 35 + .../migrateDeprecatedStructure-test.php | 38 + .../bc-layer/wpRegisterWebfonts-test.php | 62 + .../wpWebfonts/getAllWebfonts-test.php | 33 + .../bc-layer/wpWebfonts/getFontSlug-test.php | 134 ++ .../wpWebfonts/getRegisteredWebfonts-test.php | 32 + .../wpWebfonts/registerWebfont-test.php | 113 ++ phpunit/fonts-api/wpPrintFonts-test.php | 101 +- phpunit/fonts-api/wpRegisterFonts-test.php | 216 --- readme.txt | 2 +- schemas/json/theme.json | 6 +- storybook/decorators/with-theme.js | 4 +- storybook/main.js | 3 +- storybook/preview.js | 3 +- .../docs/components/contributing.story.mdx | 6 + .../stories/docs/components/readme.story.mdx | 6 + storybook/stories/docs/introduction.story.mdx | 2 +- storybook/style.scss | 11 - test/e2e/specs/editor/blocks/buttons.spec.js | 12 +- test/e2e/specs/editor/blocks/classic.spec.js | 4 - test/e2e/specs/editor/blocks/image.spec.js | 182 +++ .../specs/editor/blocks/navigation.spec.js | 525 ++++++- .../e2e/specs/editor/blocks/pullquote.spec.js | 57 + .../editor/various/adding-patterns.spec.js | 30 + .../keep-styles-on-block-transforms.spec.js | 99 ++ .../various/post-editor-template-mode.spec.js | 26 + .../editor/various/switch-to-draft.spec.js | 9 +- test/e2e/specs/editor/various/undo.spec.js | 491 +++++++ .../site-editor/push-to-global-styles.spec.js | 6 +- test/e2e/specs/site-editor/style-book.spec.js | 3 +- .../site-editor/style-variations.spec.js | 8 +- test/e2e/specs/site-editor/title.spec.js | 2 +- .../user-global-styles-revisions.spec.js | 168 +++ test/native/__mocks__/styleMock.js | 6 + .../native/integration-test-helpers/README.md | 8 + .../integration-test-helpers/add-block.js | 18 +- test/native/integration-test-helpers/index.js | 2 + .../setup-api-fetch.js | 50 + .../setup-media-picker.js | 31 +- .../setup-media-upload.js | 1 + .../integration-test-helpers/setup-picker.js | 40 + .../wait-for-store-resolvers.js | 2 +- test/native/setup.js | 3 + tools/webpack/blocks.js | 306 ++-- tsconfig.json | 1 + webpack.config.js | 2 +- 655 files changed, 17644 insertions(+), 11245 deletions(-) delete mode 100644 lib/compat/wordpress-6.1/block-editor-settings.php delete mode 100644 lib/compat/wordpress-6.1/block-template-utils.php delete mode 100644 lib/compat/wordpress-6.1/blocks.php delete mode 100644 lib/compat/wordpress-6.1/class-gutenberg-rest-block-patterns-controller-6-1.php delete mode 100644 lib/compat/wordpress-6.1/class-gutenberg-rest-templates-controller.php delete mode 100644 lib/compat/wordpress-6.1/date-settings.php delete mode 100644 lib/compat/wordpress-6.1/edit-form-blocks.php delete mode 100644 lib/compat/wordpress-6.1/get-global-styles-and-settings.php delete mode 100644 lib/compat/wordpress-6.1/persisted-preferences.php delete mode 100644 lib/compat/wordpress-6.1/rest-api.php delete mode 100644 lib/compat/wordpress-6.1/script-loader.php delete mode 100644 lib/compat/wordpress-6.1/template-parts-screen.php delete mode 100644 lib/compat/wordpress-6.1/theme.php delete mode 100644 lib/compat/wordpress-6.1/wp-theme-get-post-templates.php create mode 100644 lib/compat/wordpress-6.3/theme-previews.php create mode 100644 lib/experimental/fonts-api/bc-layer/class-gutenberg-fonts-api-bc-layer.php rename lib/experimental/fonts-api/{deprecations => bc-layer}/class-wp-web-fonts.php (100%) rename lib/experimental/fonts-api/{deprecations => bc-layer}/class-wp-webfonts-provider-local.php (100%) rename lib/experimental/fonts-api/{deprecations => bc-layer}/class-wp-webfonts-provider.php (100%) rename lib/experimental/fonts-api/{deprecations => bc-layer}/class-wp-webfonts-utils.php (100%) rename lib/experimental/fonts-api/{deprecations => bc-layer}/class-wp-webfonts.php (50%) rename lib/experimental/fonts-api/{deprecations => bc-layer}/webfonts-deprecations.php (95%) create mode 100644 lib/experimental/interactivity-api/navigation-block-interactivity.php create mode 100644 lib/experimental/interactivity-api/script-loader.php create mode 100644 lib/experimental/navigation-fallback.php create mode 100644 packages/api-fetch/src/middlewares/theme-preview.js create mode 100644 packages/block-editor/src/components/block-list/block-outline.native.js create mode 100644 packages/block-editor/src/components/block-list/test/block-invalid-warning.native.js create mode 100644 packages/block-editor/src/components/block-list/test/index.native.js create mode 100644 packages/block-editor/src/components/global-styles/advanced-panel.js create mode 100644 packages/block-editor/src/hooks/test/use-editor-wrapper-styles.native.js create mode 100644 packages/block-editor/src/hooks/use-editor-wrapper-styles.native.js create mode 100644 packages/block-editor/src/hooks/use-editor-wrapper-styles.native.scss create mode 100644 packages/block-editor/src/index.native.js delete mode 100644 packages/block-library/src/details-content/block.json delete mode 100644 packages/block-library/src/details-content/edit.js delete mode 100644 packages/block-library/src/details-content/index.js delete mode 100644 packages/block-library/src/details-content/save.js delete mode 100644 packages/block-library/src/details-summary/block.json delete mode 100644 packages/block-library/src/details-summary/edit.js delete mode 100644 packages/block-library/src/details-summary/editor.scss delete mode 100644 packages/block-library/src/details-summary/index.js delete mode 100644 packages/block-library/src/details-summary/save.js delete mode 100644 packages/block-library/src/details-summary/style.scss create mode 100644 packages/block-library/src/details/editor.scss create mode 100644 packages/block-library/src/navigation/constants.js create mode 100644 packages/block-library/src/navigation/interactivity.js create mode 100644 packages/block-library/src/utils/interactivity/constants.js create mode 100644 packages/block-library/src/utils/interactivity/directives.js create mode 100644 packages/block-library/src/utils/interactivity/hooks.js create mode 100644 packages/block-library/src/utils/interactivity/hydration.js create mode 100644 packages/block-library/src/utils/interactivity/index.js create mode 100644 packages/block-library/src/utils/interactivity/store.js create mode 100644 packages/block-library/src/utils/interactivity/utils.js create mode 100644 packages/block-library/src/utils/interactivity/vdom.js delete mode 100644 packages/components/src/CONTRIBUTING.md delete mode 100644 packages/components/src/README.md rename packages/components/src/navigable-container/{container.js => container.tsx} (71%) rename packages/components/src/navigable-container/{index.js => index.tsx} (90%) delete mode 100644 packages/components/src/navigable-container/menu.js create mode 100644 packages/components/src/navigable-container/menu.tsx rename packages/components/src/navigable-container/stories/{navigable-menu.js => navigable-menu.tsx} (66%) rename packages/components/src/navigable-container/stories/{tabbable-container.js => tabbable-container.tsx} (61%) delete mode 100644 packages/components/src/navigable-container/tabbable.js create mode 100644 packages/components/src/navigable-container/tabbable.tsx rename packages/components/src/navigable-container/test/{navigable-menu.js => navigable-menu.tsx} (97%) rename packages/components/src/navigable-container/test/{tababble-container.js => tababble-container.tsx} (74%) create mode 100644 packages/components/src/navigable-container/types.ts rename packages/components/src/toolbar/toolbar-item/{index.js => index.tsx} (80%) create mode 100644 packages/components/src/utils/use-deprecated-props.ts create mode 100644 packages/core-commands/.npmrc create mode 100644 packages/core-commands/CHANGELOG.md create mode 100644 packages/core-commands/README.md create mode 100644 packages/core-commands/package.json create mode 100644 packages/core-commands/src/add-post-type-commands.js create mode 100644 packages/core-commands/src/index.js create mode 100644 packages/core-commands/src/lock-unlock.js create mode 100644 packages/core-commands/src/private-apis.js rename packages/{edit-site/src/hooks/commands/use-navigation-commands.js => core-commands/src/site-editor-navigation-commands.js} (72%) create mode 100644 packages/data/src/dispatch.ts create mode 100644 packages/data/src/select.ts delete mode 100644 packages/e2e-tests/specs/editor/blocks/pullquote.test.js delete mode 100644 packages/e2e-tests/specs/editor/various/__snapshots__/adding-patterns.test.js.snap delete mode 100644 packages/e2e-tests/specs/editor/various/__snapshots__/keep-styles-on-block-transforms.test.js.snap delete mode 100644 packages/e2e-tests/specs/editor/various/__snapshots__/undo.test.js.snap delete mode 100644 packages/e2e-tests/specs/editor/various/adding-patterns.test.js delete mode 100644 packages/e2e-tests/specs/editor/various/keep-styles-on-block-transforms.test.js delete mode 100644 packages/e2e-tests/specs/editor/various/undo.test.js create mode 100644 packages/edit-post/src/components/view-link/index.js delete mode 100644 packages/edit-site/src/components/global-styles/context-menu.js delete mode 100644 packages/edit-site/src/components/global-styles/custom-css.js delete mode 100644 packages/edit-site/src/components/global-styles/effects-panel.js delete mode 100644 packages/edit-site/src/components/global-styles/filters-panel.js create mode 100644 packages/edit-site/src/components/global-styles/root-menu.js delete mode 100644 packages/edit-site/src/components/global-styles/screen-border.js delete mode 100644 packages/edit-site/src/components/global-styles/screen-effects.js delete mode 100644 packages/edit-site/src/components/global-styles/screen-filters.js create mode 100644 packages/edit-site/src/components/global-styles/screen-revisions/index.js create mode 100644 packages/edit-site/src/components/global-styles/screen-revisions/revisions-buttons.js create mode 100644 packages/edit-site/src/components/global-styles/screen-revisions/style.scss create mode 100644 packages/edit-site/src/components/global-styles/screen-revisions/test/use-global-styles-revisions.js create mode 100644 packages/edit-site/src/components/global-styles/screen-revisions/use-global-styles-revisions.js delete mode 100644 packages/edit-site/src/components/global-styles/screen-variations.js create mode 100644 packages/edit-site/src/components/plugin-template-setting-panel/index.js create mode 100644 packages/edit-site/src/components/revisions/index.js create mode 100644 packages/edit-site/src/components/sidebar-edit-mode/sidebar-fixed-bottom.js rename packages/edit-site/src/components/sidebar-edit-mode/{template-card/last-revision.js => template-revisions/index.js} (100%) create mode 100644 packages/edit-site/src/components/sidebar-navigation-screen-page/index.js create mode 100644 packages/edit-site/src/components/sidebar-navigation-screen-pages/index.js create mode 100644 packages/edit-site/src/components/sidebar-navigation-screen-pages/style.scss create mode 100644 packages/edit-site/src/components/sidebar-navigation-subtitle/index.js create mode 100644 packages/edit-site/src/components/sidebar-navigation-subtitle/style.scss delete mode 100644 packages/edit-site/src/hooks/commands/index.js delete mode 100644 packages/edit-site/src/hooks/commands/use-wp-admin-commands.js create mode 100644 packages/edit-site/src/utils/is-previewing-theme.js create mode 100644 packages/edit-site/src/utils/use-activate-theme.js delete mode 100644 packages/env/lib/config/config.js create mode 100644 packages/env/lib/config/get-cache-directory.js create mode 100644 packages/env/lib/config/get-config-from-environment-vars.js create mode 100644 packages/env/lib/config/load-config.js create mode 100644 packages/env/lib/config/merge-configs.js create mode 100644 packages/env/lib/config/parse-source-string.js create mode 100644 packages/env/lib/config/post-process-config.js create mode 100644 packages/env/lib/config/test/__snapshots__/config-integration.js.snap delete mode 100644 packages/env/lib/config/test/__snapshots__/config.js.snap create mode 100644 packages/env/lib/config/test/config-integration.js delete mode 100644 packages/env/lib/config/test/config.js create mode 100644 packages/env/lib/config/test/get-cache-directory.js create mode 100644 packages/env/lib/config/test/merge-configs.js create mode 100644 packages/env/lib/config/test/parse-config.js create mode 100644 packages/env/lib/config/test/parse-source-string.js create mode 100644 packages/env/lib/config/test/post-process-config.js create mode 100644 packages/env/lib/config/test/validate-config.js create mode 100644 packages/env/lib/execute-after-setup.js create mode 100644 packages/env/lib/get-host-user.js rename packages/env/{ => lib}/test/__snapshots__/md5.js.snap (100%) rename packages/env/{ => lib}/test/build-docker-compose-config.js (72%) rename packages/env/{ => lib}/test/cache.js (99%) rename packages/env/{ => lib}/test/cli.js (91%) create mode 100644 packages/env/lib/test/execute-after-setup.js rename packages/env/{ => lib}/test/md5.js (94%) rename packages/env/{ => lib}/test/parse-xdebug-mode.js (94%) delete mode 100644 packages/env/test/parse-config.js rename packages/rich-text/src/{index.js => index.ts} (90%) rename packages/rich-text/src/{is-collapsed.js => is-collapsed.ts} (51%) create mode 100644 packages/rich-text/src/types.ts create mode 100644 packages/rich-text/tsconfig.json create mode 100644 packages/router/.npmrc create mode 100644 packages/router/CHANGELOG.md create mode 100644 packages/router/README.md create mode 100644 packages/router/package.json rename packages/{edit-site/src/utils => router/src}/history.js (100%) create mode 100644 packages/router/src/index.js create mode 100644 packages/router/src/private-apis.js rename packages/{edit-site/src/components/routes/index.js => router/src/router.js} (92%) create mode 100644 phpunit/fonts-api/bc-layer/bc-layer-tests-dataset.php create mode 100644 phpunit/fonts-api/bc-layer/fonts-bc-layer-testcase.php create mode 100644 phpunit/fonts-api/bc-layer/gutenbergFontsApiBcLayer/isDeprecatedStructure-test.php create mode 100644 phpunit/fonts-api/bc-layer/gutenbergFontsApiBcLayer/migrateDeprecatedStructure-test.php create mode 100644 phpunit/fonts-api/bc-layer/wpRegisterWebfonts-test.php create mode 100644 phpunit/fonts-api/bc-layer/wpWebfonts/getAllWebfonts-test.php create mode 100644 phpunit/fonts-api/bc-layer/wpWebfonts/getFontSlug-test.php create mode 100644 phpunit/fonts-api/bc-layer/wpWebfonts/getRegisteredWebfonts-test.php create mode 100644 phpunit/fonts-api/bc-layer/wpWebfonts/registerWebfont-test.php create mode 100644 storybook/stories/docs/components/contributing.story.mdx create mode 100644 storybook/stories/docs/components/readme.story.mdx delete mode 100644 storybook/style.scss create mode 100644 test/e2e/specs/editor/blocks/pullquote.spec.js create mode 100644 test/e2e/specs/editor/various/adding-patterns.spec.js create mode 100644 test/e2e/specs/editor/various/keep-styles-on-block-transforms.spec.js create mode 100644 test/e2e/specs/editor/various/undo.spec.js create mode 100644 test/e2e/specs/site-editor/user-global-styles-revisions.spec.js create mode 100644 test/native/integration-test-helpers/setup-api-fetch.js create mode 100644 test/native/integration-test-helpers/setup-picker.js diff --git a/.eslintrc.js b/.eslintrc.js index 13c7e260e9bd9..4063bb42cf32a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -105,6 +105,7 @@ const restrictedImports = [ 'lowerCase', 'map', 'mapKeys', + 'mapValues', 'maxBy', 'memoize', 'merge', diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d3ba813d3cd8a..9bdd6f4594ae9 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,5 +1,5 @@ # Documentation -/docs @ajitbohra @ryanwelcher @juanmaguitar @fabiankaegy +/docs @ajitbohra @ryanwelcher @juanmaguitar @fabiankaegy @ndiego # Schemas /schemas/json @ajlende @@ -57,7 +57,7 @@ # Tooling /bin @ntwb @nerrad @ajitbohra /bin/api-docs @ntwb @nerrad @ajitbohra -/docs/tool @ajitbohra +/docs/tool @ajitbohra @ndiego /packages/babel-plugin-import-jsx-pragma @ntwb @nerrad @ajitbohra /packages/babel-plugin-makepot @ntwb @nerrad @ajitbohra /packages/babel-preset-default @gziolo @ntwb @nerrad @ajitbohra diff --git a/.github/workflows/check-components-changelog.yml b/.github/workflows/check-components-changelog.yml index 23fbe9183ca8d..c0cb4894009a2 100644 --- a/.github/workflows/check-components-changelog.yml +++ b/.github/workflows/check-components-changelog.yml @@ -42,8 +42,8 @@ jobs: exit 1 fi - pr_link_pattern="\(\[#${PR_NUMBER}\]\(https://github\.com/WordPress/gutenberg/pull/${PR_NUMBER}\)\)" - pr_link_grep_pattern="(\[#${PR_NUMBER}\](https://github\.com/WordPress/gutenberg/pull/${PR_NUMBER}))" + pr_link_pattern="\[#${PR_NUMBER}\]\(https://github\.com/WordPress/gutenberg/pull/${PR_NUMBER}\)" + pr_link_grep_pattern="\[#${PR_NUMBER}\](https://github\.com/WordPress/gutenberg/pull/${PR_NUMBER})" unreleased_section=$(sed -n '/^## Unreleased$/,/^## /p' "${changelog_path}") diff --git a/bin/packages/build-worker.js b/bin/packages/build-worker.js index e82091ff84374..3f1512ef0feb7 100644 --- a/bin/packages/build-worker.js +++ b/bin/packages/build-worker.js @@ -103,9 +103,13 @@ async function buildCSS( file ) { 'animations', 'z-index', ] - // Editor styles should be excluded from the default CSS vars output. + // Editor and component styles should be excluded from the default CSS vars output. .concat( - file.includes( 'common.scss' ) || ! file.includes( 'block-library' ) + file.includes( 'common.scss' ) || + ! ( + file.includes( 'block-library' ) || + file.includes( 'components' ) + ) ? [ 'default-custom-properties' ] : [] ) diff --git a/bin/plugin/commands/changelog.js b/bin/plugin/commands/changelog.js index b8d37968781dc..69be77ac66991 100644 --- a/bin/plugin/commands/changelog.js +++ b/bin/plugin/commands/changelog.js @@ -61,8 +61,7 @@ const UNKNOWN_FEATURE_FALLBACK_NAME = 'Uncategorized'; * @type {Record} */ const LABEL_TYPE_MAPPING = { - '[Feature] Navigation Screen': 'Experiments', - '[Package] Dependency Extraction Webpack Plugin': 'Tools', + '[Type] Developer Documentation': 'Documentation', '[Package] Jest Puppeteer aXe': 'Tools', '[Package] E2E Tests': 'Tools', '[Package] E2E Test Utils': 'Tools', @@ -74,16 +73,17 @@ const LABEL_TYPE_MAPPING = { '[Package] Scripts': 'Tools', '[Type] Build Tooling': 'Tools', 'Automated Testing': 'Tools', + '[Package] Dependency Extraction Webpack Plugin': 'Tools', + '[Type] Code Quality': 'Code Quality', + '[Type] Performance': 'Performance', + '[Type] Security': 'Security', + '[Feature] Navigation Screen': 'Experiments', '[Type] Experimental': 'Experiments', '[Type] Bug': 'Bug Fixes', '[Type] Regression': 'Bug Fixes', - '[Type] Feature': 'Features', '[Type] Enhancement': 'Enhancements', '[Type] New API': 'New APIs', - '[Type] Performance': 'Performance', - '[Type] Developer Documentation': 'Documentation', - '[Type] Code Quality': 'Code Quality', - '[Type] Security': 'Security', + '[Type] Feature': 'Features', }; /** @@ -303,12 +303,6 @@ function getIssueType( issue ) { ...getTypesByTitle( issue.title ), ]; - // Force all tasks identified as Documentation tasks - // to appear under the main "Documentation" section. - if ( candidates.includes( 'Documentation' ) ) { - return 'Documentation'; - } - return candidates.length ? candidates.sort( sortType )[ 0 ] : 'Various'; } @@ -377,7 +371,7 @@ function getIssueFeature( issue ) { */ function sortType( a, b ) { const [ aIndex, bIndex ] = [ a, b ].map( ( title ) => { - return Object.keys( LABEL_TYPE_MAPPING ).indexOf( title ); + return Object.values( LABEL_TYPE_MAPPING ).indexOf( title ); } ); return aIndex - bIndex; @@ -925,6 +919,10 @@ function getContributorProps( pullRequests ) { getContributorPropsMarkdownList, ] )( pullRequests ); + if ( ! contributorsList ) { + return ''; + } + return ( '## First time contributors' + '\n\n' + diff --git a/bin/plugin/commands/performance.js b/bin/plugin/commands/performance.js index 0804c794e9d8e..57c6674500a22 100644 --- a/bin/plugin/commands/performance.js +++ b/bin/plugin/commands/performance.js @@ -3,7 +3,6 @@ */ const fs = require( 'fs' ); const path = require( 'path' ); -const { mapValues } = require( 'lodash' ); const SimpleGit = require( 'simple-git' ); /** @@ -475,10 +474,26 @@ async function runPerformanceTests( branches, options ) { ( r ) => r[ branch ][ dataPoint ] ); } ); - const medians = mapValues( resultsByDataPoint, median ); + // @ts-ignore + const medians = Object.fromEntries( + Object.entries( resultsByDataPoint ).map( + ( [ dataPoint, dataPointResults ] ) => [ + dataPoint, + median( dataPointResults ), + ] + ) + ); // Format results as times. - results[ testSuite ][ branch ] = mapValues( medians, formatTime ); + // @ts-ignore + results[ testSuite ][ branch ] = Object.fromEntries( + Object.entries( medians ).map( + ( [ dataPoint, dataPointMedian ] ) => [ + dataPoint, + formatTime( dataPointMedian ), + ] + ) + ); } } diff --git a/bin/plugin/commands/test/__snapshots__/changelog.js.snap b/bin/plugin/commands/test/__snapshots__/changelog.js.snap index df71684c9aa71..2e79ecda56a7c 100644 --- a/bin/plugin/commands/test/__snapshots__/changelog.js.snap +++ b/bin/plugin/commands/test/__snapshots__/changelog.js.snap @@ -5,12 +5,8 @@ exports[`getChangelog verify that the changelog is properly formatted 1`] = ` ### Enhancements -- Scripts: Use cssnano to minimize CSS files with build. ([33750](https://github.com/WordPress/gutenberg/pull/33750)) -- Scripts: Webpack configuration update to minimize CSS. ([33676](https://github.com/WordPress/gutenberg/pull/33676)) - #### Components - Add new ColorPicker. ([33714](https://github.com/WordPress/gutenberg/pull/33714)) -- Promote \`ItemGroup\`. ([33701](https://github.com/WordPress/gutenberg/pull/33701)) - Update snackbar to use framer motion instead of react spring. ([33717](https://github.com/WordPress/gutenberg/pull/33717)) - Use updated range styles. ([33824](https://github.com/WordPress/gutenberg/pull/33824)) @@ -39,7 +35,6 @@ exports[`getChangelog verify that the changelog is properly formatted 1`] = ` ### Bug Fixes - Correct \`function_exists()\` check typo introduced in #33331. ([33513](https://github.com/WordPress/gutenberg/pull/33513)) -- ESLint Plugin: Include .jsx extenstion when linting import statements. ([33746](https://github.com/WordPress/gutenberg/pull/33746)) - Fix block appender position in classic themes. ([33895](https://github.com/WordPress/gutenberg/pull/33895)) - Fix misspelling of "queries" in filter documentation. ([33799](https://github.com/WordPress/gutenberg/pull/33799)) - Fix positioning discrepancy with draggable chip. ([33893](https://github.com/WordPress/gutenberg/pull/33893)) @@ -81,12 +76,6 @@ exports[`getChangelog verify that the changelog is properly formatted 1`] = ` #### Meta Boxes - Fix Safari 13 metaboxes from overlapping the content. ([33817](https://github.com/WordPress/gutenberg/pull/33817)) -#### Build Tooling -- Readable JS assets Plugin: Fix webpack 5 support. ([33785](https://github.com/WordPress/gutenberg/pull/33785)) - -#### Navigation Screen -- Fix regressed menu selection dropdown placeholder value for Nav Editor menu locations UI. ([33748](https://github.com/WordPress/gutenberg/pull/33748)) - #### Accessibility - Fix some JAWS bugs. ([33627](https://github.com/WordPress/gutenberg/pull/33627)) @@ -116,6 +105,15 @@ exports[`getChangelog verify that the changelog is properly formatted 1`] = ` - Refactor the HierarchicalTermSelector so that it does not cause unnecessary loading of terms. ([33418](https://github.com/WordPress/gutenberg/pull/33418)) +### Experiments + +#### Navigation Screen +- Fix regressed menu selection dropdown placeholder value for Nav Editor menu locations UI. ([33748](https://github.com/WordPress/gutenberg/pull/33748)) + +#### Components +- Promote \`ItemGroup\`. ([33701](https://github.com/WordPress/gutenberg/pull/33701)) + + ### Documentation - Add documentation to disable remote calls for block patterns. ([33930](https://github.com/WordPress/gutenberg/pull/33930)) @@ -133,8 +131,6 @@ exports[`getChangelog verify that the changelog is properly formatted 1`] = ` ### Code Quality -- Scripts: Fix typo in format change message. ([33945](https://github.com/WordPress/gutenberg/pull/33945)) - #### Components - Components utils: \`rtl()\` return type, \`rtl.watch()\` utility. ([33882](https://github.com/WordPress/gutenberg/pull/33882)) - InputControl to TypeScript. ([33696](https://github.com/WordPress/gutenberg/pull/33696)) @@ -157,17 +153,22 @@ exports[`getChangelog verify that the changelog is properly formatted 1`] = ` ### Tools +- ESLint Plugin: Include .jsx extenstion when linting import statements. ([33746](https://github.com/WordPress/gutenberg/pull/33746)) - GitHub Templates: Fix spacing in bug report template. ([33761](https://github.com/WordPress/gutenberg/pull/33761)) - GitHub Templates: Format bug report template. ([33786](https://github.com/WordPress/gutenberg/pull/33786)) +- Scripts: Fix typo in format change message. ([33945](https://github.com/WordPress/gutenberg/pull/33945)) +- Scripts: Use cssnano to minimize CSS files with build. ([33750](https://github.com/WordPress/gutenberg/pull/33750)) +- Scripts: Webpack configuration update to minimize CSS. ([33676](https://github.com/WordPress/gutenberg/pull/33676)) - Update bug issue template to use forms. ([33713](https://github.com/WordPress/gutenberg/pull/33713)) +#### Build Tooling +- Readable JS assets Plugin: Fix webpack 5 support. ([33785](https://github.com/WordPress/gutenberg/pull/33785)) +- Scripts: Update webpack to v5 (try 2). ([33818](https://github.com/WordPress/gutenberg/pull/33818)) + #### Testing - Add search performance measure and make other measures more stable. ([33848](https://github.com/WordPress/gutenberg/pull/33848)) - E2E: Block Hierarchy Navigation wait for the column to be highlighted. ([33721](https://github.com/WordPress/gutenberg/pull/33721)) -#### Build Tooling -- Scripts: Update webpack to v5 (try 2). ([33818](https://github.com/WordPress/gutenberg/pull/33818)) - ### Various diff --git a/bin/plugin/commands/test/changelog.js b/bin/plugin/commands/test/changelog.js index e9e1872c38c99..52a9391af11d1 100644 --- a/bin/plugin/commands/test/changelog.js +++ b/bin/plugin/commands/test/changelog.js @@ -188,6 +188,17 @@ describe( 'getIssueType', () => { expect( result ).toBe( 'Enhancements' ); } ); + + it( 'prioritizes meta categories', () => { + const result = getIssueType( { + labels: [ + { name: '[Type] Bug' }, + { name: '[Type] Build Tooling' }, + ], + } ); + + expect( result ).toBe( 'Tools' ); + } ); } ); describe( 'getIssueFeature', () => { @@ -474,6 +485,11 @@ describe( 'getContributorProps', () => { // npm run other:changelog -- --milestone="Gutenberg 11.3" expect( getContributorProps( pullRequests ) ).toMatchSnapshot(); } ); + test( 'do not include first time contributors section if there are not any', () => { + expect( + getContributorProps( pullRequests.slice( 0, 4 ) ) + ).toMatchInlineSnapshot( `""` ); + } ); } ); describe( 'getContributorList', () => { diff --git a/changelog.txt b/changelog.txt index 1a41dd656944f..73b7a39f88f91 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,238 @@ == Changelog == += 15.7.1 = + + + +## Changelog + +### Enhancements + +#### Fonts API +- Relocate which fonts to print into wp_print_fonts(). ([50151](https://github.com/WordPress/gutenberg/pull/50151)) + + +### Tools + +#### Testing +- Skip iframe-inline-styles end-to-end test. ([50320](https://github.com/WordPress/gutenberg/pull/50320)) + +## Contributors + +The following contributors merged PRs in this release: + +@hellofromtonya @scruffian + + += 15.7.0 = + +## Changelog + +### Enhancements + +#### Block Library +- Group: Add allowedBlocks attribute and pass it to innerBlockProps. ([49128](https://github.com/WordPress/gutenberg/pull/49128)) +- Media & Text: Add allowedBlocks attribute and pass it to innerBlock. ([49981](https://github.com/WordPress/gutenberg/pull/49981)) +- Site Logo: Add logo replace flow in Inspector controls. ([49992](https://github.com/WordPress/gutenberg/pull/49992)) + +#### Global Styles +- Fluid typography: Use logarithmic scale factor to calculate a min font size. ([49707](https://github.com/WordPress/gutenberg/pull/49707)) +- Style book: Make the style book slot generic. ([49973](https://github.com/WordPress/gutenberg/pull/49973)) +- Base Styles: Add the editor input reset with increased specificity. ([49831](https://github.com/WordPress/gutenberg/pull/49831)) + +#### Components +- Draggable: Allow elementId based elements to be attached to the ownerDocument body. ([49911](https://github.com/WordPress/gutenberg/pull/49911)) +- Spinner: Enforce no background. ([49695](https://github.com/WordPress/gutenberg/pull/49695)) +- Updates the behavior of the top toolbar fixed setting. ([49634](https://github.com/WordPress/gutenberg/pull/49634)) + +#### Design Tools +- Image: Display custom borders on placeholder. ([49569](https://github.com/WordPress/gutenberg/pull/49569)) +- Patterns: Try a masonry layout on the template pattern suggestion modal. ([49958](https://github.com/WordPress/gutenberg/pull/49958)) + +#### Duotone +- Duotone: Add block controls on the inspector. ([49838](https://github.com/WordPress/gutenberg/pull/49838)) +- Duotone: Fix setup state for image block. ([49949](https://github.com/WordPress/gutenberg/pull/49949)) +- Polish duotone rendering code. ([49706](https://github.com/WordPress/gutenberg/pull/49706)) +- Deprecate remaining global duotone functions. ([49702](https://github.com/WordPress/gutenberg/pull/49702)) +- Group duotone outputs and refactor rendering. ([49705](https://github.com/WordPress/gutenberg/pull/49705)) + +### Accessibility +- List view: Refactor ARIA attributes. ([48461](https://github.com/WordPress/gutenberg/pull/48461)) +- Block Mover: Make text labels for left/right move buttons legible. ([49747](https://github.com/WordPress/gutenberg/pull/49747)) +- Block Toolbar: Fix incorrect switcher button width in text mode. ([49847](https://github.com/WordPress/gutenberg/pull/49847)) +- Snackbar: Fix insufficient color contrast on hover. ([49682](https://github.com/WordPress/gutenberg/pull/49682)) +- Update Reakit dep to 1.3.11. ([49763](https://github.com/WordPress/gutenberg/pull/49763)) +- List View: Add aria-description. ([49907](https://github.com/WordPress/gutenberg/pull/49907)) + +### Bug Fixes + +#### Block Library + +- Cover block: Remove overflow hidden rule. ([49913](https://github.com/WordPress/gutenberg/pull/49913)) +- Fix BlockInfo slot displaying logic. ([50054](https://github.com/WordPress/gutenberg/pull/50054)) +- Fix block toolbar height and rounded corners of parent selector button when text label mode. ([49556](https://github.com/WordPress/gutenberg/pull/49556)) +- Demo content cover block alignment not respected. ([49848](https://github.com/WordPress/gutenberg/pull/49848)) +- Group block: Remove innerprops from placeholder wrapper. ([49783](https://github.com/WordPress/gutenberg/pull/49783)) +- Only display the modified post date if the post has been modified. ([46839](https://github.com/WordPress/gutenberg/pull/46839)) +- Post Excerpt: Fix crash at runtime when postType is undefined. ([49899](https://github.com/WordPress/gutenberg/pull/49899)) +- Post Featured Image: Fix some sizing issues. ([49641](https://github.com/WordPress/gutenberg/pull/49641)) +- Adjust copy of Site Logo Block. ([49540](https://github.com/WordPress/gutenberg/pull/49540)) +- Adapt flex child controls for Spacer. ([49362](https://github.com/WordPress/gutenberg/pull/49362)) +- Social Icon: Update the `link` and `mail` block variation's icons. ([49952](https://github.com/WordPress/gutenberg/pull/49952)) +- Cover: Re-instate overflow:Hidden rule to fix issue with border radius. ([50209](https://github.com/WordPress/gutenberg/pull/50209)) +- Fix site logo preview image size with long filenames. ([50242](https://github.com/WordPress/gutenberg/pull/50242)) + +#### Components +- CheckboxControl: Add support custom IDs. ([49977](https://github.com/WordPress/gutenberg/pull/49977)) +- Equalize modal internal padding. ([49890](https://github.com/WordPress/gutenberg/pull/49890)) +- Increase modal radius. ([49870](https://github.com/WordPress/gutenberg/pull/49870)) +- Query: Fix add new post link position via private SlotFill. ([49819](https://github.com/WordPress/gutenberg/pull/49819)) +- Tweak `WordPressComponent` type. ([49960](https://github.com/WordPress/gutenberg/pull/49960)) +- Update the framer motion dependency to the latest version. ([49822](https://github.com/WordPress/gutenberg/pull/49822)) +- Improve output of CHANGELOG CI check. ([49779](https://github.com/WordPress/gutenberg/pull/49779)) +- Retain context when opening modals on small screens. ([50039](https://github.com/WordPress/gutenberg/pull/50039)) +- Update CHANGELOG CI check to support forked repos. ([49906](https://github.com/WordPress/gutenberg/pull/49906)) + +#### Global Styles +- Borders: Fix border style on color/width clearing and global styles fallback logic. ([49738](https://github.com/WordPress/gutenberg/pull/49738)) +- Borders: Maintain radius in Global Styles. ([49950](https://github.com/WordPress/gutenberg/pull/49950)) +- Close stylebook if the global styles side bar is not open. ([50081](https://github.com/WordPress/gutenberg/pull/50081)) +- Do not add unregistered style variations to the theme.json schema. ([49807](https://github.com/WordPress/gutenberg/pull/49807)) +- Update preset styles to use Selectors API. ([49427](https://github.com/WordPress/gutenberg/pull/49427)) +- Change the 'WP_Theme_JSON_Data_Gutenberg' class directory. ([50062](https://github.com/WordPress/gutenberg/pull/50062)) +- Layout: Fix issue where saving user global styles included layout definitions in layout settings. ([50268](https://github.com/WordPress/gutenberg/pull/50268)) + +#### Site Editor +- Fix screen flash when deleting templates in templates list. ([48449](https://github.com/WordPress/gutenberg/pull/48449)) +- Fix the condition for the modal to choose the initial template pattern. ([49954](https://github.com/WordPress/gutenberg/pull/49954)) +- Fix editor crash caused by missing type conversion in EditorStyles component. ([49882](https://github.com/WordPress/gutenberg/pull/49882)) +- Remove frame shadow in edit view. ([49767](https://github.com/WordPress/gutenberg/pull/49767)) +- Add `home` to list of new templates. ([47389](https://github.com/WordPress/gutenberg/pull/47389)) +- Animate the site icon element between view and edit in the site editor. ([48886](https://github.com/WordPress/gutenberg/pull/48886)) +- Restore click event handler on site icon button. ([50094](https://github.com/WordPress/gutenberg/pull/50094)) +- Correctly return 'isResolving' from 'useAlternativeTemplateParts' hook. ([49921](https://github.com/WordPress/gutenberg/pull/49921)) + +#### Patterns +- Increase the dimensions of the pattern modal that appears when creating a new page. ([49859](https://github.com/WordPress/gutenberg/pull/49859)) +- Update full screen modal dimensions, and pattern grids. ([49894](https://github.com/WordPress/gutenberg/pull/49894)) +- Increase pattern modal dimensions when creating a new template. ([49722](https://github.com/WordPress/gutenberg/pull/49722)) + +#### List View +- Add parameters to custom-scrollbars-on-hover. ([49892](https://github.com/WordPress/gutenberg/pull/49892)) +- Update with scrolling and custom scrollbar. ([49793](https://github.com/WordPress/gutenberg/pull/49793)) +- Ensure list view block id is unique to the list view instance. ([49944](https://github.com/WordPress/gutenberg/pull/49944)) + +#### Block Editor +- Fix quick inserter going off-screen in some situations. ([49881](https://github.com/WordPress/gutenberg/pull/49881)) +- List View: Update drop indicator width to be aware of scroll containers. ([49786](https://github.com/WordPress/gutenberg/pull/49786)) +- Block highlight: Fix radius issue. ([49864](https://github.com/WordPress/gutenberg/pull/49864)) +- DOM: Update getScrollContainer to be aware of horizontal scroll. ([49787](https://github.com/WordPress/gutenberg/pull/49787)) +- Fix fixed block toolbar positioning. ([49990](https://github.com/WordPress/gutenberg/pull/49990)) +- iframe: Add `enqueue_block_assets`. ([49655](https://github.com/WordPress/gutenberg/pull/49655)) +- Rename immutableSet to setImmutably. ([50040](https://github.com/WordPress/gutenberg/pull/50040)) +- Edit Post: Hide overflowing content in visual editor wrapper to prevent block popovers from creating scrollbars. ([49978](https://github.com/WordPress/gutenberg/pull/49978)) + +#### REST API +- Replace fallbacks to fallback (singular) in Nav fallback REST endpoint. ([50044](https://github.com/WordPress/gutenberg/pull/50044)) +- Consolidate Navigation fallbacks logic between Editor and Front of Site via REST API. ([48698](https://github.com/WordPress/gutenberg/pull/48698)) +- Add /revisions endpoint for global styles. ([49974](https://github.com/WordPress/gutenberg/pull/49974)) + +#### Build Tooling +- Fix multiple tooltips showing on NavigableToolbars. ([49644](https://github.com/WordPress/gutenberg/pull/49644)) +- Add `--env-cwd` Option To `wp-env run`. ([49908](https://github.com/WordPress/gutenberg/pull/49908)) - Add Port To `WP_TESTS_DOMAIN`. ([49883](https://github.com/WordPress/gutenberg/pull/49883)) +- Add support in `check-license` for conjunctive (AND) licenses. ([46801](https://github.com/WordPress/gutenberg/pull/46801)) + +### Performance +Continued the work refactor away from Lodash usages to reduce the build size +[49799](https://github.com/WordPress/gutenberg/pull/49799), [49794](https://github.com/WordPress/gutenberg/pull/49794), [49755](https://github.com/WordPress/gutenberg/pull/49755) + +### Experiments +- Allow adding posts and pages with custom titles from the command menu. ([49893](https://github.com/WordPress/gutenberg/pull/49893)) +- Update the design of the command center. ([49681](https://github.com/WordPress/gutenberg/pull/49681)) + +### Documentation +- Adds closing code tags. ([49991](https://github.com/WordPress/gutenberg/pull/49991)) +- Add "block-selectors" documentation to TOC and manifest. ([49471](https://github.com/WordPress/gutenberg/pull/49471)) +- Adds note about custom fields to Plugin Sidebar page in documentation. ([49622](https://github.com/WordPress/gutenberg/pull/49622)) +- Autocomplete: Add heading and fix type for `onReplace` in README. ([49798](https://github.com/WordPress/gutenberg/pull/49798)) +- Document the separation between private APIs and experimental APIs. ([47973](https://github.com/WordPress/gutenberg/pull/47973)) +- Fix broken link and update title of Localizing Gutenberg doc. ([49851](https://github.com/WordPress/gutenberg/pull/49851)) +- Fixes broken img link. ([49805](https://github.com/WordPress/gutenberg/pull/49805)) +- Need to use WordPress package for useState. ([49478](https://github.com/WordPress/gutenberg/pull/49478)) +- Remove unused screenshot from documentation folder. ([49896](https://github.com/WordPress/gutenberg/pull/49896)) +- Small grammar fixes for block-context.md. ([49701](https://github.com/WordPress/gutenberg/pull/49701)) +- Update IS_GUTENBERG_PLUGIN to process.env.IS_GUTENBERG_PLUGIN in coding guidelines. ([49825](https://github.com/WordPress/gutenberg/pull/49825)) +- Fixes typo in README. ([49957](https://github.com/WordPress/gutenberg/pull/49957)) +- Update README.md. ([49762](https://github.com/WordPress/gutenberg/pull/49762)) +- Update README.md. ([49855](https://github.com/WordPress/gutenberg/pull/49855)) +- Update outreach.md. ([49961](https://github.com/WordPress/gutenberg/pull/49961)) +- Update wp-env prereq documentation. ([50004](https://github.com/WordPress/gutenberg/pull/50004)) +- Formatting inside alert div. ([50090](https://github.com/WordPress/gutenberg/pull/50090)) +#### Theme.json +- Add documentation for `variations` key in `theme.json`. ([49826](https://github.com/WordPress/gutenberg/pull/49826)) +- Update theme.json reference documentation to include more sections. ([48250](https://github.com/WordPress/gutenberg/pull/48250)) + +#### Gutenberg Plugin release +- Update the Gutenberg release issue template to reflect the new Gutenberg release team. ([50037](https://github.com/WordPress/gutenberg/pull/50037)) +- Update Gutenberg release documentation to include the new Gutenberg Release Team. ([50036](https://github.com/WordPress/gutenberg/pull/50036)) +- Fix broken link and minor tweaks to formatting and verbiage in the Gutenberg Release Process doc. ([49876](https://github.com/WordPress/gutenberg/pull/49876)) +- Update release checklist. ([50068](https://github.com/WordPress/gutenberg/pull/50068)) + +#### Block Development +- Improve the learning experience for writing blocks. ([49792](https://github.com/WordPress/gutenberg/pull/49792)) +- Re-write of the landing page. ([49643](https://github.com/WordPress/gutenberg/pull/49643)) +- Improve insertBlock(s) documentation. ([50078](https://github.com/WordPress/gutenberg/pull/50078)) +- Small Typo: Remove dots. ([49853](https://github.com/WordPress/gutenberg/pull/49853)) + +### Code Quality + +- Fix PrivateInserter import. ([50038](https://github.com/WordPress/gutenberg/pull/50038)) +- Remove the normalizeFalsyValue util. ([50033](https://github.com/WordPress/gutenberg/pull/50033)) +- Don't programmatically lowercase post type label. ([49591](https://github.com/WordPress/gutenberg/pull/49591)) +- Port colord to PHP. ([49700](https://github.com/WordPress/gutenberg/pull/49700)) +- Reuse cleanEmptyObject utility and fix empty string case. ([49750](https://github.com/WordPress/gutenberg/pull/49750)) +- toStyles: Don't mutate the tree argument. ([49806](https://github.com/WordPress/gutenberg/pull/49806)) +- useInstanceId: Fix useMemo hook dependencies. ([49979](https://github.com/WordPress/gutenberg/pull/49979)) + +### Tools + +#### Testing +- Components: Update `ColorPicker` unit tests. ([49698](https://github.com/WordPress/gutenberg/pull/49698)) +- Migrate CPT end-to-end tests to Playwright. ([50031](https://github.com/WordPress/gutenberg/pull/50031)) +- Fonts API: Add tests for gutenberg_add_registered_fonts_to_theme_json(). ([50049](https://github.com/WordPress/gutenberg/pull/50049)) +- Expand multi-line block tests. ([49732](https://github.com/WordPress/gutenberg/pull/49732)) +- Rich text test helpers mimic user events. ([49804](https://github.com/WordPress/gutenberg/pull/49804)) +- Fix editor canvas detaching error in end-to-end tests. ([49374](https://github.com/WordPress/gutenberg/pull/49374)) + +#### Build Tooling +- Add script which checks published types for non-checked JS packages. ([49736](https://github.com/WordPress/gutenberg/pull/49736)) +- Docgen: Fix issue where function token can't be found. ([49812](https://github.com/WordPress/gutenberg/pull/49812)) +- Publish build types for notices. ([49650](https://github.com/WordPress/gutenberg/pull/49650)) +- Publish types for plugins package. ([49649](https://github.com/WordPress/gutenberg/pull/49649)) + +## First time contributors + +The following PRs were merged by first time contributors: + +- @annziel: Fix screen flash when deleting templates in templates list. ([48449](https://github.com/WordPress/gutenberg/pull/48449)) +- @courtneyr-dev: Update outreach.md. ([49961](https://github.com/WordPress/gutenberg/pull/49961)) +- @m0hanraj: Fixes typo in README. ([49957](https://github.com/WordPress/gutenberg/pull/49957)) +- @MahendraBishnoi29: Snackbar: Fix insufficient color contrast on hover. ([49682](https://github.com/WordPress/gutenberg/pull/49682)) +- @masteradhoc: Adjust copy of Site Logo Block. ([49540](https://github.com/WordPress/gutenberg/pull/49540)) +- @sque89: Group: Add allowedBlocks attribute and pass it to innerBlockProps. ([49128](https://github.com/WordPress/gutenberg/pull/49128)) +- @tyrann0us: Don't programmatically lowercase post type label. ([49591](https://github.com/WordPress/gutenberg/pull/49591)) +- @Wtower: Update README.md. ([49855](https://github.com/WordPress/gutenberg/pull/49855)) + + +## Contributors + +The following contributors merged PRs in this release: + +@aaronrobertshaw @adamziel @ajlende @alexstine @andrewserong @annziel @aurooba @bdurette @bph @carolinan @chad1008 @chintu51 @courtneyr-dev @dcalhoun @derekblank @draganescu @ellatrix @fluiddot @gaambo @geriux @getdave @gigitux @guarani @gvgvgvijayan @gziolo @hellofromtonya @jameskoster @jasmussen @jeryj @jhnstn @jsnajdr @juanmaguitar @kevin940726 @m0hanraj @MaggieCabrera @MahendraBishnoi29 @Mamaduka @masteradhoc @mburridge @mcsf @mikachan @mirka @mokagio @mtias @ndiego @noahtallen @ntsekouras @oandregal @ObliviousHarmony @priethor @ramonjd @scruffian @SiobhyB @Soean @sque89 @t-hamano @talldan @tellthemachines @tyrann0us @tyxla @Wtower @youknowriad @zzap + + + + = 15.6.2 = diff --git a/docs/contributors/code/release.md b/docs/contributors/code/release.md index 8d9f46120d2f5..3dd8863859e37 100644 --- a/docs/contributors/code/release.md +++ b/docs/contributors/code/release.md @@ -17,7 +17,7 @@ We release a new major version approximately every two weeks. The current and ne - **On the date of the current milestone**, we publish a release candidate and make it available for plugin authors and users to test. If any regressions are found with a release candidate, a new one can be published. On this date, all remaining PRs on the milestone are moved automatically to the next release. Release candidates should be versioned incrementally, starting with `-rc.1`, then `-rc.2`, and so on. [Preparation of the release post starts here](/docs/block-editor/contributors/code/release/#writing-the-release-notes-and-post) and spans until the final release. -- **One week after the first release candidate**, the stable version is created based on the last release candidate and any necessary regression fixes. Once the stable version is released, the release post is published, including a [performance audit](/docs/block-editor/contributors/testing-overview/#performance-testing). +- **One week after the first release candidate**, the stable version is created based on the last release candidate and any necessary regression fixes. Once the stable version is released and the release post is published. If critical bugs are discovered on stable versions of the plugin, patch versions can be released at any time. @@ -103,8 +103,7 @@ Documenting the release is a group effort between the release manager, Gutenberg 1. Curating the changelog - Wednesday after the RC release to Friday 2. Selecting the release highlights - Friday to Monday 3. Drafting the release post - Monday to Wednesday -4. Running the performance tests - Wednesday right after the stable release -5. Publishing the post - Wednesday after stable release +4. Publishing the post - Wednesday after stable release #### 1. Curating the changelog @@ -140,21 +139,6 @@ When possible, the highlighted changes in the release post should include an ani These visual assets should maintain consistency with previous release posts; using lean, white themes helps in this regard and visually integrate well with the [make.wordpress.org/core](https://make.wordpress.org/core/) blog aesthetics. Including copyrighted material should be avoided, and browser plugins that can be seen in the browser canvas (spell checkers, form fillers, etc.) disabled when capturing the assets. -#### 4. Running the performance tests - -The post should also include a performance audit at the end, comparing the current Gutenberg release with both the previous one and the latest WordPress major version. There are GitHub worfklows in place to do this comparison as part of the Continuous Integration setup, so the performance audit results can be found at the workflow run generated by the release commit in the [Performance Tests workflows](https://github.com/WordPress/gutenberg/actions/workflows/performance.yml) page, with the job name `Compare performance with current WordPress Core and previous Gutenberg versions`. - -If the GitHub workflow fails, the performance audit can be executed locally using `bin/plugin/cli.js perf` and passing the branches to run the performance suite against as parameters. In addition, the current major WP version can be passed to avoid running tests against the WP `trunk`. Example: - -``` -node ./bin/plugin/cli.js perf release/x.y release/x.z wp/a.b --wp-version wp.major -``` - -The performance values usually displayed in the release post are: - -- Time to the first block (test named `firstBlock`) -- KeyPress Event (typing) (test named `type`) - #### 5. Publishing the post Once the post content is ready, an author already having permissions to post in [make.wordpress.org/core](https://make.wordpress.org/core/) will create a new draft and import the content; this post should be published after the actual release, helping external media to echo and amplify the release news. Remember asking for peer review is encouraged by the [make/core posting guidelines](https://make.wordpress.org/core/handbook/best-practices/post-comment-guidelines/#peer-review)! @@ -165,7 +149,7 @@ Occasionally it's necessary to create a minor release (i.e. X.Y.**Z**) of the Pl As you proceed with the following process, it's worth bearing in mind that such minor releases are not created as branches in their own right (e.g. `release/12.5.0`) but are simply [tags](https://github.com/WordPress/gutenberg/releases/tag/v12.5.1). -The method for minor releases is nearly identical to the main Plugin release process (see above) but has some notable exceptions. Please make sure to read _the whole_ of this guide before proceeding. +The method for minor releases is nearly identical to the main Plugin release process (see above) but has some notable exceptions. Please make sure to read _the entire_ guide before proceeding. #### Updating the release branch @@ -217,7 +201,7 @@ This is expected. The draft release will contain only the plugin zip. Only once > Do I need to publish point releases to WordPress.org? -Yes. The method for this is identical to the main Plugin release process. You will need a Gutenberg Core team member to approve the release workflow. +Yes. The method for this is identical to the main Plugin release process. You will need a member of the Gutenberg Core team the Gutenberg Release team to approve the release workflow. > The release process failed to cherry-pick version bump commit to the trunk branch. diff --git a/docs/contributors/documentation/README.md b/docs/contributors/documentation/README.md index fbb7bf5bda903..a862592ab318a 100644 --- a/docs/contributors/documentation/README.md +++ b/docs/contributors/documentation/README.md @@ -50,7 +50,7 @@ To update an existing page: 2. Create a branch to work, for example `docs/update-contrib-guide`. 3. Make the necessary changes to the existing document. 4. Commit your changes. -5. Create a pull request using "\[Type\] Documentation" label. +5. Create a pull request using the [\[Type\] Developer Documentation](https://github.com/WordPress/gutenberg/labels/%5BType%5D%20Developer%20Documentation) label. ### Create a new document @@ -103,6 +103,10 @@ Use the full directory and filename from the Gutenberg repository, not the publi An example, the link to this page is: `/docs/contributors/documentation/README.md` +
+Note: The usual link transformation is not applied to links in callouts. See below. +
+ ### Code examples The code example in markdown should be wrapped in three tick marks \`\`\` and should additionally include a language specifier. See this [GitHub documentation around fenced code blocks](https://help.github.com/en/github/writing-on-github/creating-and-highlighting-code-blocks). @@ -132,7 +136,7 @@ The preferred format for code examples is JSX, this should be the default view. The Block Editor handbook supports the same [notice styles as other WordPress handbooks](https://make.wordpress.org/docs/handbook/documentation-team-handbook/handbooks-style-and-formatting-guide/#formatting). However, the shortcode implementation is not ideal with the different locations the block editor handbook documentation is published (npm, GitHub). -The recommended way to implement in markdown is to use the raw HTML and `callout callout-LEVEL` classes. For example: +The recommended way to implement in markdown is to use the raw HTML and`callout callout-LEVEL` classes. For example: ```html
This is an **info** callout.
@@ -156,6 +160,14 @@ This is an **alert** callout. This is a **warning** callout. +
+Note: In callout notices, links also need to be HTML `<a href></a>` notations. +The usual link transformation is not applied to links in callouts. +For instance, to reach the Getting started > Create Block page the URL in GitHub is +https://developer.wordpress.org/docs/getting-started/create-block/README.md +and will have to be hardcoded for the endpoint in the Block Editor Handbook as +https://developer.wordpress.org/block-editor/getting-started/create-block/ to link correctly in the handbook. +
### Editor config You should configure your editor to use Prettier to auto-format markdown documents. See the [Getting Started documentation](/docs/contributors/code/getting-started-with-code-contribution.md) for complete details. diff --git a/docs/explanations/architecture/performance.md b/docs/explanations/architecture/performance.md index 94848d1386d98..b57a2bba1db61 100644 --- a/docs/explanations/architecture/performance.md +++ b/docs/explanations/architecture/performance.md @@ -6,9 +6,9 @@ Performance is a key feature for editor applications and the Block editor is not To ensure the block editor stays performant across releases and development, we monitor some key metrics using [performance benchmark job](#the-performance-benchmark-job). -**Loading Time:** The time it takes to load an editor page. This includes time the server takes to respond, times to first paint, first contentful paint, DOM content load complete, load complete and first block render. -**Typing Time:** The time it takes for the browser to respond while typing on the editor. -**Block Selection Time:** The time it takes for the browser to respond after a user selects block. (Inserting a block is also equivalent to selecting a block. Monitoring the selection is sufficient to cover both metrics). +- **Loading Time:** The time it takes to load an editor page. This includes time the server takes to respond, times to first paint, first contentful paint, DOM content load complete, load complete and first block render. +- **Typing Time:** The time it takes for the browser to respond while typing on the editor. +- **Block Selection Time:** The time it takes for the browser to respond after a user selects block. (Inserting a block is also equivalent to selecting a block. Monitoring the selection is sufficient to cover both metrics). ## Key Performance Decisions and Solutions @@ -53,12 +53,12 @@ To achieve that the command first prepares the following folder structure: Once the directory above is in place, the performance command loop over the performance test suites (post editor and site editor) and does the following: - 1- Start the environment for branch1 - 2- Run the performance test for the current suite - 3- Stop the environment for branch1 - 4- Repeat the first 3 steps for all other branches - 5- Repeat the previous 4 steps 3 times. - 6- Compute medians for all the performance metrics of the current suite. +1. Start the environment for `branch1` +2. Run the performance test for the current suite +3. Stop the environment for `branch1` +4. Repeat the first 3 steps for all other branches +5. Repeat the previous 4 steps 3 times. +6. Compute medians for all the performance metrics of the current suite. Once all the test suites are executed, a summary report is printed. diff --git a/docs/explanations/faq.md b/docs/explanations/faq.md index f9c6b059e133d..4a365ce5b41d1 100644 --- a/docs/explanations/faq.md +++ b/docs/explanations/faq.md @@ -151,7 +151,7 @@ This is the canonical list of keyboard shortcuts: Z - Show or hide the settings sidebar. + Show or hide the Settings sidebar. Ctrl+Shift+, , diff --git a/docs/how-to-guides/curating-the-editor-experience.md b/docs/how-to-guides/curating-the-editor-experience.md index 02f9cfba765d8..b35911d69c602 100644 --- a/docs/how-to-guides/curating-the-editor-experience.md +++ b/docs/how-to-guides/curating-the-editor-experience.md @@ -20,7 +20,7 @@ Alongside the ability to lock moving or removing blocks, the [Navigation Block]( **Apply block locking to patterns or templates** -When building patterns or templates, theme authors can use these same UI tools to set the default locked state of blocks. For example, a theme author could lock various pieces of a header. Keep in mind that by default, users with editing access can unlock these blocks. [Here’s an example of a pattern](https://gist.github.com/annezazu/acee30f8b6e8995e1b1a52796e6ef805) with various blocks locked in different ways and here’s more context on [creating a template with locked blocks](https://make.wordpress.org/core/2022/02/09/core-editor-improvement-curated-experiences-with-locking-apis-theme-json/). You can build these patterns in the editor itself, including adding locking options, before following the [documentation to register them](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-patterns/). +When building patterns or templates, theme authors can use these same UI tools to set the default locked state of blocks. For example, a theme author could lock various pieces of a header. Keep in mind that by default, users with editing access can unlock these blocks. [Here’s an example of a pattern](https://gist.github.com/annezazu/acee30f8b6e8995e1b1a52796e6ef805) with various blocks locked in different ways and here’s more context on [creating a template with locked blocks](https://make.wordpress.org/core/2022/02/09/core-editor-improvement-curated-experiences-with-locking-apis-theme-json/). You can build these patterns in the editor itself, including adding locking options, before following the [documentation to register them](/docs/reference-guides/block-api/block-patterns.md). **Apply content only editing in patterns or templates** @@ -33,7 +33,7 @@ This functionality was introduced in WordPress 6.1. In contrast to block locking - Additional child blocks cannot be inserted, further preserving the design and layout. - There is a link in the block toolbar to ‘Modify’ that a user can toggle on/off to have access to the broader design tools. Currently, it's not possibly to programmatically remove this option. -This option can be applied to Columns, Cover, and Group blocks as well as third-party blocks that have the templateLock attribute in its block.json. To adopt this functionality, you need to use `"templateLock":"contentOnly"`. [Here's an example of a pattern](https://gist.github.com/annezazu/d62acd2514cea558be6cea97fe28ff3c) with this functionality in place. For more information, please [review the relevant documentation](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-templates/#locking). +This option can be applied to Columns, Cover, and Group blocks as well as third-party blocks that have the templateLock attribute in its block.json. To adopt this functionality, you need to use `"templateLock":"contentOnly"`. [Here's an example of a pattern](https://gist.github.com/annezazu/d62acd2514cea558be6cea97fe28ff3c) with this functionality in place. For more information, please [review the relevant documentation](/docs/reference-guides/block-api/block-templates.md#locking). Note: There is no UI in place to manage content locking and it must be managed at the code level. @@ -232,7 +232,7 @@ Continuing the examples with duotone, this means you could allow full access to } ``` -You can read more about how best to [turn on/off options with theme.json here](https://developer.wordpress.org/block-editor/how-to-guides/themes/theme-json/). +You can read more about how best to [turn on/off options with theme.json here](/docs/how-to-guides/themes/theme-json.md). **Disable inherit default layout** @@ -326,7 +326,62 @@ add_filter( 'wp_theme_json_data_theme', 'example_filter_theme_json_data_theme' ) The filter receives an instance of the `WP_Theme_JSON_Data class` with the data for the respective layer. Then, you pass new data in a valid theme.json-like structure to the `update_with( $new_data )` method. A theme.json version number is required in `$new_data`. -Read more about this functionality in the [Filters for theme.json data dev note](https://make.wordpress.org/core/2022/10/10/filters-for-theme-json-data/). + +## Limiting interface options with client-side filters + +WordPress 6.2 introduced a new client-side filter allowing you to modify block-level [theme.json settings](/docs/reference-guides/theme-json-reference/theme-json-living.md#settings) before the editor is rendered. + +The filter is called `blockEditor.useSetting.before` and can be used in the JavaScript code as follows: + +``` +import { addFilter } from '@wordpress/hooks'; + +/** + * Limit the Column block's spacing options to pixels. + */ +addFilter( + 'blockEditor.useSetting.before', + 'example/useSetting.before', + ( settingValue, settingName, clientId, blockName ) => { + if ( blockName === Media & Text block'core/column' && settingName === 'spacing.units' ) { + return [ 'px' ]; + } + return settingValue; + } +); +``` + +This example will restrict the available spacing units for the Column block to just pixels. As discussed above, a similar restriction could be applied using theme.json filters or directly in a theme’s theme.json file using block-level settings. + +However, the `blockEditor.useSetting.before` filter is unique because it allows you to modify settings according to the block’s location, neighboring blocks, the current user’s role, and more. The possibilities for customization are extensive. + +In the following example, text color controls are disabled for the Heading block whenever the block is placed inside of a Media & Text block. + +``` +import { select } from '@wordpress/data'; +import { addFilter } from '@wordpress/hooks'; + +/** + * Disable text color controls on Heading blocks when placed inside of Media & Text blocks. + */ +addFilter( + 'blockEditor.useSetting.before', + 'example/useSetting.before', + ( settingValue, settingName, clientId, blockName ) => { + if ( blockName === 'core/heading' ) { + const { getBlockParents, getBlockName } = select( 'core/block-editor' ); + const blockParents = getBlockParents( clientId, true ); + const inMediaText = blockParents.some( ( ancestorId ) => getBlockName( ancestorId ) === 'core/media-text' ); + + if ( inMediaText && settingName === 'color.text' ) { + return false; + } + } + + return settingValue; + } +); +``` ## Remove access to functionality @@ -340,7 +395,7 @@ This prevents both the ability to both create new block templates or edit them f **Create an allow or disallow list to limit block options** -There might be times when you don’t want access to a block at all to be available for users. To control what’s available in the inserter, you can take two approaches: [an allow list](https://developer.wordpress.org/block-editor/reference-guides/filters/block-filters/#using-an-allow-list) that disables all blocks except those on the list or a [deny list that unregisters specific blocks](https://developer.wordpress.org/block-editor/reference-guides/filters/block-filters/#using-a-deny-list). +There might be times when you don’t want access to a block at all to be available for users. To control what’s available in the inserter, you can take two approaches: [an allow list](/docs/reference-guides/filters/block-filters.md#using-an-allow-list) that disables all blocks except those on the list or a [deny list that unregisters specific blocks](/docs/reference-guides/filters/block-filters.md#using-a-deny-list). **Disable pattern directory** @@ -360,7 +415,7 @@ Read more about this functionality in the [Page creation patterns in WordPress 6 **Lock patterns** -As mentioned in the prior section on Locking APIs, aspects of patterns themselves can be locked so that the important aspects of the design can be preserved. [Here’s an example of a pattern](https://gist.github.com/annezazu/acee30f8b6e8995e1b1a52796e6ef805) with various blocks locked in different ways. You can build these patterns in the editor itself, including adding locking options, before [following the documentation to register them](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-patterns/). +As mentioned in the prior section on Locking APIs, aspects of patterns themselves can be locked so that the important aspects of the design can be preserved. [Here’s an example of a pattern](https://gist.github.com/annezazu/acee30f8b6e8995e1b1a52796e6ef805) with various blocks locked in different ways. You can build these patterns in the editor itself, including adding locking options, before [following the documentation to register them](/docs/reference-guides/block-api/block-patterns.md). **Prioritize specific patterns from the Pattern Directory** @@ -372,7 +427,7 @@ With WordPress 6.0 themes can register patterns from [Pattern Directory](https:/ } ``` -Note that this field requires using [version 2 of theme.json](https://developer.wordpress.org/block-editor/reference-guides/theme-json-reference/theme-json-living/). The content creator will then find the respective Pattern in the inserter “Patterns” tab in the categories that match the categories from the Pattern Directory. +Note that this field requires using [version 2 of theme.json](/docs/reference-guides/theme-json-reference/theme-json-living.md). The content creator will then find the respective Pattern in the inserter “Patterns” tab in the categories that match the categories from the Pattern Directory. ## Combining approaches diff --git a/docs/how-to-guides/themes/theme-json.md b/docs/how-to-guides/themes/theme-json.md index 5956f36d52563..4a3dc8f70a85e 100644 --- a/docs/how-to-guides/themes/theme-json.md +++ b/docs/how-to-guides/themes/theme-json.md @@ -1174,7 +1174,7 @@ Currently block variations exist for "header" and "footer" values of the area te ### patterns -
Supported in WordPress from version 6.0 using [version 2](https://developer.wordpress.org/block-editor/reference-guides/theme-json-reference/theme-json-living/) of `theme.json`.
+
Supported in WordPress from version 6.0 using version 2 of theme.json.
Within this field themes can list patterns to register from [Pattern Directory](https://wordpress.org/patterns/). The `patterns` field is an array of pattern `slugs` from the Pattern Directory. Pattern slugs can be extracted by the `url` in single pattern view at the Pattern Directory. For example in this url `https://wordpress.org/patterns/pattern/partner-logos` the slug is `partner-logos`. diff --git a/docs/manifest.json b/docs/manifest.json index a55705ebbf3cf..8b6677d39c559 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1493,6 +1493,12 @@ "markdown_source": "../packages/compose/README.md", "parent": "packages" }, + { + "title": "@wordpress/core-commands", + "slug": "packages-core-commands", + "markdown_source": "../packages/core-commands/README.md", + "parent": "packages" + }, { "title": "@wordpress/core-data", "slug": "packages-core-data", @@ -1571,6 +1577,12 @@ "markdown_source": "../packages/dom/README.md", "parent": "packages" }, + { + "title": "@wordpress/e2e-test-utils-playwright", + "slug": "packages-e2e-test-utils-playwright", + "markdown_source": "../packages/e2e-test-utils-playwright/README.md", + "parent": "packages" + }, { "title": "@wordpress/e2e-test-utils", "slug": "packages-e2e-test-utils", @@ -1823,6 +1835,12 @@ "markdown_source": "../packages/rich-text/README.md", "parent": "packages" }, + { + "title": "@wordpress/router", + "slug": "packages-router", + "markdown_source": "../packages/router/README.md", + "parent": "packages" + }, { "title": "@wordpress/scripts", "slug": "packages-scripts", diff --git a/docs/reference-guides/block-api/block-deprecation.md b/docs/reference-guides/block-api/block-deprecation.md index 7154f84072e61..d40c13c040612 100644 --- a/docs/reference-guides/block-api/block-deprecation.md +++ b/docs/reference-guides/block-api/block-deprecation.md @@ -55,7 +55,9 @@ Deprecations are defined on a block type as its `deprecated` property, an array - _Return_ - `boolean`: Whether or not this otherwise valid block is eligible to be migrated by this deprecation. -It's important to note that `attributes`, `supports`, and `save` are not automatically inherited from the current version, since they can impact parsing and serialization of a block, so they must be defined on the deprecated object in order to be processed during a migration. +
+It's important to note that attributes, supports, and save are not automatically inherited from the current version, since they can impact parsing and serialization of a block, so they must be defined on the deprecated object in order to be processed during a migration. +
### Example: diff --git a/docs/reference-guides/block-api/block-registration.md b/docs/reference-guides/block-api/block-registration.md index caaec09504f04..cecdb663e3d60 100644 --- a/docs/reference-guides/block-api/block-registration.md +++ b/docs/reference-guides/block-api/block-registration.md @@ -3,9 +3,9 @@ Block registration API reference.
-You can use the functions documented on this page to register a block with JavaScript only on the client, but the recommended method is to register new block types also with PHP on the server using the `block.json` metadata file. See [metadata documentation for complete information](/docs/reference-guides/block-api/block-metadata.md). - -[Learn how to create your first block](/docs/getting-started/create-block/README.md) for the WordPress block editor. From setting up your development environment, tools, and getting comfortable with the new development model, this tutorial covers all you need to know to get started with creating blocks. +You can use the functions documented on this page to register a block with JavaScript only on the client, but the recommended method is to register new block types also with PHP on the server using the `block.json` metadata file. See metadata documentation for complete information +
+Learn how to create your first block for the WordPress block editor. From setting up your development environment, tools, and getting comfortable with the new development model, this tutorial covers all you need to know to get started with creating blocks.
## `registerBlockType` diff --git a/docs/reference-guides/core-blocks.md b/docs/reference-guides/core-blocks.md index 6c6879d36107a..e5d20bb7b1edd 100644 --- a/docs/reference-guides/core-blocks.md +++ b/docs/reference-guides/core-blocks.md @@ -226,7 +226,7 @@ Displays a title with the number of comments ([Source](https://github.com/WordPr ## Cover -Add an image or video with a text overlay — great for headers. ([Source](https://github.com/WordPress/gutenberg/tree/trunk/packages/block-library/src/cover)) +Add an image or video with a text overlay. ([Source](https://github.com/WordPress/gutenberg/tree/trunk/packages/block-library/src/cover)) - **Name:** core/cover - **Category:** media @@ -235,30 +235,12 @@ Add an image or video with a text overlay — great for headers. ([Source](https ## Details -A block that displays a summary and shows or hides additional content. ([Source](https://github.com/WordPress/gutenberg/tree/trunk/packages/block-library/src/details)) +Hide and show additional content. ([Source](https://github.com/WordPress/gutenberg/tree/trunk/packages/block-library/src/details)) - **Name:** core/details - **Category:** text -- **Supports:** align, color (background, gradients, link, text), spacing (margin, padding), typography (fontSize, lineHeight), ~~html~~ -- **Attributes:** showContent - -## Details Content - -Add content that may be shown or hidden via a Details block. ([Source](https://github.com/WordPress/gutenberg/tree/trunk/packages/block-library/src/details-content)) - -- **Name:** core/details-content -- **Category:** text -- **Supports:** color (background, gradients, link, text), spacing (margin, padding), typography (fontSize, lineHeight), ~~align~~, ~~html~~, ~~lock~~, ~~multiple~~, ~~reusable~~ -- **Attributes:** - -## Details Summary - -Provide summary text used to toggle the display of content inside a Details block. ([Source](https://github.com/WordPress/gutenberg/tree/trunk/packages/block-library/src/details-summary)) - -- **Name:** core/details-summary -- **Category:** text -- **Supports:** color (background, gradients, text), spacing (margin, padding), typography (fontSize, lineHeight), ~~align~~, ~~html~~, ~~lock~~, ~~multiple~~, ~~reusable~~ -- **Attributes:** summary +- **Supports:** align (full, wide), color (background, gradients, link, text), spacing (margin, padding), typography (fontSize, lineHeight), ~~html~~ +- **Attributes:** showContent, summary ## Embed @@ -383,7 +365,7 @@ Show login & logout links. ([Source](https://github.com/WordPress/gutenberg/tree - **Name:** core/loginout - **Category:** theme -- **Supports:** anchor, className, typography (~~fontSize~~) +- **Supports:** anchor, className, typography (fontSize, lineHeight) - **Attributes:** displayLoginAsForm, redirectToCurrent ## Media & Text diff --git a/docs/reference-guides/data/data-core.md b/docs/reference-guides/data/data-core.md index 2aa5ca2b19bf3..1ee04e09550e2 100644 --- a/docs/reference-guides/data/data-core.md +++ b/docs/reference-guides/data/data-core.md @@ -53,7 +53,7 @@ Returns all available authors. _Parameters_ - _state_ `State`: Data state. -- _query_ `GetRecordsHttpQuery`: Optional object of query parameters to include with request. +- _query_ `GetRecordsHttpQuery`: Optional object of query parameters to include with request. For valid query parameters see the [Users page](https://developer.wordpress.org/rest-api/reference/users/) in the REST API Handbook and see the arguments for [List Users](https://developer.wordpress.org/rest-api/reference/users/#list-users) and [Retrieve a User](https://developer.wordpress.org/rest-api/reference/users/#retrieve-a-user). _Returns_ @@ -126,6 +126,18 @@ _Returns_ - `any`: The current theme. +### getCurrentThemeGlobalStylesRevisions + +Returns the revisions of the current global styles theme. + +_Parameters_ + +- _state_ `State`: Data state. + +_Returns_ + +- `Object | null`: The current global styles. + ### getCurrentUser Returns the current user. @@ -234,7 +246,7 @@ _Parameters_ - _kind_ `string`: Entity kind. - _name_ `string`: Entity name. - _key_ `EntityRecordKey`: Record's key -- _query_ `GetRecordsHttpQuery`: Optional query. If requesting specific fields, fields must always include the ID. +- _query_ `GetRecordsHttpQuery`: Optional query. If requesting specific fields, fields must always include the ID. For valid query parameters see the [Reference](https://developer.wordpress.org/rest-api/reference/) in the REST API Handbook and select the entity kind. Then see the arguments available "Retrieve a [Entity kind]". _Returns_ @@ -281,7 +293,7 @@ _Parameters_ - _state_ `State`: State tree - _kind_ `string`: Entity kind. - _name_ `string`: Entity name. -- _query_ `GetRecordsHttpQuery`: Optional terms query. If requesting specific fields, fields must always include the ID. +- _query_ `GetRecordsHttpQuery`: Optional terms query. If requesting specific fields, fields must always include the ID. For valid query parameters see the [Reference](https://developer.wordpress.org/rest-api/reference/) in the REST API Handbook and select the entity kind. Then see the arguments available for "List [Entity kind]s". _Returns_ @@ -317,6 +329,18 @@ _Returns_ - `any`: The entity record's save error. +### getNavigationFallbackId + +Retrieve the fallback Navigation. + +_Parameters_ + +- _state_ `State`: Data state. + +_Returns_ + +- `EntityRecordKey | undefined`: The ID for the fallback Navigation post. + ### getRawEntityRecord Returns the entity's record object by key, with its attributes mapped to their raw values. @@ -424,7 +448,7 @@ _Parameters_ - _state_ `State`: State tree - _kind_ `string`: Entity kind. - _name_ `string`: Entity name. -- _query_ `GetRecordsHttpQuery`: Optional terms query. +- _query_ `GetRecordsHttpQuery`: Optional terms query. For valid query parameters see the [Reference](https://developer.wordpress.org/rest-api/reference/) in the REST API Handbook and select the entity kind. Then see the arguments available for "List [Entity kind]s". _Returns_ @@ -607,6 +631,18 @@ _Returns_ - `Object`: Action object. +### receiveNavigationFallbackId + +Returns an action object signalling that the fallback Navigation Menu id has been received. + +_Parameters_ + +- _fallbackId_ `integer`: the id of the fallback Navigation Menu + +_Returns_ + +- `Object`: Action object. + ### receiveThemeSupports > **Deprecated** since WP 5.9, this is not useful anymore, use the selector direclty. diff --git a/gutenberg.php b/gutenberg.php index 3e31496689e09..ce72c8eb81e4c 100644 --- a/gutenberg.php +++ b/gutenberg.php @@ -3,9 +3,9 @@ * Plugin Name: Gutenberg * Plugin URI: https://github.com/WordPress/gutenberg * Description: Printing since 1440. This is the development plugin for the block editor, site editor, and other future WordPress core functionality. - * Requires at least: 6.0 + * Requires at least: 6.1 * Requires PHP: 5.6 - * Version: 15.7.0-rc.1 + * Version: 15.8.0-rc.1 * Author: Gutenberg Team * Text Domain: gutenberg * diff --git a/lib/block-supports/layout.php b/lib/block-supports/layout.php index 33f616ac7ee8d..87cc4a6cc5f18 100644 --- a/lib/block-supports/layout.php +++ b/lib/block-supports/layout.php @@ -83,9 +83,8 @@ function gutenberg_get_layout_style( $selector, $layout, $has_block_gap_support $wide_max_width_value = $wide_size ? $wide_size : $content_size; // Make sure there is a single CSS rule, and all tags are stripped for security. - // TODO: Use `safecss_filter_attr` instead when the minimum required WP version is >= 6.1. - $all_max_width_value = wp_strip_all_tags( explode( ';', $all_max_width_value )[0] ); - $wide_max_width_value = wp_strip_all_tags( explode( ';', $wide_max_width_value )[0] ); + $all_max_width_value = safecss_filter_attr( explode( ';', $all_max_width_value )[0] ); + $wide_max_width_value = safecss_filter_attr( explode( ';', $wide_max_width_value )[0] ); $margin_left = 'left' === $justify_content ? '0 !important' : 'auto !important'; $margin_right = 'right' === $justify_content ? '0 !important' : 'auto !important'; diff --git a/lib/block-supports/typography.php b/lib/block-supports/typography.php index 644d65b761557..f1d29217e3883 100644 --- a/lib/block-supports/typography.php +++ b/lib/block-supports/typography.php @@ -211,44 +211,6 @@ function gutenberg_typography_get_preset_inline_style_value( $style_value, $css_ return sprintf( 'var(--wp--preset--%s--%s);', $css_property, $slug ); } -/** - * This method is no longer used and has been deprecated in Core since 6.1.0. - * - * It can be deleted once Gutenberg's minimum supported WordPress version is >= 6.1 - * - * Generates an inline style for a typography feature e.g. text decoration, - * text transform, and font style. - * - * @since 5.8.0 - * @deprecated 6.1.0 - * - * @param array $attributes Block's attributes. - * @param string $feature Key for the feature within the typography styles. - * @param string $css_property Slug for the CSS property the inline style sets. - * - * @return string CSS inline style. - */ -function gutenberg_typography_get_css_variable_inline_style( $attributes, $feature, $css_property ) { - // Retrieve current attribute value or skip if not found. - $style_value = _wp_array_get( $attributes, array( 'style', 'typography', $feature ), false ); - if ( ! $style_value ) { - return; - } - - // If we don't have a preset CSS variable, we'll assume it's a regular CSS value. - if ( ! str_contains( $style_value, "var:preset|{$css_property}|" ) ) { - return sprintf( '%s:%s;', $css_property, $style_value ); - } - - // We have a preset CSS variable as the style. - // Get the style value from the string and return CSS style. - $index_to_splice = strrpos( $style_value, '|' ) + 1; - $slug = substr( $style_value, $index_to_splice ); - - // Return the actual CSS inline style e.g. `text-decoration:var(--wp--preset--text-decoration--underline);`. - return sprintf( '%s:var(--wp--preset--%s--%s);', $css_property, $css_property, $slug ); -} - /** * Renders typography styles/content to the block wrapper. * @@ -303,12 +265,11 @@ function gutenberg_get_typography_value_and_unit( $raw_value, $options = array() return null; } - // Converts numeric values to pixel values by default. if ( empty( $raw_value ) ) { return null; } - // Converts numbers to pixel values by default. + // Converts numeric values to pixel values by default. if ( is_numeric( $raw_value ) ) { $raw_value = $raw_value . 'px'; } @@ -467,7 +428,10 @@ function gutenberg_get_typography_font_size_value( $preset, $should_use_fluid_ty } // Checks if fluid font sizes are activated. - $typography_settings = gutenberg_get_global_settings( array( 'typography' ) ); + $global_settings = gutenberg_get_global_settings(); + $typography_settings = isset( $global_settings['typography'] ) ? $global_settings['typography'] : array(); + $layout_settings = isset( $global_settings['layout'] ) ? $global_settings['layout'] : array(); + $should_use_fluid_typography = isset( $typography_settings['fluid'] ) && ( true === $typography_settings['fluid'] || is_array( $typography_settings['fluid'] ) ) ? @@ -481,7 +445,7 @@ function gutenberg_get_typography_font_size_value( $preset, $should_use_fluid_ty $fluid_settings = isset( $typography_settings['fluid'] ) && is_array( $typography_settings['fluid'] ) ? $typography_settings['fluid'] : array(); // Defaults. - $default_maximum_viewport_width = '1600px'; + $default_maximum_viewport_width = isset( $layout_settings['wideSize'] ) ? $layout_settings['wideSize'] : '1600px'; $default_minimum_viewport_width = '320px'; $default_minimum_font_size_factor_max = 0.75; $default_minimum_font_size_factor_min = 0.25; diff --git a/lib/blocks.php b/lib/blocks.php index f7ad8a11c88d9..bbee108b71c5f 100644 --- a/lib/blocks.php +++ b/lib/blocks.php @@ -23,8 +23,6 @@ function gutenberg_reregister_core_block_types() { 'columns', 'comments', 'details', - 'details-content', - 'details-summary', 'group', 'html', 'list', @@ -137,8 +135,6 @@ function gutenberg_reregister_core_block_types() { $block_folders = $details['block_folders']; $block_names = $details['block_names']; - $registry = WP_Block_Type_Registry::get_instance(); - foreach ( $block_folders as $folder_name ) { $block_json_file = $blocks_dir . $folder_name . '/block.json'; @@ -150,10 +146,7 @@ function gutenberg_reregister_core_block_types() { continue; } - if ( $registry->is_registered( $metadata['name'] ) ) { - $registry->unregister( $metadata['name'] ); - } - + gutenberg_deregister_core_block_and_assets( $metadata['name'] ); gutenberg_register_core_block_assets( $folder_name ); register_block_type_from_metadata( $block_json_file ); } @@ -165,9 +158,7 @@ function gutenberg_reregister_core_block_types() { $sub_block_names_normalized = is_string( $sub_block_names ) ? array( $sub_block_names ) : $sub_block_names; foreach ( $sub_block_names_normalized as $block_name ) { - if ( $registry->is_registered( $block_name ) ) { - $registry->unregister( $block_name ); - } + gutenberg_deregister_core_block_and_assets( $block_name ); gutenberg_register_core_block_assets( $block_name ); } @@ -178,6 +169,28 @@ function gutenberg_reregister_core_block_types() { add_action( 'init', 'gutenberg_reregister_core_block_types' ); +/** + * Deregisters the existing core block type and its assets. + * + * @param string $block_name The name of the block. + * + * @return void + */ +function gutenberg_deregister_core_block_and_assets( $block_name ) { + $registry = WP_Block_Type_Registry::get_instance(); + if ( $registry->is_registered( $block_name ) ) { + $block_type = $registry->get_registered( $block_name ); + if ( ! empty( $block_type->view_script_handles ) ) { + foreach ( $block_type->view_script_handles as $view_script_handle ) { + if ( str_starts_with( $view_script_handle, 'wp-block-' ) ) { + wp_deregister_script( $view_script_handle ); + } + } + } + $registry->unregister( $block_name ); + } +} + /** * Registers block styles for a core block. * diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php index fbbf9a05d1c81..6f72ef2c403ce 100644 --- a/lib/class-wp-theme-json-gutenberg.php +++ b/lib/class-wp-theme-json-gutenberg.php @@ -795,7 +795,7 @@ protected static function sanitize( $input, $valid_block_names, $valid_element_n if ( empty( $result ) ) { unset( $output[ $subtree ] ); } else { - $output[ $subtree ] = $result; + $output[ $subtree ] = static::resolve_custom_css_format( $result ); } } @@ -1989,20 +1989,6 @@ protected static function get_property_value( $styles, $path, $theme_json = null return $value; } - // Convert custom CSS properties. - $prefix = 'var:'; - $prefix_len = strlen( $prefix ); - $token_in = '|'; - $token_out = '--'; - if ( 0 === strncmp( $value, $prefix, $prefix_len ) ) { - $unwrapped_name = str_replace( - $token_in, - $token_out, - substr( $value, $prefix_len ) - ); - $value = "var(--wp--$unwrapped_name)"; - } - return $value; } @@ -3578,4 +3564,51 @@ protected function get_feature_declarations_for_node( $metadata, &$node ) { return $declarations; } + + /** + * This is used to convert the internal representation of variables to the CSS representation. + * For example, `var:preset|color|vivid-green-cyan` becomes `var(--wp--preset--color--vivid-green-cyan)`. + * + * @since 6.3.0 + * @param string $value The variable such as var:preset|color|vivid-green-cyan to convert. + * @return string The converted variable. + */ + private static function convert_custom_properties( $value ) { + $prefix = 'var:'; + $prefix_len = strlen( $prefix ); + $token_in = '|'; + $token_out = '--'; + if ( 0 === strpos( $value, $prefix ) ) { + $unwrapped_name = str_replace( + $token_in, + $token_out, + substr( $value, $prefix_len ) + ); + $value = "var(--wp--$unwrapped_name)"; + } + + return $value; + } + + /** + * Given a tree, converts the internal representation of variables to the CSS representation. + * It is recursive and modifies the input in-place. + * + * @since 6.3.0 + * @param array $tree Input to process. + * @return array The modified $tree. + */ + private static function resolve_custom_css_format( $tree ) { + $prefix = 'var:'; + + foreach ( $tree as $key => $data ) { + if ( is_string( $data ) && 0 === strpos( $data, $prefix ) ) { + $tree[ $key ] = self::convert_custom_properties( $data ); + } elseif ( is_array( $data ) ) { + $tree[ $key ] = self::resolve_custom_css_format( $data ); + } + } + + return $tree; + } } diff --git a/lib/client-assets.php b/lib/client-assets.php index 473221960e4de..9757e4b7ff24a 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -77,16 +77,8 @@ function gutenberg_override_script( $scripts, $handle, $src, $deps = array(), $v $scripts->add( $handle, $src, $deps, $ver, ( $in_footer ? 1 : null ) ); } - /* - * `WP_Dependencies::set_translations` will fall over on itself if setting - * translations on the `wp-i18n` handle, since it internally adds `wp-i18n` - * as a dependency of itself, exhausting memory. The same applies for the - * polyfill and hooks scripts, which are dependencies _of_ `wp-i18n`. - * - * See: https://core.trac.wordpress.org/ticket/46089 - */ - if ( ! in_array( $handle, array( 'wp-i18n', 'wp-polyfill', 'wp-hooks' ), true ) ) { - $scripts->set_translations( $handle, 'default' ); + if ( in_array( 'wp-i18n', $deps, true ) ) { + $scripts->set_translations( $handle ); } /* @@ -221,6 +213,9 @@ function gutenberg_register_packages_scripts( $scripts ) { case 'wp-edit-site': array_push( $dependencies, 'wp-dom-ready' ); break; + case 'wp-preferences': + array_push( $dependencies, 'wp-preferences-persistence' ); + break; } // Get the path from Gutenberg directory as expected by `gutenberg_url`. @@ -283,7 +278,7 @@ function gutenberg_register_packages_styles( $styles ) { $styles, 'wp-edit-post', gutenberg_url( 'build/edit-post/style.css' ), - array( 'wp-components', 'wp-block-editor', 'wp-editor', 'wp-edit-blocks', 'wp-block-library' ), + array( 'wp-components', 'wp-block-editor', 'wp-editor', 'wp-edit-blocks', 'wp-block-library', 'wp-commands' ), $version ); $styles->add_data( 'wp-edit-post', 'rtl', 'replace' ); diff --git a/lib/compat/wordpress-6.1/block-editor-settings.php b/lib/compat/wordpress-6.1/block-editor-settings.php deleted file mode 100644 index cafe91e787dc6..0000000000000 --- a/lib/compat/wordpress-6.1/block-editor-settings.php +++ /dev/null @@ -1,177 +0,0 @@ -=' ) && version_compare( $wp_version, '6.0-beta1', '<' ); - $is_wp_6_0 = version_compare( $wp_version, '6.0-beta1', '>=' ); - - // Make sure the styles array exists. - // In some contexts, like the navigation editor, it doesn't. - if ( ! isset( $settings['styles'] ) ) { - $settings['styles'] = array(); - } - - // Remove existing global styles provided by core. - $styles_without_existing_global_styles = array(); - foreach ( $settings['styles'] as $style ) { - if ( - ( $is_wp_5_9 && ! gutenberg_is_global_styles_in_5_9( $style ) ) || // Can be removed when plugin minimum version is 6.0. - ( $is_wp_6_0 && ( ! isset( $style['isGlobalStyles'] ) || ! $style['isGlobalStyles'] ) ) - ) { - $styles_without_existing_global_styles[] = $style; - } - } - - // Recreate global styles. - $new_global_styles = array(); - $presets = array( - array( - 'css' => 'variables', - '__unstableType' => 'presets', - 'isGlobalStyles' => true, - ), - array( - 'css' => 'presets', - '__unstableType' => 'presets', - 'isGlobalStyles' => true, - ), - ); - foreach ( $presets as $preset_style ) { - $actual_css = gutenberg_get_global_stylesheet( array( $preset_style['css'] ) ); - if ( '' !== $actual_css ) { - $preset_style['css'] = $actual_css; - $new_global_styles[] = $preset_style; - } - } - - if ( wp_theme_has_theme_json() ) { - $block_classes = array( - 'css' => 'styles', - '__unstableType' => 'theme', - 'isGlobalStyles' => true, - ); - $actual_css = gutenberg_get_global_stylesheet( array( $block_classes['css'] ) ); - if ( '' !== $actual_css ) { - $block_classes['css'] = $actual_css; - $new_global_styles[] = $block_classes; - } - } else { - // If there is no `theme.json` file, ensure base layout styles are still available. - $block_classes = array( - 'css' => 'base-layout-styles', - '__unstableType' => 'base-layout', - 'isGlobalStyles' => true, - ); - $actual_css = gutenberg_get_global_stylesheet( array( $block_classes['css'] ) ); - if ( '' !== $actual_css ) { - $block_classes['css'] = $actual_css; - $new_global_styles[] = $block_classes; - } - } - - $settings['styles'] = array_merge( $new_global_styles, $styles_without_existing_global_styles ); - } - - // Copied from get_block_editor_settings() at wordpress-develop/block-editor.php. - $settings['__experimentalFeatures'] = gutenberg_get_global_settings(); - - if ( isset( $settings['__experimentalFeatures']['color']['palette'] ) ) { - $colors_by_origin = $settings['__experimentalFeatures']['color']['palette']; - $settings['colors'] = isset( $colors_by_origin['custom'] ) ? - $colors_by_origin['custom'] : ( - isset( $colors_by_origin['theme'] ) ? - $colors_by_origin['theme'] : - $colors_by_origin['default'] - ); - } - - if ( isset( $settings['__experimentalFeatures']['color']['gradients'] ) ) { - $gradients_by_origin = $settings['__experimentalFeatures']['color']['gradients']; - $settings['gradients'] = isset( $gradients_by_origin['custom'] ) ? - $gradients_by_origin['custom'] : ( - isset( $gradients_by_origin['theme'] ) ? - $gradients_by_origin['theme'] : - $gradients_by_origin['default'] - ); - } - - if ( isset( $settings['__experimentalFeatures']['typography']['fontSizes'] ) ) { - $font_sizes_by_origin = $settings['__experimentalFeatures']['typography']['fontSizes']; - $settings['fontSizes'] = isset( $font_sizes_by_origin['custom'] ) ? - $font_sizes_by_origin['custom'] : ( - isset( $font_sizes_by_origin['theme'] ) ? - $font_sizes_by_origin['theme'] : - $font_sizes_by_origin['default'] - ); - } - - if ( isset( $settings['__experimentalFeatures']['color']['custom'] ) ) { - $settings['disableCustomColors'] = ! $settings['__experimentalFeatures']['color']['custom']; - unset( $settings['__experimentalFeatures']['color']['custom'] ); - } - if ( isset( $settings['__experimentalFeatures']['color']['customGradient'] ) ) { - $settings['disableCustomGradients'] = ! $settings['__experimentalFeatures']['color']['customGradient']; - unset( $settings['__experimentalFeatures']['color']['customGradient'] ); - } - if ( isset( $settings['__experimentalFeatures']['typography']['customFontSize'] ) ) { - $settings['disableCustomFontSizes'] = ! $settings['__experimentalFeatures']['typography']['customFontSize']; - unset( $settings['__experimentalFeatures']['typography']['customFontSize'] ); - } - if ( isset( $settings['__experimentalFeatures']['typography']['lineHeight'] ) ) { - $settings['enableCustomLineHeight'] = $settings['__experimentalFeatures']['typography']['lineHeight']; - unset( $settings['__experimentalFeatures']['typography']['lineHeight'] ); - } - if ( isset( $settings['__experimentalFeatures']['spacing']['units'] ) ) { - $settings['enableCustomUnits'] = $settings['__experimentalFeatures']['spacing']['units']; - unset( $settings['__experimentalFeatures']['spacing']['units'] ); - } - if ( isset( $settings['__experimentalFeatures']['spacing']['padding'] ) ) { - $settings['enableCustomSpacing'] = $settings['__experimentalFeatures']['spacing']['padding']; - unset( $settings['__experimentalFeatures']['spacing']['padding'] ); - } - if ( isset( $settings['__experimentalFeatures']['spacing']['customSpacingSize'] ) ) { - $settings['disableCustomSpacingSizes'] = ! $settings['__experimentalFeatures']['spacing']['customSpacingSize']; - unset( $settings['__experimentalFeatures']['spacing']['customSpacingSize'] ); - } - - if ( isset( $settings['__experimentalFeatures']['spacing']['spacingSizes'] ) ) { - $spacing_sizes_by_origin = $settings['__experimentalFeatures']['spacing']['spacingSizes']; - $settings['spacingSizes'] = isset( $spacing_sizes_by_origin['custom'] ) ? - $spacing_sizes_by_origin['custom'] : ( - isset( $spacing_sizes_by_origin['theme'] ) ? - $spacing_sizes_by_origin['theme'] : - $spacing_sizes_by_origin['default'] - ); - } - - $settings['localAutosaveInterval'] = 15; - $settings['disableLayoutStyles'] = current_theme_supports( 'disable-layout-styles' ); - - return $settings; -} - -add_filter( 'block_editor_settings_all', 'gutenberg_get_block_editor_settings', PHP_INT_MAX ); diff --git a/lib/compat/wordpress-6.1/block-template-utils.php b/lib/compat/wordpress-6.1/block-template-utils.php deleted file mode 100644 index 9a02e8187a2b8..0000000000000 --- a/lib/compat/wordpress-6.1/block-template-utils.php +++ /dev/null @@ -1,598 +0,0 @@ - array( 'auto-draft', 'draft', 'publish' ), - 'post_type' => $template_type, - 'posts_per_page' => -1, - 'no_found_rows' => true, - 'lazy_load_term_meta' => false, // Do not lazy load term meta, as template post types only have one term. - 'tax_query' => array( - array( - 'taxonomy' => 'wp_theme', - 'field' => 'name', - 'terms' => get_stylesheet(), - ), - ), - ); - - if ( 'wp_template_part' === $template_type && isset( $query['area'] ) ) { - $wp_query_args['tax_query'][] = array( - 'taxonomy' => 'wp_template_part_area', - 'field' => 'name', - 'terms' => $query['area'], - ); - $wp_query_args['tax_query']['relation'] = 'AND'; - } - - if ( isset( $query['slug__in'] ) ) { - $wp_query_args['post_name__in'] = $query['slug__in']; - } - - // This is only needed for the regular templates/template parts CPT listing and editor. - if ( isset( $query['wp_id'] ) ) { - $wp_query_args['p'] = $query['wp_id']; - } else { - $wp_query_args['post_status'] = 'publish'; - } - - $template_query = new WP_Query( $wp_query_args ); - $query_result = array(); - foreach ( $template_query->posts as $post ) { - $template = gutenberg_build_block_template_result_from_post( $post ); - if ( is_wp_error( $template ) ) { - continue; - } - - if ( $post_type && ! $template->is_custom ) { - continue; - } - - if ( $post_type && - isset( $template->post_types ) && - ! in_array( $post_type, $template->post_types, true ) - ) { - continue; - } - - $query_result[] = $template; - } - if ( ! isset( $query['wp_id'] ) ) { - $template_files = _get_block_templates_files( $template_type ); - foreach ( $template_files as $template_file ) { - $template = _build_block_template_result_from_file( $template_file, $template_type ); - - if ( $post_type && ! $template->is_custom ) { - continue; - } - - if ( $post_type && - isset( $template->post_types ) && - ! in_array( $post_type, $template->post_types, true ) - ) { - continue; - } - - $is_not_custom = false === array_search( - get_stylesheet() . '//' . $template_file['slug'], - array_column( $query_result, 'id' ), - true - ); - $fits_slug_query = - ! isset( $query['slug__in'] ) || in_array( $template_file['slug'], $query['slug__in'], true ); - $fits_area_query = - ! isset( $query['area'] ) || $template_file['area'] === $query['area']; - $should_include = $is_not_custom && $fits_slug_query && $fits_area_query; - if ( $should_include ) { - $query_result[] = $template; - } - } - } - /** - * Filters the array of queried block templates array after they've been fetched. - * - * @since 10.8 - * - * @param Gutenberg_Block_Template[] $query_result Array of found block templates. - * @param array $query { - * Optional. Arguments to retrieve templates. - * - * @type array $slug__in List of slugs to include. - * @type int $wp_id Post ID of customized template. - * } - * @param array $template_type wp_template or wp_template_part. - */ - return apply_filters( 'get_block_templates', $query_result, $query, $template_type ); -} - -/** - * Retrieves a single unified template object using its id. - * - * @param string $id Template unique identifier (example: theme_slug//template_slug). - * @param array $template_type wp_template or wp_template_part. - * - * @return Gutenberg_Block_Template|null Template. - */ -function gutenberg_get_block_template( $id, $template_type = 'wp_template' ) { - /** - * Filters the block template object before the query takes place. - * - * Return a non-null value to bypass the WordPress queries. - * - * @since 10.8 - * - * @param Gutenberg_Block_Template|null $block_template Return block template object to short-circuit the default query, - * or null to allow WP to run it's normal queries. - * @param string $id Template unique identifier (example: theme_slug//template_slug). - * @param array $template_type wp_template or wp_template_part. - */ - $block_template = apply_filters( 'pre_get_block_template', null, $id, $template_type ); - if ( ! is_null( $block_template ) ) { - return $block_template; - } - - $parts = explode( '//', $id, 2 ); - if ( count( $parts ) < 2 ) { - return null; - } - list( $theme, $slug ) = $parts; - $wp_query_args = array( - 'post_name__in' => array( $slug ), - 'post_type' => $template_type, - 'post_status' => array( 'auto-draft', 'draft', 'publish', 'trash' ), - 'posts_per_page' => 1, - 'no_found_rows' => true, - 'tax_query' => array( - array( - 'taxonomy' => 'wp_theme', - 'field' => 'name', - 'terms' => $theme, - ), - ), - ); - $template_query = new WP_Query( $wp_query_args ); - $posts = $template_query->posts; - - if ( count( $posts ) > 0 ) { - $template = gutenberg_build_block_template_result_from_post( $posts[0] ); - - if ( ! is_wp_error( $template ) ) { - return $template; - } - } - - $block_template = get_block_file_template( $id, $template_type ); - - /** - * Filters the queried block template object after it's been fetched. - * - * @since 10.8 - * - * @param Gutenberg_Block_Template|null $block_template The found block template, or null if there isn't one. - * @param string $id Template unique identifier (example: theme_slug//template_slug). - * @param array $template_type wp_template or wp_template_part. - */ - return apply_filters( 'get_block_template', $block_template, $id, $template_type ); -} - -/** - * Builds the title and description of a post-specific template based on the underlying referenced post. - * Mutates the underlying template object. - * - * @since 6.1.0 - * @access private - * @internal - * - * @param string $post_type Post type e.g.: page, post, product. - * @param string $slug Slug of the post e.g.: a-story-about-shoes. - * @param WP_Block_Template $template Template to mutate adding the description and title computed. - * @return boolean Returns true if the referenced post was found and false otherwise. - */ -function _gutenberg_build_title_and_description_for_single_post_type_block_template( $post_type, $slug, WP_Block_Template $template ) { - $post_type_object = get_post_type_object( $post_type ); - - $default_args = array( - 'post_type' => $post_type, - 'post_status' => 'publish', - 'posts_per_page' => 1, - 'update_post_meta_cache' => false, - 'update_post_term_cache' => false, - 'ignore_sticky_posts' => true, - 'no_found_rows' => true, - ); - - $args = array( - 'name' => $slug, - ); - $args = wp_parse_args( $args, $default_args ); - - $posts_query = new WP_Query( $args ); - - if ( empty( $posts_query->posts ) ) { - $template->title = sprintf( - /* translators: Custom template title in the Site Editor referencing a post that was not found. 1: Post type singular name, 2: Post type slug. */ - __( 'Not found: %1$s (%2$s)', 'gutenberg' ), - $post_type_object->labels->singular_name, - $slug - ); - - return false; - } - - $post_title = $posts_query->posts[0]->post_title; - - $template->title = sprintf( - /* translators: Custom template title in the Site Editor. 1: Post type singular name, 2: Post title. */ - __( '%1$s: %2$s', 'gutenberg' ), - $post_type_object->labels->singular_name, - $post_title - ); - - $template->description = sprintf( - /* translators: Custom template description in the Site Editor. %s: Post title. */ - __( 'Template for %s', 'gutenberg' ), - $post_title - ); - - $args = array( - 'title' => $post_title, - ); - $args = wp_parse_args( $args, $default_args ); - - $posts_with_same_title_query = new WP_Query( $args ); - - if ( count( $posts_with_same_title_query->posts ) > 1 ) { - $template->title = sprintf( - /* translators: Custom template title in the Site Editor. 1: Template title, 2: Post type slug. */ - __( '%1$s (%2$s)', 'gutenberg' ), - $template->title, - $slug - ); - } - - return true; -} - -/** - * Builds the title and description of a taxonomy-specific template based on the underlying entity referenced. - * Mutates the underlying template object. - * - * @access private - * @internal - * - * @param string $taxonomy Identifier of the taxonomy, e.g.: category. - * @param string $slug Slug of the term, e.g.: shoes. - * @param WP_Block_Template $template Template to mutate adding the description and title computed. - * - * @return boolean True if the term referenced was found and false otherwise. - */ -function _gutenberg_build_title_and_description_for_taxonomy_block_template( $taxonomy, $slug, WP_Block_Template $template ) { - $taxonomy_object = get_taxonomy( $taxonomy ); - - $default_args = array( - 'taxonomy' => $taxonomy, - 'hide_empty' => false, - 'update_term_meta_cache' => false, - ); - - $term_query = new WP_Term_Query(); - - $args = array( - 'number' => 1, - 'slug' => $slug, - ); - $args = wp_parse_args( $args, $default_args ); - - $terms_query = $term_query->query( $args ); - - if ( empty( $terms_query ) ) { - $template->title = sprintf( - /* translators: Custom template title in the Site Editor, referencing a taxonomy term that was not found. 1: Taxonomy singular name, 2: Term slug. */ - __( 'Not found: %1$s (%2$s)', 'gutenberg' ), - $taxonomy_object->labels->singular_name, - $slug - ); - return false; - } - - $term_title = $terms_query[0]->name; - - $template->title = sprintf( - /* translators: Custom template title in the Site Editor. 1: Taxonomy singular name, 2: Term title. */ - __( '%1$s: %2$s', 'gutenberg' ), - $taxonomy_object->labels->singular_name, - $term_title - ); - - $template->description = sprintf( - /* translators: Custom template description in the Site Editor. %s: Term title. */ - __( 'Template for %s', 'gutenberg' ), - $term_title - ); - - $term_query = new WP_Term_Query(); - - $args = array( - 'number' => 2, - 'name' => $term_title, - ); - $args = wp_parse_args( $args, $default_args ); - - $terms_with_same_title_query = $term_query->query( $args ); - - if ( count( $terms_with_same_title_query ) > 1 ) { - $template->title = sprintf( - /* translators: Custom template title in the Site Editor. 1: Template title, 2: Term slug. */ - __( '%1$s (%2$s)', 'gutenberg' ), - $template->title, - $slug - ); - } - - return true; -} - -/** - * Build a unified template object based a post Object. - * - * @param WP_Post $post Template post. - * - * @return Gutenberg_Block_Template|WP_Error Template. - */ -function gutenberg_build_block_template_result_from_post( $post ) { - $default_template_types = get_default_block_template_types(); - $terms = get_the_terms( $post, 'wp_theme' ); - - if ( is_wp_error( $terms ) ) { - return $terms; - } - - if ( ! $terms ) { - return new WP_Error( 'template_missing_theme', __( 'No theme is defined for this template.', 'gutenberg' ) ); - } - - $origin = get_post_meta( $post->ID, 'origin', true ); - $is_wp_suggestion = get_post_meta( $post->ID, 'is_wp_suggestion', true ); - - $theme = $terms[0]->name; - $template_file = _get_block_template_file( $post->post_type, $post->post_name ); - $has_theme_file = get_stylesheet() === $theme && null !== $template_file; - - $template = new WP_Block_Template(); - $template->wp_id = $post->ID; - $template->id = $theme . '//' . $post->post_name; - $template->theme = $theme; - $template->content = $post->post_content; - $template->slug = $post->post_name; - $template->source = 'custom'; - $template->origin = ! empty( $origin ) ? $origin : null; - $template->type = $post->post_type; - $template->description = $post->post_excerpt; - $template->title = $post->post_title; - $template->status = $post->post_status; - $template->has_theme_file = $has_theme_file; - $template->is_custom = empty( $is_wp_suggestion ); - $template->author = $post->post_author; - - // We keep this check for existent templates that are part of the template hierarchy. - if ( 'wp_template' === $post->post_type && isset( $default_template_types[ $template->slug ] ) ) { - $template->is_custom = false; - } - - if ( 'wp_template' === $post->post_type && $has_theme_file && isset( $template_file['postTypes'] ) ) { - $template->post_types = $template_file['postTypes']; - } - - if ( 'wp_template_part' === $post->post_type ) { - $type_terms = get_the_terms( $post, 'wp_template_part_area' ); - if ( ! is_wp_error( $type_terms ) && false !== $type_terms ) { - $template->area = $type_terms[0]->name; - } - } - // If it is a block template without description and without title or with title equal to the slug. - if ( 'wp_template' === $post->post_type && empty( $template->description ) && ( empty( $template->title ) || $template->title === $template->slug ) ) { - $matches = array(); - // If it is a block template for a single author, page, post, tag, category, custom post type or custom taxonomy. - if ( preg_match( '/(author|page|single|tag|category|taxonomy)-(.+)/', $template->slug, $matches ) ) { - $type = $matches[1]; - $slug_remaining = $matches[2]; - switch ( $type ) { - case 'author': - $nice_name = $slug_remaining; - $users = get_users( - array( - 'capability' => 'edit_posts', - 'search' => $nice_name, - 'search_columns' => array( 'user_nicename' ), - 'fields' => 'display_name', - ) - ); - - if ( empty( $users ) ) { - $template->title = sprintf( - // translators: Represents the title of a user's custom template in the Site Editor referencing a deleted author, where %s is the author's nicename, e.g. "Deleted author: jane-doe". - __( 'Deleted author: %s', 'gutenberg' ), - $nice_name - ); - } else { - $author_name = $users[0]; - - $template->title = sprintf( - // translators: Represents the title of a user's custom template in the Site Editor, where %s is the author's name, e.g. "Author: Jane Doe". - __( 'Author: %s', 'gutenberg' ), - $author_name - ); - $template->description = sprintf( - // translators: Represents the description of a user's custom template in the Site Editor, e.g. "Template for Author: Jane Doe". - __( 'Template for %1$s', 'gutenberg' ), - $author_name - ); - - $users_with_same_name = get_users( - array( - 'capability' => 'edit_posts', - 'search' => $author_name, - 'search_columns' => array( 'display_name' ), - 'fields' => 'display_name', - ) - ); - if ( count( $users_with_same_name ) > 1 ) { - $template->title = sprintf( - // translators: Represents the title of a user's custom template in the Site Editor, where %1$s is the template title of an author template and %2$s is the nicename of the author, e.g. "Author: Jane Doe (jane-doe)". - __( '%1$s (%2$s)', 'gutenberg' ), - $template->title, - $nice_name - ); - } - } - break; - case 'page': - _gutenberg_build_title_and_description_for_single_post_type_block_template( 'page', $slug_remaining, $template ); - break; - case 'single': - $post_types = get_post_types(); - foreach ( $post_types as $post_type ) { - $post_type_length = strlen( $post_type ) + 1; - // If $slug_remaining starts with $post_type followed by a hyphen. - if ( 0 === strncmp( $slug_remaining, $post_type . '-', $post_type_length ) ) { - $slug = substr( $slug_remaining, $post_type_length, strlen( $slug_remaining ) ); - $found = _gutenberg_build_title_and_description_for_single_post_type_block_template( $post_type, $slug, $template ); - if ( $found ) { - break; - } - } - } - break; - case 'tag': - _gutenberg_build_title_and_description_for_taxonomy_block_template( 'post_tag', $slug_remaining, $template ); - break; - case 'category': - _gutenberg_build_title_and_description_for_taxonomy_block_template( 'category', $slug_remaining, $template ); - break; - case 'taxonomy': - $taxonomies = get_taxonomies(); - foreach ( $taxonomies as $taxonomy ) { - $taxonomy_length = strlen( $taxonomy ) + 1; - // If $slug_remaining starts with $taxonomy followed by a hyphen. - if ( 0 === strncmp( $slug_remaining, $taxonomy . '-', $taxonomy_length ) ) { - $slug = substr( $slug_remaining, $taxonomy_length, strlen( $slug_remaining ) ); - $found = _gutenberg_build_title_and_description_for_taxonomy_block_template( $taxonomy, $slug, $template ); - if ( $found ) { - break; - } - } - } - break; - } - } - } - return $template; -} - -if ( ! function_exists( 'get_template_hierarchy' ) ) { - /** - * Helper function to get the Template Hierarchy for a given slug. - * We need to Handle special cases here like `front-page`, `singular` and `archive` templates. - * - * Noting that we always add `index` as the last fallback template. - * - * @param string $slug The template slug to be created. - * @param boolean $is_custom Indicates if a template is custom or part of the template hierarchy. - * @param string $template_prefix The template prefix for the created template. This is used to extract the main template type ex. in `taxonomy-books` we extract the `taxonomy`. - * - * @return array The template hierarchy. - */ - function get_template_hierarchy( $slug, $is_custom = false, $template_prefix = '' ) { - if ( 'index' === $slug ) { - return array( 'index' ); - } - if ( $is_custom ) { - return array( 'page', 'singular', 'index' ); - } - if ( 'front-page' === $slug ) { - return array( 'front-page', 'home', 'index' ); - } - $template_hierarchy = array( $slug ); - // Most default templates don't have `$template_prefix` assigned. - if ( $template_prefix ) { - list($type) = explode( '-', $template_prefix ); - // We need these checks because we always add the `$slug` above. - if ( ! in_array( $template_prefix, array( $slug, $type ), true ) ) { - $template_hierarchy[] = $template_prefix; - } - if ( $slug !== $type ) { - $template_hierarchy[] = $type; - } - } - // Handle `archive` template. - if ( - str_starts_with( $slug, 'author' ) || - str_starts_with( $slug, 'taxonomy' ) || - str_starts_with( $slug, 'category' ) || - str_starts_with( $slug, 'tag' ) || - 'date' === $slug - ) { - $template_hierarchy[] = 'archive'; - } - // Handle `single` template. - if ( 'attachment' === $slug ) { - $template_hierarchy[] = 'single'; - } - // Handle `singular` template. - if ( - str_starts_with( $slug, 'single' ) || - str_starts_with( $slug, 'page' ) || - 'attachment' === $slug - ) { - $template_hierarchy[] = 'singular'; - } - $template_hierarchy[] = 'index'; - return $template_hierarchy; - } -} diff --git a/lib/compat/wordpress-6.1/blocks.php b/lib/compat/wordpress-6.1/blocks.php deleted file mode 100644 index 908cf3f088eb9..0000000000000 --- a/lib/compat/wordpress-6.1/blocks.php +++ /dev/null @@ -1,185 +0,0 @@ -= 6.1. - * - * @param string[] $attrs Array of allowed CSS attributes. - * @return string[] CSS attributes. - */ -function gutenberg_safe_style_attrs_6_1( $attrs ) { - $attrs[] = 'flex-wrap'; - $attrs[] = 'gap'; - $attrs[] = 'column-gap'; - $attrs[] = 'row-gap'; - $attrs[] = 'margin-block-start'; - $attrs[] = 'margin-block-end'; - $attrs[] = 'margin-inline-start'; - $attrs[] = 'margin-inline-end'; - - return $attrs; -} -add_filter( 'safe_style_css', 'gutenberg_safe_style_attrs_6_1' ); - -/** - * Update allowed CSS values to match WordPress 6.1. - * - * Note: This should be removed when the minimum required WP version is >= 6.1. - * - * The logic in this function follows that provided in: https://core.trac.wordpress.org/ticket/55966. - * - * @param boolean $allow_css Whether or not the current test string is allowed. - * @param string $css_test_string The CSS string to be tested. - * @return boolean - */ -function gutenberg_safecss_filter_attr_allow_css_6_1( $allow_css, $css_test_string ) { - if ( false === $allow_css ) { - /* - * Allow CSS functions like var(), calc(), etc. by removing them from the test string. - * Nested functions and parentheses are also removed, so long as the parentheses are balanced. - */ - $css_test_string = preg_replace( - '/\b(?:var|calc|min|max|minmax|clamp)(\((?:[^()]|(?1))*\))/', - '', - $css_test_string - ); - - // Check for any CSS containing \ ( & } = or comments, - // except for url(), calc(), or var() usage checked above. - $allow_css = ! preg_match( '%[\\\(&=}]|/\*%', $css_test_string ); - } - return $allow_css; -} -add_filter( 'safecss_filter_attr_allow_css', 'gutenberg_safecss_filter_attr_allow_css_6_1', 10, 2 ); - -/** - * Registers view scripts for core blocks if handling is missing in WordPress core. - * - * @since 6.1.0 - * - * @param array $settings Array of determined settings for registering a block type. - * @param array $metadata Metadata provided for registering a block type. - * - * @return array Array of settings for registering a block type. - */ -function gutenberg_block_type_metadata_view_script( $settings, $metadata ) { - if ( - ! isset( $metadata['viewScript'] ) || - ! empty( $settings['view_script'] ) || - ! isset( $metadata['file'] ) || - ! str_starts_with( $metadata['file'], wp_normalize_path( gutenberg_dir_path() ) ) - ) { - return $settings; - } - - $view_script_path = wp_normalize_path( realpath( dirname( $metadata['file'] ) . '/' . remove_block_asset_path_prefix( $metadata['viewScript'] ) ) ); - - if ( file_exists( $view_script_path ) ) { - $view_script_id = str_replace( array( '.min.js', '.js' ), '', basename( remove_block_asset_path_prefix( $metadata['viewScript'] ) ) ); - $view_script_handle = str_replace( 'core/', 'wp-block-', $metadata['name'] ) . '-' . $view_script_id; - wp_deregister_script( $view_script_handle ); - - // 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_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( - $view_script_handle, - gutenberg_url( str_replace( wp_normalize_path( gutenberg_dir_path() ), '', $view_script_path ) ), - $view_script_dependencies, - $view_script_version - ); - if ( $result ) { - $settings['view_script'] = $view_script_handle; - - if ( ! empty( $metadata['textdomain'] ) && in_array( 'wp-i18n', $view_script_dependencies, true ) ) { - wp_set_script_translations( $view_script_handle, $metadata['textdomain'] ); - } - } - } - return $settings; -} -add_filter( 'block_type_metadata_settings', 'gutenberg_block_type_metadata_view_script', 10, 2 ); - -/** - * Allow multiple view scripts per block. - * - * Filters the metadata provided for registering a block type. - * - * @since 6.1.0 - * - * @param array $metadata Metadata for registering a block type. - * - * @return array - */ -function gutenberg_block_type_metadata_multiple_view_scripts( $metadata ) { - - // Early return if viewScript is empty, or not an array. - if ( ! isset( $metadata['viewScript'] ) || ! is_array( $metadata['viewScript'] ) ) { - return $metadata; - } - - // Register all viewScript items. - foreach ( $metadata['viewScript'] as $view_script ) { - $item_metadata = $metadata; - $item_metadata['viewScript'] = $view_script; - gutenberg_block_type_metadata_view_script( array(), $item_metadata ); - } - - // Proceed with the default behavior. - $metadata['viewScript'] = $metadata['viewScript'][0]; - return $metadata; -} -add_filter( 'block_type_metadata', 'gutenberg_block_type_metadata_multiple_view_scripts' ); - -/** - * Register render template for core blocks if handling is missing in WordPress core. - * - * @since 6.1.0 - * - * @param array $settings Array of determined settings for registering a block type. - * @param array $metadata Metadata provided for registering a block type. - * - * @return array Array of settings for registering a block type. - */ -function gutenberg_block_type_metadata_render_template( $settings, $metadata ) { - if ( empty( $metadata['render'] ) || isset( $settings['render_callback'] ) ) { - return $settings; - } - - $template_path = wp_normalize_path( - realpath( - dirname( $metadata['file'] ) . '/' . - remove_block_asset_path_prefix( $metadata['render'] ) - ) - ); - - // Bail if the file does not exist. - if ( ! file_exists( $template_path ) ) { - return $settings; - } - /** - * Renders the block on the server. - * - * @param array $attributes Block attributes. - * @param string $content Block default content. - * @param WP_Block $block Block instance. - * - * @return string Returns the block content. - */ - $settings['render_callback'] = function( $attributes, $content, $block ) use ( $template_path ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable - ob_start(); - require $template_path; - return ob_get_clean(); - }; - - return $settings; -} -add_filter( 'block_type_metadata_settings', 'gutenberg_block_type_metadata_render_template', 10, 2 ); diff --git a/lib/compat/wordpress-6.1/class-gutenberg-rest-block-patterns-controller-6-1.php b/lib/compat/wordpress-6.1/class-gutenberg-rest-block-patterns-controller-6-1.php deleted file mode 100644 index b40f3aa2497f1..0000000000000 --- a/lib/compat/wordpress-6.1/class-gutenberg-rest-block-patterns-controller-6-1.php +++ /dev/null @@ -1,133 +0,0 @@ -get_fields_for_response( $request ); - $keys = array( - 'name' => 'name', - 'title' => 'title', - 'description' => 'description', - 'viewportWidth' => 'viewport_width', - 'blockTypes' => 'block_types', - 'postTypes' => 'post_types', - 'categories' => 'categories', - 'keywords' => 'keywords', - 'content' => 'content', - 'inserter' => 'inserter', - ); - $data = array(); - foreach ( $keys as $item_key => $rest_key ) { - if ( isset( $item[ $item_key ] ) && rest_is_field_included( $rest_key, $fields ) ) { - $data[ $rest_key ] = $item[ $item_key ]; - } - } - - $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; - $data = $this->add_additional_fields_to_object( $data, $request ); - $data = $this->filter_response_by_context( $data, $context ); - return rest_ensure_response( $data ); - } - - /** - * Retrieves the block pattern schema, conforming to JSON Schema. - * - * @since 6.0.0 - * @since 6.1.0 Added `post_types` property. - * - * @return array Item schema data. - */ - public function get_item_schema() { - $schema = array( - '$schema' => 'http://json-schema.org/draft-04/schema#', - 'title' => 'block-pattern', - 'type' => 'object', - 'properties' => array( - 'name' => array( - 'description' => __( 'The pattern name.', 'gutenberg' ), - 'type' => 'string', - 'readonly' => true, - 'context' => array( 'view', 'edit', 'embed' ), - ), - 'title' => array( - 'description' => __( 'The pattern title, in human readable format.', 'gutenberg' ), - 'type' => 'string', - 'readonly' => true, - 'context' => array( 'view', 'edit', 'embed' ), - ), - 'description' => array( - 'description' => __( 'The pattern detailed description.', 'gutenberg' ), - 'type' => 'string', - 'readonly' => true, - 'context' => array( 'view', 'edit', 'embed' ), - ), - 'viewport_width' => array( - 'description' => __( 'The pattern viewport width for inserter preview.', 'gutenberg' ), - 'type' => 'number', - 'readonly' => true, - 'context' => array( 'view', 'edit', 'embed' ), - ), - 'block_types' => array( - 'description' => __( 'Block types that the pattern is intended to be used with.', 'gutenberg' ), - 'type' => 'array', - 'readonly' => true, - 'context' => array( 'view', 'edit', 'embed' ), - ), - 'post_types' => array( - 'description' => __( 'An array of post types that the pattern is restricted to be used with.', 'gutenberg' ), - 'type' => 'array', - 'readonly' => true, - 'context' => array( 'view', 'edit', 'embed' ), - ), - 'categories' => array( - 'description' => __( 'The pattern category slugs.', 'gutenberg' ), - 'type' => 'array', - 'readonly' => true, - 'context' => array( 'view', 'edit', 'embed' ), - ), - 'keywords' => array( - 'description' => __( 'The pattern keywords.', 'gutenberg' ), - 'type' => 'array', - 'readonly' => true, - 'context' => array( 'view', 'edit', 'embed' ), - ), - 'content' => array( - 'description' => __( 'The pattern content.', 'gutenberg' ), - 'type' => 'string', - 'readonly' => true, - 'context' => array( 'view', 'edit', 'embed' ), - ), - 'inserter' => array( - 'description' => __( 'Determines whether the pattern is visible in inserter.', 'gutenberg' ), - 'type' => 'boolean', - 'readonly' => true, - 'context' => array( 'view', 'edit', 'embed' ), - ), - ), - ); - - return $this->add_additional_fields_schema( $schema ); - } -} diff --git a/lib/compat/wordpress-6.1/class-gutenberg-rest-templates-controller.php b/lib/compat/wordpress-6.1/class-gutenberg-rest-templates-controller.php deleted file mode 100644 index f0416e0a50e96..0000000000000 --- a/lib/compat/wordpress-6.1/class-gutenberg-rest-templates-controller.php +++ /dev/null @@ -1,303 +0,0 @@ -namespace, - '/' . $this->rest_base . '/lookup', - array( - array( - 'methods' => WP_REST_Server::READABLE, - 'callback' => array( $this, 'get_template_fallback' ), - 'permission_callback' => array( $this, 'get_item_permissions_check' ), - 'args' => array( - 'slug' => array( - 'description' => __( 'The slug of the template to get the fallback for', 'gutenberg' ), - 'type' => 'string', - 'required' => true, - ), - 'is_custom' => array( - 'description' => __( 'Indicates if a template is custom or part of the template hierarchy', 'gutenberg' ), - 'type' => 'boolean', - ), - 'template_prefix' => array( - 'description' => __( 'The template prefix for the created template. This is used to extract the main template type ex. in `taxonomy-books` we extract the `taxonomy`', 'gutenberg' ), - 'type' => 'string', - ), - ), - ), - ) - ); - parent::register_routes(); - } - - /** - * Returns the fallback template for a given slug. - * - * @param WP_REST_Request $request The request instance. - * - * @return WP_REST_Response|WP_Error - */ - public function get_template_fallback( $request ) { - $hierarchy = gutenberg_get_template_hierarchy( $request['slug'], $request['is_custom'], $request['template_prefix'] ); - $fallback_template = resolve_block_template( $request['slug'], $hierarchy, '' ); - $response = $this->prepare_item_for_response( $fallback_template, $request ); - return rest_ensure_response( $response ); - } - - /** - * Returns a list of templates. - * - * @param WP_REST_Request $request The request instance. - * - * @return WP_REST_Response - */ - public function get_items( $request ) { - $query = array(); - if ( isset( $request['wp_id'] ) ) { - $query['wp_id'] = $request['wp_id']; - } - if ( isset( $request['area'] ) ) { - $query['area'] = $request['area']; - } - if ( isset( $request['post_type'] ) ) { - $query['post_type'] = $request['post_type']; - } - - $templates = array(); - foreach ( gutenberg_get_block_templates( $query, $this->post_type ) as $template ) { - $data = $this->prepare_item_for_response( $template, $request ); - $templates[] = $this->prepare_response_for_collection( $data ); - } - - return rest_ensure_response( $templates ); - } - - /** - * Returns the given template - * - * @param WP_REST_Request $request The request instance. - * - * @return WP_REST_Response|WP_Error - */ - public function get_item( $request ) { - if ( isset( $request['source'] ) && 'theme' === $request['source'] ) { - $template = get_block_file_template( $request['id'], $this->post_type ); - } else { - $template = gutenberg_get_block_template( $request['id'], $this->post_type ); - } - - if ( ! $template ) { - return new WP_Error( 'rest_template_not_found', __( 'No templates exist with that id.', 'gutenberg' ), array( 'status' => 404 ) ); - } - - return $this->prepare_item_for_response( $template, $request ); - } - - /** - * Creates a single template. - * - * @param WP_REST_Request $request Full details about the request. - * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. - */ - public function create_item( $request ) { - $changes = $this->prepare_item_for_database( $request ); - if ( is_wp_error( $changes ) ) { - return $changes; - } - - $changes->post_name = $request['slug']; - $result = wp_insert_post( wp_slash( (array) $changes ), true ); - if ( is_wp_error( $result ) ) { - return $result; - } - - $posts = gutenberg_get_block_templates( array( 'wp_id' => $result ), $this->post_type ); - if ( ! count( $posts ) ) { - return new WP_Error( 'rest_template_insert_error', __( 'No templates exist with that id.', 'gutenberg' ) ); - } - $id = $posts[0]->id; - $template = gutenberg_get_block_template( $id, $this->post_type ); - - $fields_update = $this->update_additional_fields_for_object( $template, $request ); - if ( is_wp_error( $fields_update ) ) { - return $fields_update; - } - - return $this->prepare_item_for_response( - gutenberg_get_block_template( $id, $this->post_type ), - $request - ); - } - - /** - * Updates a single template. - * - * @since 5.8.0 - * - * @param WP_REST_Request $request Full details about the request. - * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. - */ - public function update_item( $request ) { - $template = gutenberg_get_block_template( $request['id'], $this->post_type ); - if ( ! $template ) { - return new WP_Error( 'rest_template_not_found', __( 'No templates exist with that id.', 'gutenberg' ), array( 'status' => 404 ) ); - } - - $post_before = get_post( $template->wp_id ); - - if ( isset( $request['source'] ) && 'theme' === $request['source'] ) { - wp_delete_post( $template->wp_id, true ); - $request->set_param( 'context', 'edit' ); - - $template = gutenberg_get_block_template( $request['id'], $this->post_type ); - $response = $this->prepare_item_for_response( $template, $request ); - - return rest_ensure_response( $response ); - } - - $changes = $this->prepare_item_for_database( $request ); - - if ( is_wp_error( $changes ) ) { - return $changes; - } - - if ( 'custom' === $template->source ) { - $update = true; - $result = wp_update_post( wp_slash( (array) $changes ), false ); - } else { - $update = false; - $post_before = null; - $result = wp_insert_post( wp_slash( (array) $changes ), false ); - } - - if ( is_wp_error( $result ) ) { - if ( 'db_update_error' === $result->get_error_code() ) { - $result->add_data( array( 'status' => 500 ) ); - } else { - $result->add_data( array( 'status' => 400 ) ); - } - return $result; - } - - $template = gutenberg_get_block_template( $request['id'], $this->post_type ); - $fields_update = $this->update_additional_fields_for_object( $template, $request ); - if ( is_wp_error( $fields_update ) ) { - return $fields_update; - } - - $request->set_param( 'context', 'edit' ); - - $post = get_post( $template->wp_id ); - /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php */ - do_action( "rest_after_insert_{$this->post_type}", $post, $request, false ); - - wp_after_insert_post( $post, $update, $post_before ); - - $response = $this->prepare_item_for_response( $template, $request ); - - return rest_ensure_response( $response ); - } - - /** - * Prepares a single template for create or update. - * - * @param WP_REST_Request $request Request object. - * @return stdClass Changes to pass to wp_update_post. - */ - protected function prepare_item_for_database( $request ) { - $template = $request['id'] ? gutenberg_get_block_template( $request['id'], $this->post_type ) : null; - $changes = new stdClass(); - if ( null === $template ) { - $changes->post_type = $this->post_type; - $changes->post_status = 'publish'; - $changes->tax_input = array( - 'wp_theme' => isset( $request['theme'] ) ? $request['theme'] : get_stylesheet(), - ); - } elseif ( 'custom' !== $template->source ) { - $changes->post_name = $template->slug; - $changes->post_type = $this->post_type; - $changes->post_status = 'publish'; - $changes->tax_input = array( - 'wp_theme' => $template->theme, - ); - $changes->meta_input = array( - 'origin' => $template->source, - ); - } else { - $changes->post_name = $template->slug; - $changes->ID = $template->wp_id; - $changes->post_status = 'publish'; - } - if ( isset( $request['content'] ) ) { - $changes->post_content = $request['content']; - } elseif ( null !== $template && 'custom' !== $template->source ) { - $changes->post_content = $template->content; - } - if ( isset( $request['title'] ) ) { - $changes->post_title = $request['title']; - } elseif ( null !== $template && 'custom' !== $template->source ) { - $changes->post_title = $template->title; - } - if ( isset( $request['description'] ) ) { - $changes->post_excerpt = $request['description']; - } elseif ( null !== $template && 'custom' !== $template->source ) { - $changes->post_excerpt = $template->description; - } - - if ( 'wp_template' === $this->post_type ) { - if ( isset( $request['is_wp_suggestion'] ) ) { - $changes->meta_input = wp_parse_args( - array( - 'is_wp_suggestion' => $request['is_wp_suggestion'], - ), - $changes->meta_input = array() - ); - } - } - if ( 'wp_template_part' === $this->post_type ) { - if ( isset( $request['area'] ) ) { - $changes->tax_input['wp_template_part_area'] = _filter_block_template_part_area( $request['area'] ); - } elseif ( null !== $template && 'custom' !== $template->source && $template->area ) { - $changes->tax_input['wp_template_part_area'] = _filter_block_template_part_area( $template->area ); - } elseif ( ! $template->area ) { - $changes->tax_input['wp_template_part_area'] = WP_TEMPLATE_PART_AREA_UNCATEGORIZED; - } - } - - if ( ! empty( $request['author'] ) ) { - $post_author = (int) $request['author']; - - if ( get_current_user_id() !== $post_author ) { - $user_obj = get_userdata( $post_author ); - - if ( ! $user_obj ) { - return new WP_Error( - 'rest_invalid_author', - __( 'Invalid author ID.', 'gutenberg' ), - array( 'status' => 400 ) - ); - } - } - - $changes->post_author = $post_author; - } - return $changes; - } -} diff --git a/lib/compat/wordpress-6.1/date-settings.php b/lib/compat/wordpress-6.1/date-settings.php deleted file mode 100644 index bdc65ede58cc3..0000000000000 --- a/lib/compat/wordpress-6.1/date-settings.php +++ /dev/null @@ -1,82 +0,0 @@ -get_data( 'wp-date', 'after' ); - if ( $inline_scripts ) { - foreach ( $inline_scripts as $index => $inline_script ) { - if ( str_starts_with( $inline_script, 'wp.date.setSettings' ) ) { - unset( $scripts->registered['wp-date']->extra['after'][ $index ] ); - } - } - } - - // Calculate the timezone abbr (EDT, PST) if possible. - $timezone_string = get_option( 'timezone_string', 'UTC' ); - $timezone_abbr = ''; - - if ( ! empty( $timezone_string ) ) { - $timezone_date = new DateTime( 'now', new DateTimeZone( $timezone_string ) ); - $timezone_abbr = $timezone_date->format( 'T' ); - } - - $scripts->add_inline_script( - 'wp-date', - sprintf( - 'wp.date.setSettings( %s );', - wp_json_encode( - array( - 'l10n' => array( - 'locale' => get_user_locale(), - 'months' => array_values( $wp_locale->month ), - 'monthsShort' => array_values( $wp_locale->month_abbrev ), - 'weekdays' => array_values( $wp_locale->weekday ), - 'weekdaysShort' => array_values( $wp_locale->weekday_abbrev ), - 'meridiem' => (object) $wp_locale->meridiem, - 'relative' => array( - /* translators: %s: Duration. */ - 'future' => __( '%s from now', 'gutenberg' ), - /* translators: %s: Duration. */ - 'past' => __( '%s ago', 'gutenberg' ), - ), - 'startOfWeek' => (int) get_option( 'start_of_week', 0 ), - ), - 'formats' => array( - /* translators: Time format, see https://www.php.net/manual/datetime.format.php */ - 'time' => get_option( 'time_format', __( 'g:i a', 'gutenberg' ) ), - /* translators: Date format, see https://www.php.net/manual/datetime.format.php */ - 'date' => get_option( 'date_format', __( 'F j, Y', 'gutenberg' ) ), - /* translators: Date/Time format, see https://www.php.net/manual/datetime.format.php */ - 'datetime' => __( 'F j, Y g:i a', 'gutenberg' ), - /* translators: Abbreviated date/time format, see https://www.php.net/manual/datetime.format.php */ - 'datetimeAbbreviated' => __( 'M j, Y g:i a', 'gutenberg' ), - ), - 'timezone' => array( - 'offset' => get_option( 'gmt_offset', 0 ), - 'string' => $timezone_string, - 'abbr' => $timezone_abbr, - ), - ) - ) - ), - 'after' - ); -} -add_action( 'wp_default_scripts', 'gutenberg_update_date_settings' ); diff --git a/lib/compat/wordpress-6.1/edit-form-blocks.php b/lib/compat/wordpress-6.1/edit-form-blocks.php deleted file mode 100644 index fd2414f228b20..0000000000000 --- a/lib/compat/wordpress-6.1/edit-form-blocks.php +++ /dev/null @@ -1,23 +0,0 @@ -post ) ) { - $preload_paths[] = array( rest_get_route_for_post_type_items( 'wp_template' ), 'OPTIONS' ); - $preload_paths[] = array( '/wp/v2/settings', 'OPTIONS' ); - } - - return $preload_paths; -} -add_filter( 'block_editor_rest_api_preload_paths', 'gutenberg_preload_template_permissions', 10, 2 ); 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 deleted file mode 100644 index fd6113c7405c4..0000000000000 --- a/lib/compat/wordpress-6.1/get-global-styles-and-settings.php +++ /dev/null @@ -1,56 +0,0 @@ -get_styles_block_nodes(); - foreach ( $block_nodes as $metadata ) { - $block_css = $tree->get_styles_for_block( $metadata ); - - if ( ! wp_should_load_separate_core_block_assets() ) { - wp_add_inline_style( 'global-styles', $block_css ); - continue; - } - - $stylesheet_handle = 'global-styles'; - if ( isset( $metadata['name'] ) ) { - // These block styles are added on block_render. - // This hooks inline CSS to them so that they are loaded conditionally - // based on whether or not the block is used on the page. - if ( str_starts_with( $metadata['name'], 'core/' ) ) { - $block_name = str_replace( 'core/', '', $metadata['name'] ); - $stylesheet_handle = 'wp-block-' . $block_name; - } - wp_add_inline_style( $stylesheet_handle, $block_css ); - } - - // The likes of block element styles from theme.json do not have $metadata['name'] set. - if ( ! isset( $metadata['name'] ) && ! empty( $metadata['path'] ) ) { - $result = array_values( - array_filter( - $metadata['path'], - function ( $item ) { - if ( str_contains( $item, 'core/' ) ) { - return true; - } - return false; - } - ) - ); - if ( isset( $result[0] ) ) { - if ( str_starts_with( $result[0], 'core/' ) ) { - $block_name = str_replace( 'core/', '', $result[0] ); - $stylesheet_handle = 'wp-block-' . $block_name; - } - wp_add_inline_style( $stylesheet_handle, $block_css ); - } - } - } -} diff --git a/lib/compat/wordpress-6.1/persisted-preferences.php b/lib/compat/wordpress-6.1/persisted-preferences.php deleted file mode 100644 index 2263820e9911b..0000000000000 --- a/lib/compat/wordpress-6.1/persisted-preferences.php +++ /dev/null @@ -1,104 +0,0 @@ -get_blog_prefix() . 'persisted_preferences'; - - register_meta( - 'user', - $meta_key, - array( - 'type' => 'object', - 'single' => true, - 'show_in_rest' => 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' ), - 'type' => 'string', - 'format' => 'date-time', - 'readonly' => false, - ), - ), - 'additionalProperties' => true, - ), - ), - ) - ); -} - -add_action( 'init', 'gutenberg_register_persisted_preferences_meta' ); - -/** - * Configures the preferences package to use user meta persistence. - */ -function gutenberg_configure_persisted_preferences() { - $user_id = get_current_user_id(); - if ( empty( $user_id ) ) { - return; - } - - global $wpdb; - $meta_key = $wpdb->get_blog_prefix() . 'persisted_preferences'; - - $preload_data = get_user_meta( $user_id, $meta_key, true ); - - wp_add_inline_script( - 'wp-preferences', - sprintf( - '( function() { - var serverData = %s; - var userId = "%s"; - var persistenceLayer = wp.preferencesPersistence.__unstableCreatePersistenceLayer( serverData, userId ); - var preferencesStore = wp.preferences.store; - wp.data.dispatch( preferencesStore ).setPersistenceLayer( persistenceLayer ); - } ) ();', - wp_json_encode( $preload_data ), - $user_id - ), - 'after' - ); -} - -add_action( 'admin_init', 'gutenberg_configure_persisted_preferences' ); - -/** - * Register dependencies for the inline script that configures the persistence layer. - * - * Note: When porting this to core update the code here: - * https://github.com/WordPress/wordpress-develop/blob/d2ab3d183740c3d1252cb921b18005495007e022/src/wp-includes/script-loader.php#L251-L258 - * - * And make the same update to the gutenberg client assets file here: - * https://github.com/WordPress/gutenberg/blob/3f3c8df23c70a37b7ac4dddebc82030362133593/lib/client-assets.php#L242-L254 - * - * The update should be adding a new case like this like this: - * ``` - * case 'wp-preferences': - * array_push( $dependencies, 'wp-preferences-persistence' ); - * break; - * ``` - * - * @param WP_Scripts $scripts An instance of WP_Scripts. - */ -function gutenberg_update_preferences_persistence_deps( $scripts ) { - $persistence_script = $scripts->query( 'wp-preferences', 'registered' ); - if ( isset( $persistence_script->deps ) ) { - array_push( $persistence_script->deps, 'wp-preferences-persistence' ); - } -} - -add_action( 'wp_default_scripts', 'gutenberg_update_preferences_persistence_deps', 11 ); diff --git a/lib/compat/wordpress-6.1/rest-api.php b/lib/compat/wordpress-6.1/rest-api.php deleted file mode 100644 index f2293e5169a9c..0000000000000 --- a/lib/compat/wordpress-6.1/rest-api.php +++ /dev/null @@ -1,70 +0,0 @@ -data['icon'] = $post_type->menu_icon; - return $response; -} -add_filter( 'rest_prepare_post_type', 'gutenberg_update_post_types_rest_response', 10, 2 ); - -/** - * Exposes the site logo URL through the WordPress REST API. - * - * This is used for fetching this information when user has no rights - * to update settings. - * - * Note: Backports into wp-includes/rest-api/class-wp-rest-server.php file. - * - * @param WP_REST_Response $response REST API response. - * @return WP_REST_Response $response REST API response. - */ -function gutenberg_add_site_icon_url_to_index( WP_REST_Response $response ) { - $response->data['site_icon_url'] = get_site_icon_url(); - - return $response; -} -add_action( 'rest_index', 'gutenberg_add_site_icon_url_to_index' ); - -/** - * Returns the has_archive post type field. - * - * @param array $type The response data. - * @param string $field_name The field name. The function handles field has_archive. - */ -function gutenberg_get_post_type_has_archive_field( $type, $field_name ) { - if ( ! empty( $type ) && ! empty( $type['slug'] ) && 'has_archive' === $field_name ) { - $post_type_object = get_post_type_object( $type['slug'] ); - return $post_type_object->has_archive; - } -} - -/** - * Registers the has_archive post type REST API field. - */ -function gutenberg_register_has_archive_on_post_types_endpoint() { - register_rest_field( - 'type', - 'has_archive', - array( - 'get_callback' => 'gutenberg_get_post_type_has_archive_field', - 'schema' => array( - 'description' => __( 'If the value is a string, the value will be used as the archive slug. If the value is false the post type has no archive.', 'gutenberg' ), - 'type' => array( 'string', 'boolean' ), - 'context' => array( 'view', 'edit' ), - ), - ) - ); -} -add_action( 'rest_api_init', 'gutenberg_register_has_archive_on_post_types_endpoint' ); diff --git a/lib/compat/wordpress-6.1/script-loader.php b/lib/compat/wordpress-6.1/script-loader.php deleted file mode 100644 index 4948882c113a8..0000000000000 --- a/lib/compat/wordpress-6.1/script-loader.php +++ /dev/null @@ -1,125 +0,0 @@ - file_get_contents( $classic_theme_styles ), - 'baseURL' => get_theme_file_uri( $classic_theme_styles ), - '__unstableType' => 'theme', - 'isGlobalStyles' => false, - ); - - return $editor_settings; -} -add_filter( 'block_editor_settings_all', 'gutenberg_add_editor_classic_theme_styles' ); diff --git a/lib/compat/wordpress-6.1/template-parts-screen.php b/lib/compat/wordpress-6.1/template-parts-screen.php deleted file mode 100644 index 7c370635fe2ba..0000000000000 --- a/lib/compat/wordpress-6.1/template-parts-screen.php +++ /dev/null @@ -1,217 +0,0 @@ - 'wp_template_part' ), - admin_url( 'themes.php?page=gutenberg-template-parts' ) - ); - wp_safe_redirect( $redirect_url ); - exit; - } -} -add_action( 'load-appearance_page_gutenberg-template-parts', 'gutenberg_template_parts_screen_permissions' ); - -/** - * Initialize the editor for the screen. Most of this is copied from `site-editor.php`. - * - * Note: Parts that need to be ported back should have inline comments. - * - * @param string $hook Current page hook. - * @return void - */ -function gutenberg_template_parts_screen_init( $hook ) { - global $current_screen, $editor_styles; - - if ( 'appearance_page_gutenberg-template-parts' !== $hook ) { - return; - } - - // Flag that we're loading the block editor. - $current_screen->is_block_editor( true ); - - // Default to is-fullscreen-mode to avoid jumps in the UI. - add_filter( - 'admin_body_class', - static function( $classes ) { - return "$classes is-fullscreen-mode"; - } - ); - - $indexed_template_types = array(); - foreach ( get_default_block_template_types() as $slug => $template_type ) { - $template_type['slug'] = (string) $slug; - $indexed_template_types[] = $template_type; - } - - $block_editor_context = new WP_Block_Editor_Context( array( 'name' => 'core/edit-site' ) ); - $custom_settings = array( - 'siteUrl' => site_url(), - 'postsPerPage' => get_option( 'posts_per_page' ), - 'styles' => get_block_editor_theme_styles(), - 'defaultTemplateTypes' => $indexed_template_types, - 'defaultTemplatePartAreas' => get_allowed_block_template_part_areas(), - 'supportsLayout' => wp_theme_has_theme_json(), - 'supportsTemplatePartsMode' => ! wp_is_block_theme() && current_theme_supports( 'block-template-parts' ), - ); - - // Add additional back-compat patterns registered by `current_screen` et al. - $custom_settings['__experimentalAdditionalBlockPatterns'] = WP_Block_Patterns_Registry::get_instance()->get_all_registered( true ); - $custom_settings['__experimentalAdditionalBlockPatternCategories'] = WP_Block_Pattern_Categories_Registry::get_instance()->get_all_registered( true ); - - $editor_settings = get_block_editor_settings( $custom_settings, $block_editor_context ); - - if ( isset( $_GET['postType'] ) && ! isset( $_GET['postId'] ) ) { - $post_type = get_post_type_object( $_GET['postType'] ); - if ( ! $post_type ) { - wp_die( __( 'Invalid post type.', 'gutenberg' ) ); - } - } - - $active_global_styles_id = WP_Theme_JSON_Resolver_Gutenberg::get_user_global_styles_post_id(); - $active_theme = get_stylesheet(); - $preload_paths = array( - array( '/wp/v2/media', 'OPTIONS' ), - '/wp/v2/types?context=view', - '/wp/v2/types/wp_template?context=edit', - '/wp/v2/types/wp_template-part?context=edit', - '/wp/v2/templates?context=edit&per_page=-1', - '/wp/v2/template-parts?context=edit&per_page=-1', - '/wp/v2/themes?context=edit&status=active', - '/wp/v2/global-styles/' . $active_global_styles_id . '?context=edit', - '/wp/v2/global-styles/' . $active_global_styles_id, - '/wp/v2/global-styles/themes/' . $active_theme, - ); - - block_editor_rest_api_preload( $preload_paths, $block_editor_context ); - - wp_add_inline_script( - 'wp-edit-site', - sprintf( - 'wp.domReady( function() { - wp.editSite.initializeEditor( "site-editor", %s ); - } );', - wp_json_encode( $editor_settings ) - ) - ); - - // Preload server-registered block schemas. - wp_add_inline_script( - 'wp-blocks', - 'wp.blocks.unstable__bootstrapServerSideBlockDefinitions(' . wp_json_encode( get_block_editor_server_block_settings() ) . ');' - ); - - wp_add_inline_script( - 'wp-blocks', - sprintf( 'wp.blocks.setCategories( %s );', wp_json_encode( get_block_categories( $block_editor_context ) ) ) - ); - - wp_enqueue_script( 'wp-edit-site' ); - wp_enqueue_script( 'wp-format-library' ); - wp_enqueue_style( 'wp-edit-site' ); - wp_enqueue_style( 'wp-format-library' ); - wp_enqueue_media(); - - if ( - current_theme_supports( 'wp-block-styles' ) || - ( ! is_array( $editor_styles ) || count( $editor_styles ) === 0 ) - ) { - wp_enqueue_style( 'wp-block-library-theme' ); - } - - /** This action is documented in wp-admin/edit-form-blocks.php */ - do_action( 'enqueue_block_editor_assets' ); -} -add_action( 'admin_enqueue_scripts', 'gutenberg_template_parts_screen_init' ); - -/** - * The main entry point for the screen. - * - * @return void - */ -function gutenberg_template_parts_screen_render() { - echo '
'; -} - -/** - * Register the new theme feature. - * - * Migrates into `create_initial_theme_features` method. - * - * @return void - */ -function gutenberg_register_template_parts_theme_feature() { - register_theme_feature( - 'block-template-parts', - array( - 'description' => __( 'Whether a theme uses block-based template parts.', 'gutenberg' ), - 'show_in_rest' => true, - ) - ); -} -add_action( 'setup_theme', 'gutenberg_register_template_parts_theme_feature', 5 ); diff --git a/lib/compat/wordpress-6.1/theme.php b/lib/compat/wordpress-6.1/theme.php deleted file mode 100644 index a36e48a14db96..0000000000000 --- a/lib/compat/wordpress-6.1/theme.php +++ /dev/null @@ -1,47 +0,0 @@ - __( 'Whether the theme disables generated layout styles.', 'gutenberg' ), - 'show_in_rest' => true, - ) - ); -} -add_action( 'setup_theme', 'gutenberg_create_initial_theme_features', 0 ); - -if ( ! function_exists( 'wp_theme_get_element_class_name' ) ) { - /** - * Given an element name, returns a class name. - * Alias from WP_Theme_JSON_Gutenberg::get_element_class_name. - * - * @param string $element The name of the element. - * - * @return string The name of the class. - * - * @since 6.1.0 - */ - function wp_theme_get_element_class_name( $element ) { - return WP_Theme_JSON_Gutenberg::get_element_class_name( $element ); - } -} diff --git a/lib/compat/wordpress-6.1/wp-theme-get-post-templates.php b/lib/compat/wordpress-6.1/wp-theme-get-post-templates.php deleted file mode 100644 index e006a2ccca3de..0000000000000 --- a/lib/compat/wordpress-6.1/wp-theme-get-post-templates.php +++ /dev/null @@ -1,44 +0,0 @@ -slug; - }, - gutenberg_get_block_templates( array( 'post_type' => $post_type ), 'wp_template' ) - ); - $core_block_templates = array_map( - function( $template ) { - return $template->slug; - }, - get_block_templates( array( 'post_type' => $post_type ), 'wp_template' ) - ); - $templates_to_exclude = array_diff( $core_block_templates, $gutenberg_block_templates ); - foreach ( $templates_to_exclude as $template_slug ) { - unset( $templates[ $template_slug ] ); - } - return $templates; -} -add_filter( 'theme_templates', 'gutenberg_load_block_page_templates', 10, 4 ); diff --git a/lib/compat/wordpress-6.2/block-editor-settings.php b/lib/compat/wordpress-6.2/block-editor-settings.php index 2fd8dc1e2f6bb..593a8b7e6b55f 100644 --- a/lib/compat/wordpress-6.2/block-editor-settings.php +++ b/lib/compat/wordpress-6.2/block-editor-settings.php @@ -22,6 +22,9 @@ function gutenberg_get_block_editor_settings_6_2( $settings ) { ); } + // Copied from get_block_editor_settings() at wordpress-develop/block-editor.php. + $settings['__experimentalFeatures'] = gutenberg_get_global_settings(); + return $settings; } diff --git a/lib/compat/wordpress-6.2/class-gutenberg-rest-block-patterns-controller-6-2.php b/lib/compat/wordpress-6.2/class-gutenberg-rest-block-patterns-controller-6-2.php index ddaac89d13a18..70a9ae397fe8d 100644 --- a/lib/compat/wordpress-6.2/class-gutenberg-rest-block-patterns-controller-6-2.php +++ b/lib/compat/wordpress-6.2/class-gutenberg-rest-block-patterns-controller-6-2.php @@ -13,7 +13,7 @@ * * @see WP_REST_Controller */ -class Gutenberg_REST_Block_Patterns_Controller_6_2 extends Gutenberg_REST_Block_Patterns_Controller_6_1 { +class Gutenberg_REST_Block_Patterns_Controller_6_2 extends WP_REST_Block_Patterns_Controller { /** * Defines whether remote patterns should be loaded. * diff --git a/lib/compat/wordpress-6.2/default-filters.php b/lib/compat/wordpress-6.2/default-filters.php index 4698ead7f9bc3..6a5bcb3b75b05 100644 --- a/lib/compat/wordpress-6.2/default-filters.php +++ b/lib/compat/wordpress-6.2/default-filters.php @@ -23,3 +23,18 @@ */ add_action( 'start_previewing_theme', '_gutenberg_clean_theme_json_caches' ); add_action( 'switch_theme', '_gutenberg_clean_theme_json_caches' ); + +/** + * This is a temporary fix to ensure that the block editor styles are enqueued + * in the order the iframe expects. + * + * The wp_enqueue_registered_block_scripts_and_styles callback has been removed in core + * as of https://github.com/WordPress/wordpress-develop/pull/4356. + * + * However, Gutenberg supports WordPress 6.1 and 6.2, which still have this callback. + * Hence, why we remove it first and then re-add it. + * + * This way we make sure it still works the same in WordPress trunk, 6.1 and 6.2. + */ +remove_action( 'enqueue_block_editor_assets', 'wp_enqueue_registered_block_scripts_and_styles' ); +add_action( 'enqueue_block_editor_assets', 'wp_enqueue_registered_block_scripts_and_styles', 1 ); diff --git a/lib/compat/wordpress-6.2/html-api/class-wp-html-tag-processor.php b/lib/compat/wordpress-6.2/html-api/class-wp-html-tag-processor.php index 8c41e732154b4..f06302d4b742f 100644 --- a/lib/compat/wordpress-6.2/html-api/class-wp-html-tag-processor.php +++ b/lib/compat/wordpress-6.2/html-api/class-wp-html-tag-processor.php @@ -39,10 +39,10 @@ * * Example: * ```php - * $tags = new WP_HTML_Tag_Processor( $html ); - * if ( $tags->next_tag( 'option' ) ) { - * $tags->set_attribute( 'selected', true ); - * } + * $tags = new WP_HTML_Tag_Processor( $html ); + * if ( $tags->next_tag( 'option' ) ) { + * $tags->set_attribute( 'selected', true ); + * } * ``` * * ### Finding tags @@ -55,7 +55,7 @@ * * If you want to _find whatever the next tag is_: * ```php - * $tags->next_tag(); + * $tags->next_tag(); * ``` * * | Goal | Query | @@ -88,17 +88,17 @@ * * 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--; - * } + * // 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 @@ -117,10 +117,10 @@ * * Example: * ```php - * if ( $tags->next_tag( array( 'class' => 'wp-group-block' ) ) ) { - * $tags->set_attribute( 'title', 'This groups the contained content.' ); - * $tags->remove_attribute( 'data-test-id' ); - * } + * if ( $tags->next_tag( array( '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 @@ -142,29 +142,29 @@ * * 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 `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' ); + * // from `` + * // to ` + * $tags->remove_class( 'rugby' ); * - * // from `` - * // to ` - * $tags->remove_class( 'rugby' ); + * // from `` + * // to ` + * $tags->remove_class( 'rugby' ); * ``` * * When class changes are enqueued but a direct change to `class` is made via @@ -185,24 +185,24 @@ * bookmark and update it frequently, such as within a loop. * * ```php - * $total_todos = 0; - * while ( $p->next_tag( array( 'tag_name' => 'UL', 'class_name' => 'todo' ) ) ) { - * $p->set_bookmark( 'list-start' ); - * while ( $p->next_tag( array( 'tag_closers' => 'visit' ) ) ) { - * if ( 'UL' === $p->get_tag() && $p->is_tag_closer() ) { - * $p->set_bookmark( 'list-end' ); - * $p->seek( 'list-start' ); - * $p->set_attribute( 'data-contained-todos', (string) $total_todos ); - * $total_todos = 0; - * $p->seek( 'list-end' ); - * break; - * } + * $total_todos = 0; + * while ( $p->next_tag( array( 'tag_name' => 'UL', 'class_name' => 'todo' ) ) ) { + * $p->set_bookmark( 'list-start' ); + * while ( $p->next_tag( array( 'tag_closers' => 'visit' ) ) ) { + * if ( 'UL' === $p->get_tag() && $p->is_tag_closer() ) { + * $p->set_bookmark( 'list-end' ); + * $p->seek( 'list-start' ); + * $p->set_attribute( 'data-contained-todos', (string) $total_todos ); + * $total_todos = 0; + * $p->seek( 'list-end' ); + * break; + * } * - * if ( 'LI' === $p->get_tag() && ! $p->is_tag_closer() ) { - * $total_todos++; - * } + * if ( 'LI' === $p->get_tag() && ! $p->is_tag_closer() ) { + * $total_todos++; * } * } + * } * ``` * * ## Design and limitations @@ -229,11 +229,11 @@ * The Tag Processor's design incorporates a "garbage-in-garbage-out" philosophy. * HTML5 specifies that certain invalid content be transformed into different forms * for display, such as removing null bytes from an input document and replacing - * invalid characters with the Unicode replacement character U+FFFD �. Where errors - * or transformations exist within the HTML5 specification, the Tag Processor leaves - * those invalid inputs untouched, passing them through to the final browser to handle. - * While this implies that certain operations will be non-spec-compliant, such as - * reading the value of an attribute with invalid content, it also preserves a + * invalid characters with the Unicode replacement character `U+FFFD` (visually "�"). + * Where errors or transformations exist within the HTML5 specification, the Tag Processor + * leaves those invalid inputs untouched, passing them through to the final browser + * to handle. While this implies that certain operations will be non-spec-compliant, + * such as reading the value of an attribute with invalid content, it also preserves a * simplicity and efficiency for handling those error cases. * * Most operations within the Tag Processor are designed to minimize the difference @@ -253,9 +253,10 @@ class WP_HTML_Tag_Processor { * The maximum number of bookmarks allowed to exist at * any given time. * - * @see set_bookmark() * @since 6.2.0 * @var int + * + * @see WP_HTML_Tag_Processor::set_bookmark() */ const MAX_BOOKMARKS = 10; @@ -263,9 +264,10 @@ class WP_HTML_Tag_Processor { * Maximum number of times seek() can be called. * Prevents accidental infinite loops. * - * @see seek() * @since 6.2.0 * @var int + * + * @see WP_HTML_Tag_Processor::seek() */ const MAX_SEEK_OPS = 1000; @@ -317,23 +319,6 @@ class WP_HTML_Tag_Processor { */ private $stop_on_tag_closers; - /** - * Holds updated HTML as updates are applied. - * - * Updates and unmodified portions of the input document are - * appended to this value as they are applied. It will hold - * a copy of the updated document up until the point of the - * latest applied update. The fully-updated HTML document - * will comprise this value plus the part of the input document - * which follows that latest update. - * - * @see $bytes_already_copied - * - * @since 6.2.0 - * @var string - */ - private $output_buffer = ''; - /** * How many bytes from the original HTML document have been read and parsed. * @@ -346,23 +331,6 @@ class WP_HTML_Tag_Processor { */ private $bytes_already_parsed = 0; - /** - * How many bytes from the input HTML document have already been - * copied into the output buffer. - * - * Lexical updates are enqueued and processed in batches. Prior - * to any given update in the input document, there might exist - * a span of HTML unaffected by any changes. This span ought to - * be copied verbatim into the output buffer before applying the - * following update. This value will point to the starting byte - * offset in the input document where that unaffected span of - * HTML starts. - * - * @since 6.2.0 - * @var int - */ - private $bytes_already_copied = 0; - /** * Byte offset in input document where current tag name starts. * @@ -421,23 +389,23 @@ class WP_HTML_Tag_Processor { * * Example: * ```php - * // supposing the parser is working through this content - * // and stops after recognizing the `id` attribute - * //
- * // ^ parsing will continue from this point - * $this->attributes = array( - * '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 = array( - * '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. + * // supposing the parser is working through this content + * // and stops after recognizing the `id` attribute + * //
+ * // ^ parsing will continue from this point + * $this->attributes = array( + * '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 = array( + * '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 @@ -458,12 +426,12 @@ class WP_HTML_Tag_Processor { * * Example: * ```php - * // Add the `wp-block-group` class, remove the `wp-group` class. - * $classname_updates = array( - * // Indexed by a comparable class name - * 'wp-block-group' => WP_HTML_Tag_Processor::ADD_CLASS, - * 'wp-group' => WP_HTML_Tag_Processor::REMOVE_CLASS - * ); + * // Add the `wp-block-group` class, remove the `wp-group` class. + * $classname_updates = array( + * // Indexed by a comparable class name + * 'wp-block-group' => WP_HTML_Tag_Processor::ADD_CLASS, + * 'wp-group' => WP_HTML_Tag_Processor::REMOVE_CLASS + * ); * ``` * * @since 6.2.0 @@ -512,16 +480,16 @@ class WP_HTML_Tag_Processor { * * Example: * ```php - * // 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, $new_value ); - * - * // Correspondingly, something like this will appear in this array. - * $lexical_updates = array( - * WP_HTML_Text_Replacement( 14, 28, 'https://my-site.my-domain/wp-content/uploads/2014/08/kittens.jpg' ) - * ); + * // 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, $new_value ); + * + * // Correspondingly, something like this will appear in this array. + * $lexical_updates = array( + * WP_HTML_Text_Replacement( 14, 28, 'https://my-site.my-domain/wp-content/uploads/2014/08/kittens.jpg' ) + * ); * ``` * * @since 6.2.0 @@ -532,9 +500,10 @@ class WP_HTML_Tag_Processor { /** * Tracks and limits `seek()` calls to prevent accidental infinite loops. * - * @see seek * @since 6.2.0 * @var int + * + * @see WP_HTML_Tag_Processor::seek() */ protected $seek_count = 0; @@ -754,9 +723,10 @@ public function release_bookmark( $name ) { /** * Skips contents of title and textarea tags. * - * @see https://html.spec.whatwg.org/multipage/parsing.html#rcdata-state * @since 6.2.0 * + * @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. * @return bool Whether an end to the RCDATA region was found before the end of the document. */ @@ -1303,8 +1273,7 @@ private function skip_whitespace() { * @return void */ private function after_tag() { - $this->class_name_updates_to_attributes_updates(); - $this->apply_attributes_updates(); + $this->get_updated_html(); $this->tag_name_starts_at = null; $this->tag_name_length = null; $this->tag_ends_at = null; @@ -1316,11 +1285,11 @@ private function after_tag() { * Converts class name updates into tag attributes updates * (they are accumulated in different data formats for performance). * - * @see $lexical_updates - * @see $classname_updates - * * @since 6.2.0 * + * @see WP_HTML_Tag_Processor::$lexical_updates + * @see WP_HTML_Tag_Processor::$classname_updates + * * @return void */ private function class_name_updates_to_attributes_updates() { @@ -1460,15 +1429,19 @@ private function class_name_updates_to_attributes_updates() { * Applies attribute updates to HTML document. * * @since 6.2.0 + * @since 6.2.1 Accumulates shift for internal cursor and passed pointer. * @since 6.3.0 Invalidate any bookmarks whose targets are overwritten. * - * @return void + * @param int $shift_this_point Accumulate and return shift for this position. + * @return int How many bytes the given pointer moved in response to the updates. */ - private function apply_attributes_updates() { + private function apply_attributes_updates( $shift_this_point = 0 ) { if ( ! count( $this->lexical_updates ) ) { - return; + return 0; } + $accumulated_shift_for_given_point = 0; + /* * Attribute updates can be enqueued in any order but updates * to the document must occur in lexical order; that is, each @@ -1481,12 +1454,28 @@ private function apply_attributes_updates() { */ usort( $this->lexical_updates, array( self::class, 'sort_start_ascending' ) ); + $bytes_already_copied = 0; + $output_buffer = ''; foreach ( $this->lexical_updates as $diff ) { - $this->output_buffer .= substr( $this->html, $this->bytes_already_copied, $diff->start - $this->bytes_already_copied ); - $this->output_buffer .= $diff->text; - $this->bytes_already_copied = $diff->end; + $shift = strlen( $diff->text ) - ( $diff->end - $diff->start ); + + // Adjust the cursor position by however much an update affects it. + if ( $diff->start <= $this->bytes_already_parsed ) { + $this->bytes_already_parsed += $shift; + } + + // Accumulate shift of the given pointer within this function call. + if ( $diff->start <= $shift_this_point ) { + $accumulated_shift_for_given_point += $shift; + } + + $output_buffer .= substr( $this->html, $bytes_already_copied, $diff->start - $bytes_already_copied ); + $output_buffer .= $diff->text; + $bytes_already_copied = $diff->end; } + $this->html = $output_buffer . substr( $this->html, $bytes_already_copied ); + /* * Adjust bookmark locations to account for how the text * replacements adjust offsets in the input document. @@ -1527,6 +1516,8 @@ private function apply_attributes_updates() { } $this->lexical_updates = array(); + + return $accumulated_shift_for_given_point; } /** @@ -1576,8 +1567,6 @@ public function seek( $bookmark_name ) { // Point this tag processor before the sought tag opener and consume it. $this->bytes_already_parsed = $this->bookmarks[ $bookmark_name ]->start; - $this->bytes_already_copied = $this->bytes_already_parsed; - $this->output_buffer = substr( $this->html, 0, $this->bytes_already_copied ); return $this->next_tag( array( 'tag_closers' => 'visit' ) ); } @@ -1676,14 +1665,14 @@ private function get_enqueued_attribute_value( $comparable_name ) { * * Example: * ```php - * $p = new WP_HTML_Tag_Processor( '
Test
' ); - * $p->next_tag( array( 'class_name' => 'test' ) ) === true; - * $p->get_attribute( 'data-test-id' ) === '14'; - * $p->get_attribute( 'enabled' ) === true; - * $p->get_attribute( 'aria-label' ) === null; - * - * $p->next_tag() === false; - * $p->get_attribute( 'class' ) === null; + * $p = new WP_HTML_Tag_Processor( '
Test
' ); + * $p->next_tag( array( 'class_name' => 'test' ) ) === true; + * $p->get_attribute( 'data-test-id' ) === '14'; + * $p->get_attribute( 'enabled' ) === true; + * $p->get_attribute( 'aria-label' ) === null; + * + * $p->next_tag() === false; + * $p->get_attribute( 'class' ) === null; * ``` * * @since 6.2.0 @@ -1755,20 +1744,20 @@ public function get_attribute( $name ) { * > case-insensitive match for each other. * - HTML 5 spec * - * @see https://html.spec.whatwg.org/multipage/syntax.html#attributes-2:ascii-case-insensitive - * * Example: * ```php - * $p = new WP_HTML_Tag_Processor( '
Test
' ); - * $p->next_tag( array( 'class_name' => 'test' ) ) === true; - * $p->get_attribute_names_with_prefix( 'data-' ) === array( 'data-enabled', 'data-test-id' ); + * $p = new WP_HTML_Tag_Processor( '
Test
' ); + * $p->next_tag( array( 'class_name' => 'test' ) ) === true; + * $p->get_attribute_names_with_prefix( 'data-' ) === array( 'data-enabled', 'data-test-id' ); * - * $p->next_tag() === false; - * $p->get_attribute_names_with_prefix( 'data-' ) === null; + * $p->next_tag() === false; + * $p->get_attribute_names_with_prefix( 'data-' ) === null; * ``` * * @since 6.2.0 * + * @see https://html.spec.whatwg.org/multipage/syntax.html#attributes-2:ascii-case-insensitive + * * @param string $prefix Prefix of requested attribute names. * @return array|null List of attribute names, or `null` when no tag opener is matched. */ @@ -1793,12 +1782,12 @@ function get_attribute_names_with_prefix( $prefix ) { * * Example: * ```php - * $p = new WP_HTML_Tag_Processor( '
Test
' ); - * $p->next_tag() === true; - * $p->get_tag() === 'DIV'; + * $p = new WP_HTML_Tag_Processor( '
Test
' ); + * $p->next_tag() === true; + * $p->get_tag() === 'DIV'; * - * $p->next_tag() === false; - * $p->get_tag() === null; + * $p->next_tag() === false; + * $p->get_tag() === null; * ``` * * @since 6.2.0 @@ -1845,12 +1834,12 @@ public function has_self_closing_flag() { * * Example: * ```php - * $p = new WP_HTML_Tag_Processor( '
' ); - * $p->next_tag( array( 'tag_name' => 'div', 'tag_closers' => 'visit' ) ); - * $p->is_tag_closer() === false; + * $p = new WP_HTML_Tag_Processor( '
' ); + * $p->next_tag( array( 'tag_name' => 'div', 'tag_closers' => 'visit' ) ); + * $p->is_tag_closer() === false; * - * $p->next_tag( array( 'tag_name' => 'div', 'tag_closers' => 'visit' ) ); - * $p->is_tag_closer() === true; + * $p->next_tag( array( 'tag_name' => 'div', 'tag_closers' => 'visit' ) ); + * $p->is_tag_closer() === true; * ``` * * @since 6.2.0 @@ -2110,7 +2099,8 @@ public function remove_class( $class_name ) { * Returns the string representation of the HTML Tag Processor. * * @since 6.2.0 - * @see get_updated_html + * + * @see WP_HTML_Tag_Processor::get_updated_html() * * @return string The processed HTML. */ @@ -2122,6 +2112,7 @@ public function __toString() { * Returns the string representation of the HTML Tag Processor. * * @since 6.2.0 + * @since 6.2.1 Shifts the internal cursor corresponding to the applied updates. * * @return string The processed HTML. */ @@ -2132,46 +2123,24 @@ public function get_updated_html() { * When there is nothing more to update and nothing has already been * updated, return the original document and avoid a string copy. */ - if ( $requires_no_updating && 0 === $this->bytes_already_copied ) { + if ( $requires_no_updating ) { return $this->html; } /* - * If there are no updates left to apply, but some have already - * been applied, then finish by copying the rest of the input - * to the end of the updated document and return. + * Keep track of the position right before the current tag. This will + * be necessary for reparsing the current tag after updating the HTML. */ - if ( $requires_no_updating && $this->bytes_already_copied > 0 ) { - $this->html = $this->output_buffer . substr( $this->html, $this->bytes_already_copied ); - $this->bytes_already_copied = strlen( $this->output_buffer ); - return $this->output_buffer . substr( $this->html, $this->bytes_already_copied ); - } - - // Apply the updates, rewind to before the current tag, and reparse the attributes. - $content_up_to_opened_tag_name = $this->output_buffer . substr( - $this->html, - $this->bytes_already_copied, - $this->tag_name_starts_at + $this->tag_name_length - $this->bytes_already_copied - ); + $before_current_tag = $this->tag_name_starts_at - 1; /* - * 1. Apply the edits by flushing them to the output buffer and updating the copied byte count. - * - * Note: `apply_attributes_updates()` modifies `$this->output_buffer`. + * 1. Apply the enqueued edits and update all the pointers to reflect those changes. */ $this->class_name_updates_to_attributes_updates(); - $this->apply_attributes_updates(); + $before_current_tag += $this->apply_attributes_updates( $before_current_tag ); /* - * 2. Replace the original HTML with the now-updated HTML so that it's possible to - * seek to a previous location and have a consistent view of the updated document. - */ - $this->html = $this->output_buffer . substr( $this->html, $this->bytes_already_copied ); - $this->output_buffer = $content_up_to_opened_tag_name; - $this->bytes_already_copied = strlen( $this->output_buffer ); - - /* - * 3. Point this tag processor at the original tag opener and consume it + * 2. Rewind to before the current tag and reparse to get updated attributes. * * At this point the internal cursor points to the end of the tag name. * Rewind before the tag name starts so that it's as if the cursor didn't @@ -2183,9 +2152,19 @@ public function get_updated_html() { * ^ | back up by the length of the tag name plus the opening < * \<-/ back up by strlen("em") + 1 ==> 3 */ - $this->bytes_already_parsed = strlen( $content_up_to_opened_tag_name ) - $this->tag_name_length - 1; + + // Store existing state so it can be restored after reparsing. + $previous_parsed_byte_count = $this->bytes_already_parsed; + $previous_query = $this->last_query; + + // Reparse attributes. + $this->bytes_already_parsed = $before_current_tag; $this->next_tag(); + // Restore previous state. + $this->bytes_already_parsed = $previous_parsed_byte_count; + $this->parse_query( $previous_query ); + return $this->html; } diff --git a/lib/compat/wordpress-6.2/script-loader.php b/lib/compat/wordpress-6.2/script-loader.php index 149a6a18e1450..142cbf4e1477b 100644 --- a/lib/compat/wordpress-6.2/script-loader.php +++ b/lib/compat/wordpress-6.2/script-loader.php @@ -129,28 +129,11 @@ function gutenberg_resolve_assets_override() { $scripts = ob_get_clean(); - /* - * Generate font @font-face styles for the site editor iframe. - * Use the registered font families for printing. - */ - if ( class_exists( 'WP_Fonts' ) ) { - $wp_fonts = wp_fonts(); - $registered = $wp_fonts->get_registered_font_families(); - if ( ! empty( $registered ) ) { - $queue = $wp_fonts->queue; - $done = $wp_fonts->done; - - $wp_fonts->done = array(); - $wp_fonts->queue = $registered; - - ob_start(); - $wp_fonts->do_items(); - $styles .= ob_get_clean(); - - // Reset the Web Fonts API. - $wp_fonts->done = $done; - $wp_fonts->queue = $queue; - } + // Generate font @font-face styles. + if ( function_exists( 'wp_print_fonts' ) ) { + ob_start(); + wp_print_fonts( true ); + $styles .= ob_get_clean(); } return array( diff --git a/lib/compat/wordpress-6.3/class-gutenberg-rest-templates-controller-6-3.php b/lib/compat/wordpress-6.3/class-gutenberg-rest-templates-controller-6-3.php index 2f9bd7a8cd9e4..cbe9b5242a248 100644 --- a/lib/compat/wordpress-6.3/class-gutenberg-rest-templates-controller-6-3.php +++ b/lib/compat/wordpress-6.3/class-gutenberg-rest-templates-controller-6-3.php @@ -9,7 +9,7 @@ /** * Base Templates REST API Controller. */ -class Gutenberg_REST_Templates_Controller_6_3 extends Gutenberg_REST_Templates_Controller { +class Gutenberg_REST_Templates_Controller_6_3 extends WP_REST_Templates_Controller { /** * Registers the controllers routes. diff --git a/lib/compat/wordpress-6.3/get-global-styles-and-settings.php b/lib/compat/wordpress-6.3/get-global-styles-and-settings.php index bb489664e1eea..609d9b3de38a1 100644 --- a/lib/compat/wordpress-6.3/get-global-styles-and-settings.php +++ b/lib/compat/wordpress-6.3/get-global-styles-and-settings.php @@ -124,3 +124,37 @@ function wp_get_block_css_selector( $block_type, $target = 'root', $fallback = f function gutenberg_get_remote_theme_patterns() { return WP_Theme_JSON_Resolver_Gutenberg::get_theme_data( array(), array( 'with_supports' => false ) )->get_patterns(); } + +/** + * Gets the styles resulting of merging core, theme, and user data. + * + * @since 5.9.0 + * @since 6.3.0 the internal link format "var:preset|color|secondary" is resolved + * to "var(--wp--preset--font-size--small)" so consumers don't have to. + * + * @param array $path Path to the specific style to retrieve. Optional. + * If empty, will return all styles. + * @param array $context { + * Metadata to know where to retrieve the $path from. Optional. + * + * @type string $block_name Which block to retrieve the styles from. + * If empty, it'll return the styles for the global context. + * @type string $origin Which origin to take data from. + * Valid values are 'all' (core, theme, and user) or 'base' (core and theme). + * If empty or unknown, 'all' is used. + * } + * @return array The styles to retrieve. + */ +function gutenberg_get_global_styles( $path = array(), $context = array() ) { + if ( ! empty( $context['block_name'] ) ) { + $path = array_merge( array( 'blocks', $context['block_name'] ), $path ); + } + + $origin = 'custom'; + if ( isset( $context['origin'] ) && 'base' === $context['origin'] ) { + $origin = 'theme'; + } + $styles = WP_Theme_JSON_Resolver_Gutenberg::get_merged_data( $origin )->get_raw_data()['styles']; + + return _wp_array_get( $styles, $path, $styles ); +} diff --git a/lib/compat/wordpress-6.3/html-api/class-gutenberg-html-tag-processor-6-3.php b/lib/compat/wordpress-6.3/html-api/class-gutenberg-html-tag-processor-6-3.php index 65fcefe8ac9c8..5b38484f659ac 100644 --- a/lib/compat/wordpress-6.3/html-api/class-gutenberg-html-tag-processor-6-3.php +++ b/lib/compat/wordpress-6.3/html-api/class-gutenberg-html-tag-processor-6-3.php @@ -39,10 +39,10 @@ * * Example: * ```php - * $tags = new WP_HTML_Tag_Processor( $html ); - * if ( $tags->next_tag( 'option' ) ) { - * $tags->set_attribute( 'selected', true ); - * } + * $tags = new WP_HTML_Tag_Processor( $html ); + * if ( $tags->next_tag( 'option' ) ) { + * $tags->set_attribute( 'selected', true ); + * } * ``` * * ### Finding tags @@ -55,7 +55,7 @@ * * If you want to _find whatever the next tag is_: * ```php - * $tags->next_tag(); + * $tags->next_tag(); * ``` * * | Goal | Query | @@ -88,17 +88,17 @@ * * 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--; - * } + * // 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 @@ -117,10 +117,10 @@ * * Example: * ```php - * if ( $tags->next_tag( array( 'class' => 'wp-group-block' ) ) ) { - * $tags->set_attribute( 'title', 'This groups the contained content.' ); - * $tags->remove_attribute( 'data-test-id' ); - * } + * if ( $tags->next_tag( array( '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 @@ -142,29 +142,29 @@ * * 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 `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' ); + * // from `` + * // to ` + * $tags->remove_class( 'rugby' ); * - * // from `` - * // to ` - * $tags->remove_class( 'rugby' ); + * // from `` + * // to ` + * $tags->remove_class( 'rugby' ); * ``` * * When class changes are enqueued but a direct change to `class` is made via @@ -185,24 +185,24 @@ * bookmark and update it frequently, such as within a loop. * * ```php - * $total_todos = 0; - * while ( $p->next_tag( array( 'tag_name' => 'UL', 'class_name' => 'todo' ) ) ) { - * $p->set_bookmark( 'list-start' ); - * while ( $p->next_tag( array( 'tag_closers' => 'visit' ) ) ) { - * if ( 'UL' === $p->get_tag() && $p->is_tag_closer() ) { - * $p->set_bookmark( 'list-end' ); - * $p->seek( 'list-start' ); - * $p->set_attribute( 'data-contained-todos', (string) $total_todos ); - * $total_todos = 0; - * $p->seek( 'list-end' ); - * break; - * } + * $total_todos = 0; + * while ( $p->next_tag( array( 'tag_name' => 'UL', 'class_name' => 'todo' ) ) ) { + * $p->set_bookmark( 'list-start' ); + * while ( $p->next_tag( array( 'tag_closers' => 'visit' ) ) ) { + * if ( 'UL' === $p->get_tag() && $p->is_tag_closer() ) { + * $p->set_bookmark( 'list-end' ); + * $p->seek( 'list-start' ); + * $p->set_attribute( 'data-contained-todos', (string) $total_todos ); + * $total_todos = 0; + * $p->seek( 'list-end' ); + * break; + * } * - * if ( 'LI' === $p->get_tag() && ! $p->is_tag_closer() ) { - * $total_todos++; - * } + * if ( 'LI' === $p->get_tag() && ! $p->is_tag_closer() ) { + * $total_todos++; * } * } + * } * ``` * * ## Design and limitations @@ -229,11 +229,11 @@ * The Tag Processor's design incorporates a "garbage-in-garbage-out" philosophy. * HTML5 specifies that certain invalid content be transformed into different forms * for display, such as removing null bytes from an input document and replacing - * invalid characters with the Unicode replacement character U+FFFD �. Where errors - * or transformations exist within the HTML5 specification, the Tag Processor leaves - * those invalid inputs untouched, passing them through to the final browser to handle. - * While this implies that certain operations will be non-spec-compliant, such as - * reading the value of an attribute with invalid content, it also preserves a + * invalid characters with the Unicode replacement character `U+FFFD` (visually "�"). + * Where errors or transformations exist within the HTML5 specification, the Tag Processor + * leaves those invalid inputs untouched, passing them through to the final browser + * to handle. While this implies that certain operations will be non-spec-compliant, + * such as reading the value of an attribute with invalid content, it also preserves a * simplicity and efficiency for handling those error cases. * * Most operations within the Tag Processor are designed to minimize the difference @@ -253,9 +253,10 @@ class Gutenberg_HTML_Tag_Processor_6_3 { * The maximum number of bookmarks allowed to exist at * any given time. * - * @see set_bookmark() * @since 6.2.0 * @var int + * + * @see WP_HTML_Tag_Processor::set_bookmark() */ const MAX_BOOKMARKS = 10; @@ -263,9 +264,10 @@ class Gutenberg_HTML_Tag_Processor_6_3 { * Maximum number of times seek() can be called. * Prevents accidental infinite loops. * - * @see seek() * @since 6.2.0 * @var int + * + * @see WP_HTML_Tag_Processor::seek() */ const MAX_SEEK_OPS = 1000; @@ -317,23 +319,6 @@ class Gutenberg_HTML_Tag_Processor_6_3 { */ private $stop_on_tag_closers; - /** - * Holds updated HTML as updates are applied. - * - * Updates and unmodified portions of the input document are - * appended to this value as they are applied. It will hold - * a copy of the updated document up until the point of the - * latest applied update. The fully-updated HTML document - * will comprise this value plus the part of the input document - * which follows that latest update. - * - * @see $bytes_already_copied - * - * @since 6.2.0 - * @var string - */ - private $output_buffer = ''; - /** * How many bytes from the original HTML document have been read and parsed. * @@ -346,23 +331,6 @@ class Gutenberg_HTML_Tag_Processor_6_3 { */ private $bytes_already_parsed = 0; - /** - * How many bytes from the input HTML document have already been - * copied into the output buffer. - * - * Lexical updates are enqueued and processed in batches. Prior - * to any given update in the input document, there might exist - * a span of HTML unaffected by any changes. This span ought to - * be copied verbatim into the output buffer before applying the - * following update. This value will point to the starting byte - * offset in the input document where that unaffected span of - * HTML starts. - * - * @since 6.2.0 - * @var int - */ - private $bytes_already_copied = 0; - /** * Byte offset in input document where current tag name starts. * @@ -421,23 +389,23 @@ class Gutenberg_HTML_Tag_Processor_6_3 { * * Example: * ```php - * // supposing the parser is working through this content - * // and stops after recognizing the `id` attribute - * //
- * // ^ parsing will continue from this point - * $this->attributes = array( - * '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 = array( - * '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. + * // supposing the parser is working through this content + * // and stops after recognizing the `id` attribute + * //
+ * // ^ parsing will continue from this point + * $this->attributes = array( + * '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 = array( + * '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 @@ -458,12 +426,12 @@ class Gutenberg_HTML_Tag_Processor_6_3 { * * Example: * ```php - * // Add the `wp-block-group` class, remove the `wp-group` class. - * $classname_updates = array( - * // Indexed by a comparable class name - * 'wp-block-group' => WP_HTML_Tag_Processor::ADD_CLASS, - * 'wp-group' => WP_HTML_Tag_Processor::REMOVE_CLASS - * ); + * // Add the `wp-block-group` class, remove the `wp-group` class. + * $classname_updates = array( + * // Indexed by a comparable class name + * 'wp-block-group' => WP_HTML_Tag_Processor::ADD_CLASS, + * 'wp-group' => WP_HTML_Tag_Processor::REMOVE_CLASS + * ); * ``` * * @since 6.2.0 @@ -512,16 +480,16 @@ class Gutenberg_HTML_Tag_Processor_6_3 { * * Example: * ```php - * // 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, $new_value ); - * - * // Correspondingly, something like this will appear in this array. - * $lexical_updates = array( - * WP_HTML_Text_Replacement( 14, 28, 'https://my-site.my-domain/wp-content/uploads/2014/08/kittens.jpg' ) - * ); + * // 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, $new_value ); + * + * // Correspondingly, something like this will appear in this array. + * $lexical_updates = array( + * WP_HTML_Text_Replacement( 14, 28, 'https://my-site.my-domain/wp-content/uploads/2014/08/kittens.jpg' ) + * ); * ``` * * @since 6.2.0 @@ -532,9 +500,10 @@ class Gutenberg_HTML_Tag_Processor_6_3 { /** * Tracks and limits `seek()` calls to prevent accidental infinite loops. * - * @see seek * @since 6.2.0 * @var int + * + * @see WP_HTML_Tag_Processor::seek() */ protected $seek_count = 0; @@ -754,9 +723,10 @@ public function release_bookmark( $name ) { /** * Skips contents of title and textarea tags. * - * @see https://html.spec.whatwg.org/multipage/parsing.html#rcdata-state * @since 6.2.0 * + * @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. * @return bool Whether an end to the RCDATA region was found before the end of the document. */ @@ -1303,8 +1273,7 @@ private function skip_whitespace() { * @return void */ private function after_tag() { - $this->class_name_updates_to_attributes_updates(); - $this->apply_attributes_updates(); + $this->get_updated_html(); $this->tag_name_starts_at = null; $this->tag_name_length = null; $this->tag_ends_at = null; @@ -1316,11 +1285,11 @@ private function after_tag() { * Converts class name updates into tag attributes updates * (they are accumulated in different data formats for performance). * - * @see $lexical_updates - * @see $classname_updates - * * @since 6.2.0 * + * @see WP_HTML_Tag_Processor::$lexical_updates + * @see WP_HTML_Tag_Processor::$classname_updates + * * @return void */ private function class_name_updates_to_attributes_updates() { @@ -1460,15 +1429,19 @@ private function class_name_updates_to_attributes_updates() { * Applies attribute updates to HTML document. * * @since 6.2.0 + * @since 6.2.1 Accumulates shift for internal cursor and passed pointer. * @since 6.3.0 Invalidate any bookmarks whose targets are overwritten. * - * @return void + * @param int $shift_this_point Accumulate and return shift for this position. + * @return int How many bytes the given pointer moved in response to the updates. */ - private function apply_attributes_updates() { + private function apply_attributes_updates( $shift_this_point = 0 ) { if ( ! count( $this->lexical_updates ) ) { - return; + return 0; } + $accumulated_shift_for_given_point = 0; + /* * Attribute updates can be enqueued in any order but updates * to the document must occur in lexical order; that is, each @@ -1481,12 +1454,28 @@ private function apply_attributes_updates() { */ usort( $this->lexical_updates, array( self::class, 'sort_start_ascending' ) ); + $bytes_already_copied = 0; + $output_buffer = ''; foreach ( $this->lexical_updates as $diff ) { - $this->output_buffer .= substr( $this->html, $this->bytes_already_copied, $diff->start - $this->bytes_already_copied ); - $this->output_buffer .= $diff->text; - $this->bytes_already_copied = $diff->end; + $shift = strlen( $diff->text ) - ( $diff->end - $diff->start ); + + // Adjust the cursor position by however much an update affects it. + if ( $diff->start <= $this->bytes_already_parsed ) { + $this->bytes_already_parsed += $shift; + } + + // Accumulate shift of the given pointer within this function call. + if ( $diff->start <= $shift_this_point ) { + $accumulated_shift_for_given_point += $shift; + } + + $output_buffer .= substr( $this->html, $bytes_already_copied, $diff->start - $bytes_already_copied ); + $output_buffer .= $diff->text; + $bytes_already_copied = $diff->end; } + $this->html = $output_buffer . substr( $this->html, $bytes_already_copied ); + /* * Adjust bookmark locations to account for how the text * replacements adjust offsets in the input document. @@ -1527,6 +1516,8 @@ private function apply_attributes_updates() { } $this->lexical_updates = array(); + + return $accumulated_shift_for_given_point; } /** @@ -1576,8 +1567,6 @@ public function seek( $bookmark_name ) { // Point this tag processor before the sought tag opener and consume it. $this->bytes_already_parsed = $this->bookmarks[ $bookmark_name ]->start; - $this->bytes_already_copied = $this->bytes_already_parsed; - $this->output_buffer = substr( $this->html, 0, $this->bytes_already_copied ); return $this->next_tag( array( 'tag_closers' => 'visit' ) ); } @@ -1676,14 +1665,14 @@ private function get_enqueued_attribute_value( $comparable_name ) { * * Example: * ```php - * $p = new WP_HTML_Tag_Processor( '
Test
' ); - * $p->next_tag( array( 'class_name' => 'test' ) ) === true; - * $p->get_attribute( 'data-test-id' ) === '14'; - * $p->get_attribute( 'enabled' ) === true; - * $p->get_attribute( 'aria-label' ) === null; - * - * $p->next_tag() === false; - * $p->get_attribute( 'class' ) === null; + * $p = new WP_HTML_Tag_Processor( '
Test
' ); + * $p->next_tag( array( 'class_name' => 'test' ) ) === true; + * $p->get_attribute( 'data-test-id' ) === '14'; + * $p->get_attribute( 'enabled' ) === true; + * $p->get_attribute( 'aria-label' ) === null; + * + * $p->next_tag() === false; + * $p->get_attribute( 'class' ) === null; * ``` * * @since 6.2.0 @@ -1755,20 +1744,20 @@ public function get_attribute( $name ) { * > case-insensitive match for each other. * - HTML 5 spec * - * @see https://html.spec.whatwg.org/multipage/syntax.html#attributes-2:ascii-case-insensitive - * * Example: * ```php - * $p = new WP_HTML_Tag_Processor( '
Test
' ); - * $p->next_tag( array( 'class_name' => 'test' ) ) === true; - * $p->get_attribute_names_with_prefix( 'data-' ) === array( 'data-enabled', 'data-test-id' ); + * $p = new WP_HTML_Tag_Processor( '
Test
' ); + * $p->next_tag( array( 'class_name' => 'test' ) ) === true; + * $p->get_attribute_names_with_prefix( 'data-' ) === array( 'data-enabled', 'data-test-id' ); * - * $p->next_tag() === false; - * $p->get_attribute_names_with_prefix( 'data-' ) === null; + * $p->next_tag() === false; + * $p->get_attribute_names_with_prefix( 'data-' ) === null; * ``` * * @since 6.2.0 * + * @see https://html.spec.whatwg.org/multipage/syntax.html#attributes-2:ascii-case-insensitive + * * @param string $prefix Prefix of requested attribute names. * @return array|null List of attribute names, or `null` when no tag opener is matched. */ @@ -1793,12 +1782,12 @@ function get_attribute_names_with_prefix( $prefix ) { * * Example: * ```php - * $p = new WP_HTML_Tag_Processor( '
Test
' ); - * $p->next_tag() === true; - * $p->get_tag() === 'DIV'; + * $p = new WP_HTML_Tag_Processor( '
Test
' ); + * $p->next_tag() === true; + * $p->get_tag() === 'DIV'; * - * $p->next_tag() === false; - * $p->get_tag() === null; + * $p->next_tag() === false; + * $p->get_tag() === null; * ``` * * @since 6.2.0 @@ -1845,12 +1834,12 @@ public function has_self_closing_flag() { * * Example: * ```php - * $p = new WP_HTML_Tag_Processor( '
' ); - * $p->next_tag( array( 'tag_name' => 'div', 'tag_closers' => 'visit' ) ); - * $p->is_tag_closer() === false; + * $p = new WP_HTML_Tag_Processor( '
' ); + * $p->next_tag( array( 'tag_name' => 'div', 'tag_closers' => 'visit' ) ); + * $p->is_tag_closer() === false; * - * $p->next_tag( array( 'tag_name' => 'div', 'tag_closers' => 'visit' ) ); - * $p->is_tag_closer() === true; + * $p->next_tag( array( 'tag_name' => 'div', 'tag_closers' => 'visit' ) ); + * $p->is_tag_closer() === true; * ``` * * @since 6.2.0 @@ -2110,7 +2099,8 @@ public function remove_class( $class_name ) { * Returns the string representation of the HTML Tag Processor. * * @since 6.2.0 - * @see get_updated_html + * + * @see WP_HTML_Tag_Processor::get_updated_html() * * @return string The processed HTML. */ @@ -2122,6 +2112,7 @@ public function __toString() { * Returns the string representation of the HTML Tag Processor. * * @since 6.2.0 + * @since 6.2.1 Shifts the internal cursor corresponding to the applied updates. * * @return string The processed HTML. */ @@ -2132,46 +2123,24 @@ public function get_updated_html() { * When there is nothing more to update and nothing has already been * updated, return the original document and avoid a string copy. */ - if ( $requires_no_updating && 0 === $this->bytes_already_copied ) { + if ( $requires_no_updating ) { return $this->html; } /* - * If there are no updates left to apply, but some have already - * been applied, then finish by copying the rest of the input - * to the end of the updated document and return. + * Keep track of the position right before the current tag. This will + * be necessary for reparsing the current tag after updating the HTML. */ - if ( $requires_no_updating && $this->bytes_already_copied > 0 ) { - $this->html = $this->output_buffer . substr( $this->html, $this->bytes_already_copied ); - $this->bytes_already_copied = strlen( $this->output_buffer ); - return $this->output_buffer . substr( $this->html, $this->bytes_already_copied ); - } - - // Apply the updates, rewind to before the current tag, and reparse the attributes. - $content_up_to_opened_tag_name = $this->output_buffer . substr( - $this->html, - $this->bytes_already_copied, - $this->tag_name_starts_at + $this->tag_name_length - $this->bytes_already_copied - ); + $before_current_tag = $this->tag_name_starts_at - 1; /* - * 1. Apply the edits by flushing them to the output buffer and updating the copied byte count. - * - * Note: `apply_attributes_updates()` modifies `$this->output_buffer`. + * 1. Apply the enqueued edits and update all the pointers to reflect those changes. */ $this->class_name_updates_to_attributes_updates(); - $this->apply_attributes_updates(); + $before_current_tag += $this->apply_attributes_updates( $before_current_tag ); /* - * 2. Replace the original HTML with the now-updated HTML so that it's possible to - * seek to a previous location and have a consistent view of the updated document. - */ - $this->html = $this->output_buffer . substr( $this->html, $this->bytes_already_copied ); - $this->output_buffer = $content_up_to_opened_tag_name; - $this->bytes_already_copied = strlen( $this->output_buffer ); - - /* - * 3. Point this tag processor at the original tag opener and consume it + * 2. Rewind to before the current tag and reparse to get updated attributes. * * At this point the internal cursor points to the end of the tag name. * Rewind before the tag name starts so that it's as if the cursor didn't @@ -2183,9 +2152,19 @@ public function get_updated_html() { * ^ | back up by the length of the tag name plus the opening < * \<-/ back up by strlen("em") + 1 ==> 3 */ - $this->bytes_already_parsed = strlen( $content_up_to_opened_tag_name ) - $this->tag_name_length - 1; + + // Store existing state so it can be restored after reparsing. + $previous_parsed_byte_count = $this->bytes_already_parsed; + $previous_query = $this->last_query; + + // Reparse attributes. + $this->bytes_already_parsed = $before_current_tag; $this->next_tag(); + // Restore previous state. + $this->bytes_already_parsed = $previous_parsed_byte_count; + $this->parse_query( $previous_query ); + return $this->html; } diff --git a/lib/compat/wordpress-6.3/script-loader.php b/lib/compat/wordpress-6.3/script-loader.php index c735f3b8a792a..8b00e10d09b66 100644 --- a/lib/compat/wordpress-6.3/script-loader.php +++ b/lib/compat/wordpress-6.3/script-loader.php @@ -56,7 +56,7 @@ function _gutenberg_get_iframed_editor_assets() { ob_start(); wp_print_styles(); - wp_print_fonts(); + wp_print_fonts( true ); $styles = ob_get_clean(); ob_start(); diff --git a/lib/compat/wordpress-6.3/theme-previews.php b/lib/compat/wordpress-6.3/theme-previews.php new file mode 100644 index 0000000000000..e73c13daa7cfb --- /dev/null +++ b/lib/compat/wordpress-6.3/theme-previews.php @@ -0,0 +1,129 @@ +errors() ) ) { + if ( current_filter() === 'template' ) { + $theme_path = $wp_theme->get_template(); + } else { + $theme_path = $wp_theme->get_stylesheet(); + } + + return sanitize_text_field( $theme_path ); + } + + return $current_stylesheet; +} + +/** + * Adds a middleware to the REST API to set the theme for the preview. + */ +function gutenberg_attach_theme_preview_middleware() { + // Don't allow non-admins to preview themes. + if ( ! current_user_can( 'switch_themes' ) ) { + return; + } + + wp_add_inline_script( + 'wp-api-fetch', + sprintf( + 'wp.apiFetch.use( wp.apiFetch.createThemePreviewMiddleware( %s ) );', + wp_json_encode( sanitize_text_field( $_GET['theme_preview'] ) ) + ), + 'after' + ); +} + +/** + * Temporary function to add a live preview button to block themes. + * Remove when https://core.trac.wordpress.org/ticket/58190 lands. + */ +function add_live_preview_button() { + global $pagenow; + if ( 'themes.php' === $pagenow ) { + ?> + + + + array( $template_slug ) ) ); + $current_template = get_block_templates( array( 'slug__in' => array( $template_slug ) ) ); if ( ! empty( $current_template ) ) { $template_blocks = parse_blocks( $current_template[0]->content ); diff --git a/lib/experimental/class-gutenberg-rest-global-styles-revisions-controller.php b/lib/experimental/class-gutenberg-rest-global-styles-revisions-controller.php index 19e5262a1d351..c5d5a5f0dff1a 100644 --- a/lib/experimental/class-gutenberg-rest-global-styles-revisions-controller.php +++ b/lib/experimental/class-gutenberg-rest-global-styles-revisions-controller.php @@ -122,46 +122,20 @@ public function prepare_item_for_response( $item, $request ) { $raw_revision_config = json_decode( $item->post_content, true ); $config = ( new WP_Theme_JSON_Gutenberg( $raw_revision_config, 'custom' ) )->get_raw_data(); - // Builds human-friendly date. - $now_gmt = time(); - $modified = strtotime( $item->post_modified ); - $modified_gmt = strtotime( $item->post_modified_gmt . ' +0000' ); - /* translators: %s: Human-readable time difference. */ - $time_ago = sprintf( __( '%s ago', 'gutenberg' ), human_time_diff( $modified_gmt, $now_gmt ) ); - $date_short = date_i18n( _x( 'j M @ H:i', 'revision date short format', 'gutenberg' ), $modified ); - // Prepares item data. $data = array(); $fields = $this->get_fields_for_response( $request ); if ( rest_is_field_included( 'author', $fields ) ) { - $data['author'] = (int) $parent->post_author; - } - - if ( rest_is_field_included( 'author_avatar_url', $fields ) ) { - $data['author_avatar_url'] = get_avatar_url( - $parent->post_author, - array( - 'size' => 24, - ) - ); - } - - if ( rest_is_field_included( 'author_display_name', $fields ) ) { - $data['author_display_name'] = get_the_author_meta( 'display_name', $parent->post_author ); + $data['author'] = (int) $item->post_author; } if ( rest_is_field_included( 'date', $fields ) ) { - $data['date'] = $parent->post_date; - } - - if ( rest_is_field_included( 'date_display', $fields ) ) { - /* translators: 1: Human-readable time difference, 2: short date combined to show rendered revision date. */ - $data['date_display'] = sprintf( __( '%1$s (%2$s)', 'gutenberg' ), $time_ago, $date_short ); + $data['date'] = $item->post_date; } if ( rest_is_field_included( 'date_gmt', $fields ) ) { - $data['date_gmt'] = $parent->post_date_gmt; + $data['date_gmt'] = $item->post_date_gmt; } if ( rest_is_field_included( 'id', $fields ) ) { @@ -169,11 +143,11 @@ public function prepare_item_for_response( $item, $request ) { } if ( rest_is_field_included( 'modified', $fields ) ) { - $data['modified'] = $parent->post_modified; + $data['modified'] = $item->post_modified; } if ( rest_is_field_included( 'modified_gmt', $fields ) ) { - $data['modified_gmt'] = $parent->post_modified_gmt; + $data['modified_gmt'] = $item->post_modified_gmt; } if ( rest_is_field_included( 'parent', $fields ) ) { @@ -218,71 +192,53 @@ public function get_item_schema() { * Adds settings and styles from the WP_REST_Revisions_Controller item fields. * Leaves out GUID as global styles shouldn't be accessible via URL. */ - 'author' => array( + 'author' => array( 'description' => __( 'The ID for the author of the revision.', 'gutenberg' ), 'type' => 'integer', 'context' => array( 'view', 'edit', 'embed' ), ), - 'date' => array( + 'date' => array( 'description' => __( "The date the revision was published, in the site's timezone.", 'gutenberg' ), 'type' => 'string', 'format' => 'date-time', 'context' => array( 'view', 'edit', 'embed' ), ), - 'date_gmt' => array( + 'date_gmt' => array( 'description' => __( 'The date the revision was published, as GMT.', 'gutenberg' ), 'type' => 'string', 'format' => 'date-time', 'context' => array( 'view', 'edit' ), ), - 'id' => array( + 'id' => array( 'description' => __( 'Unique identifier for the revision.', 'gutenberg' ), 'type' => 'integer', 'context' => array( 'view', 'edit', 'embed' ), ), - 'modified' => array( + 'modified' => array( 'description' => __( "The date the revision was last modified, in the site's timezone.", 'gutenberg' ), 'type' => 'string', 'format' => 'date-time', 'context' => array( 'view', 'edit' ), ), - 'modified_gmt' => array( + 'modified_gmt' => array( 'description' => __( 'The date the revision was last modified, as GMT.', 'gutenberg' ), 'type' => 'string', 'format' => 'date-time', 'context' => array( 'view', 'edit' ), ), - 'parent' => array( + 'parent' => array( 'description' => __( 'The ID for the parent of the revision.', 'gutenberg' ), 'type' => 'integer', 'context' => array( 'view', 'edit', 'embed' ), ), - // Adds custom global styles revisions schema. - 'author_display_name' => array( - 'description' => __( 'The display name of the author.', 'gutenberg' ), - 'type' => 'string', - 'context' => array( 'view', 'edit' ), - ), - - 'author_avatar_url' => array( - 'description' => __( 'A URL to the avatar image of the author', 'gutenberg' ), - 'type' => 'string', - 'context' => array( 'view', 'edit' ), - ), - - 'date_display' => array( - 'description' => __( 'A human-friendly rendering of the date', 'gutenberg' ), - 'type' => 'string', - 'context' => array( 'view', 'edit' ), - ), // Adds settings and styles from the WP_REST_Global_Styles_Controller parent schema. - 'styles' => array( + 'styles' => array( 'description' => __( 'Global styles.', 'gutenberg' ), 'type' => array( 'object' ), 'context' => array( 'view', 'edit' ), ), - 'settings' => array( + 'settings' => array( 'description' => __( 'Global settings.', 'gutenberg' ), 'type' => array( 'object' ), 'context' => array( 'view', 'edit' ), @@ -309,6 +265,9 @@ public function get_item_permissions_check( $request ) { return $post; } + /* + * The same check as WP_REST_Global_Styles_Controller->get_item_permissions_check. + */ if ( ! current_user_can( 'read_post', $post->ID ) ) { return new WP_Error( 'rest_cannot_view', diff --git a/lib/experimental/editor-settings.php b/lib/experimental/editor-settings.php index a700a0f484371..c7dd5850a505c 100644 --- a/lib/experimental/editor-settings.php +++ b/lib/experimental/editor-settings.php @@ -86,12 +86,22 @@ function gutenberg_enable_experiments() { if ( $gutenberg_experiments && array_key_exists( 'gutenberg-command-center', $gutenberg_experiments ) ) { wp_add_inline_script( 'wp-edit-site', 'window.__experimentalEnableCommandCenter = true', 'before' ); } + if ( $gutenberg_experiments && array_key_exists( 'gutenberg-command-center', $gutenberg_experiments ) ) { + wp_add_inline_script( 'wp-edit-post', 'window.__experimentalEnableCommandCenter = true', 'before' ); + } if ( $gutenberg_experiments && array_key_exists( 'gutenberg-group-grid-variation', $gutenberg_experiments ) ) { wp_add_inline_script( 'wp-block-editor', 'window.__experimentalEnableGroupGridVariation = true', 'before' ); } if ( $gutenberg_experiments && array_key_exists( 'gutenberg-details-blocks', $gutenberg_experiments ) ) { wp_add_inline_script( 'wp-block-editor', 'window.__experimentalEnableDetailsBlocks = true', 'before' ); } + if ( $gutenberg_experiments && array_key_exists( 'gutenberg-interactivity-api-navigation-block', $gutenberg_experiments ) ) { + wp_add_inline_script( 'wp-block-editor', 'window.__experimentalEnableNavigationBlockInteractivity = true', 'before' ); + } + if ( $gutenberg_experiments && array_key_exists( 'gutenberg-theme-previews', $gutenberg_experiments ) ) { + wp_add_inline_script( 'wp-block-editor', 'window.__experimentalEnableThemePreviews = true', 'before' ); + } + } add_action( 'admin_init', 'gutenberg_enable_experiments' ); diff --git a/lib/experimental/fonts-api/bc-layer/class-gutenberg-fonts-api-bc-layer.php b/lib/experimental/fonts-api/bc-layer/class-gutenberg-fonts-api-bc-layer.php new file mode 100644 index 0000000000000..8c85976987523 --- /dev/null +++ b/lib/experimental/fonts-api/bc-layer/class-gutenberg-fonts-api-bc-layer.php @@ -0,0 +1,96 @@ +wp_fonts = ! empty( $wp_fonts ) ? $wp_fonts : wp_fonts(); + } /** * Gets the font slug. @@ -39,9 +53,22 @@ public static function get_font_slug( $to_convert ) { $message = is_array( $to_convert ) ? 'Use WP_Fonts_Utils::get_font_family_from_variation() to get the font family from an array and then WP_Fonts_Utils::convert_font_family_into_handle() to convert the font-family name into a handle' : 'Use WP_Fonts_Utils::convert_font_family_into_handle() to convert the font-family name into a handle'; - _deprecated_function( __METHOD__, 'X.X.X', $message ); + _deprecated_function( __METHOD__, 'GB 14.9.1', $message ); + + if ( empty( $to_convert ) ) { + return false; + } - return static::_get_font_slug( $to_convert ); + $font_family_name = is_array( $to_convert ) + ? WP_Fonts_Utils::get_font_family_from_variation( $to_convert ) + : $to_convert; + + $slug = false; + if ( ! empty( $font_family_name ) ) { + $slug = WP_Fonts_Utils::convert_font_family_into_handle( $font_family_name ); + } + + return $slug; } /** @@ -54,6 +81,20 @@ public static function init() { _deprecated_function( __METHOD__, 'GB 14.9.1', 'wp_fonts()' ); } + /** + * Get the list of all registered font family handles. + * + * @since X.X.X + * @deprecated GB 15.8.0 Use wp_fonts()->get_registered_font_families(). + * + * @return string[] + */ + public function get_registered_font_families() { + _deprecated_function( __METHOD__, 'GB 15.8.0', 'wp_fonts()->get_registered_font_families()' ); + + return $this->wp_fonts->get_registered_font_families(); + } + /** * Gets the list of registered fonts. * @@ -79,7 +120,7 @@ public function get_registered_webfonts() { public function get_enqueued_webfonts() { _deprecated_function( __METHOD__, 'GB 14.9.1', 'wp_fonts()->get_enqueued()' ); - return $this->queue; + return $this->wp_fonts->queue; } /** @@ -114,21 +155,35 @@ public function register_webfont( array $webfont, $font_family_handle = '', $var _deprecated_function( __METHOD__, 'GB 14.9.1', 'wp_register_fonts()' ); } - // When font family's handle is not passed, attempt to get it from the variation. - if ( ! WP_Fonts_Utils::is_defined( $font_family_handle ) ) { - $font_family = WP_Fonts_Utils::get_font_family_from_variation( $webfont ); - if ( $font_family ) { - $font_family_handle = WP_Fonts_Utils::convert_font_family_into_handle( $font_family ); - } + // Bail out if no variation passed as there's not to register. + if ( empty( $webfont ) ) { + return false; + } + + // Restructure definition: keyed by font-family and array of variations. + $font = array( $webfont ); + if ( WP_Fonts_Utils::is_defined( $font_family_handle ) ) { + $font = array( $font_family_handle => $font ); + } else { + $font = Gutenberg_Fonts_API_BC_Layer::migrate_deprecated_structure( $font, true ); + $font_family_handle = array_key_first( $font ); } - if ( empty( $font_family_handle ) ) { + if ( empty( $font ) || empty( $font_family_handle ) ) { return false; } - return $this->add_variation( $font_family_handle, $webfont, $variation_handle ) - ? $font_family_handle - : false; + // If the variation handle was passed, add it as variation key. + if ( WP_Fonts_Utils::is_defined( $variation_handle ) ) { + $font[ $font_family_handle ] = array( $variation_handle => $font[ $font_family_handle ][0] ); + } + + // Register with the Fonts API. + $handle = wp_register_fonts( $font ); + if ( empty( $handle ) ) { + return false; + } + return array_pop( $handle ); } /** @@ -147,82 +202,6 @@ public function enqueue_webfont( $font_family_name ) { return true; } - /** - * Migrates deprecated webfonts structure into new API data structure, - * i.e. variations grouped by their font-family. - * - * @param array $webfonts Array of webfonts to migrate. - * @return array - */ - public function migrate_deprecated_structure( array $webfonts ) { - $message = 'A deprecated fonts array structure passed to wp_register_fonts(). ' . - 'Variations must be grouped and keyed by their font family.'; - _deprecated_argument( __METHOD__, '14.9.1', $message ); - - $new_webfonts = array(); - foreach ( $webfonts as $webfont ) { - $font_family = WP_Fonts_Utils::get_font_family_from_variation( $webfont ); - if ( ! $font_family ) { - continue; - } - - if ( ! isset( $new_webfonts[ $font_family ] ) ) { - $new_webfonts[ $font_family ] = array(); - } - - $new_webfonts[ $font_family ][] = $webfont; - } - - return $new_webfonts; - } - - /** - * Determines if the given webfonts array is the deprecated array structure. - * - * @param array $webfonts Array of webfonts to check. - * @return bool True when deprecated structure, else false. - */ - public function is_deprecated_structure( array $webfonts ) { - // Checks the first key to determine if it's empty or non-string. - foreach ( $webfonts as $font_family => $variations ) { - return ! WP_Fonts_Utils::is_defined( $font_family ); - } - } - - /** - * Handle the deprecated web fonts structure. - * - * @param array $webfont Web font for extracting font family. - * @param string $message Deprecation message to throw. - * @return string|null The font family slug if successfully registered. Else null. - */ - protected function extract_font_family_from_deprecated_webfonts_structure( array $webfont, $message ) { - _deprecated_argument( __METHOD__, '14.9.1', $message ); - - $font_family = WP_Fonts_Utils::get_font_family_from_variation( $webfont ); - if ( ! $font_family ) { - return null; - } - - return WP_Fonts_Utils::convert_font_family_into_handle( $font_family ); - } - - /** - * Gets the font slug. - * - * Helper function for reuse without the deprecation. - * - * @param array|string $to_convert The value to convert into a slug. Expected as the web font's array - * or a font-family as a string. - * @return string|false The font slug on success, or false if the font-family cannot be determined. - */ - private static function _get_font_slug( $to_convert ) { - $font_family_name = is_array( $to_convert ) ? WP_Fonts_Utils::get_font_family_from_variation( $to_convert ) : $to_convert; - return ! empty( $font_family_name ) - ? WP_Fonts_Utils::convert_font_family_into_handle( $font_family_name ) - : false; - } - /** * Gets the registered webfonts in the original web font property structure keyed by each handle. * @@ -233,7 +212,7 @@ private function _get_registered_webfonts() { $registered = array(); // Find the registered font families. - foreach ( $this->registered as $handle => $obj ) { + foreach ( $this->wp_fonts->registered as $handle => $obj ) { if ( ! $obj->extra['is_font_family'] ) { continue; } @@ -248,7 +227,7 @@ private function _get_registered_webfonts() { // Build the return array structure. foreach ( $font_families as $font_family_handle => $variations ) { foreach ( $variations as $variation_handle ) { - $variation_obj = $this->registered[ $variation_handle ]; + $variation_obj = $this->wp_fonts->registered[ $variation_handle ]; $registered[ $font_family_handle ][ $variation_handle ] = $variation_obj->extra['font-properties']; } @@ -256,40 +235,4 @@ private function _get_registered_webfonts() { return $registered; } - - /** - * Gets the enqueued webfonts in the original web font property structure keyed by each handle. - * - * @return array[] - */ - private function _get_enqueued_webfonts() { - $enqueued = array(); - foreach ( $this->queue as $handle ) { - // Skip if not registered. - if ( ! isset( $this->registered[ $handle ] ) ) { - continue; - } - - // Skip if already found. - if ( isset( $enqueued[ $handle ] ) ) { - continue; - } - - $obj = $this->registered[ $handle ]; - - // If a variation, add it. - if ( ! $obj->extra['is_font_family'] ) { - $enqueued[ $handle ] = $obj->extra['font-properties']; - continue; - } - - // If font-family, add all of its variations. - foreach ( $obj->deps as $variation_handle ) { - $obj = $this->registered[ $variation_handle ]; - $enqueued[ $variation_handle ] = $obj->extra['font-properties']; - } - } - - return $enqueued; - } } diff --git a/lib/experimental/fonts-api/deprecations/webfonts-deprecations.php b/lib/experimental/fonts-api/bc-layer/webfonts-deprecations.php similarity index 95% rename from lib/experimental/fonts-api/deprecations/webfonts-deprecations.php rename to lib/experimental/fonts-api/bc-layer/webfonts-deprecations.php index 7179370471ad1..7a3a7bd013eea 100644 --- a/lib/experimental/fonts-api/deprecations/webfonts-deprecations.php +++ b/lib/experimental/fonts-api/bc-layer/webfonts-deprecations.php @@ -17,16 +17,18 @@ * @since X.X.X * @deprecated GB 15.1 Use wp_fonts() instead. * - * @global WP_Web_Fonts $wp_webfonts + * @global WP_Webfonts $wp_webfonts * - * @return WP_Web_Fonts WP_Web_Fonts instance. + * @return WP_Webfonts WP_Webfonts instance. */ function wp_webfonts() { _deprecated_function( __FUNCTION__, 'GB 15.1', 'wp_fonts()' ); global $wp_webfonts; - $wp_webfonts = wp_fonts(); + if ( ! ( $wp_webfonts instanceof WP_Webfonts ) ) { + $wp_webfonts = new WP_Webfonts( wp_fonts() ); + } return $wp_webfonts; } @@ -59,6 +61,8 @@ function wp_webfonts() { function wp_register_webfonts( array $webfonts ) { _deprecated_function( __FUNCTION__, 'GB 15.1', 'wp_register_fonts()' ); + $webfonts = Gutenberg_Fonts_API_BC_Layer::migrate_deprecated_structure( $webfonts ); + return wp_register_fonts( $webfonts ); } } @@ -249,6 +253,8 @@ function wp_register_webfont_provider( $name, $classname ) { * An empty array if none were processed. */ function wp_print_webfonts( $handles = false ) { + _deprecated_function( __FUNCTION__, 'GB 15.1', 'wp_print_fonts' ); + return wp_print_fonts( $handles ); } } diff --git a/lib/experimental/fonts-api/class-wp-fonts.php b/lib/experimental/fonts-api/class-wp-fonts.php index 6a06de8f4c86a..cbabd3d85d36d 100644 --- a/lib/experimental/fonts-api/class-wp-fonts.php +++ b/lib/experimental/fonts-api/class-wp-fonts.php @@ -16,7 +16,7 @@ * * @since X.X.X */ -class WP_Fonts extends WP_Webfonts { +class WP_Fonts extends WP_Dependencies { /** * An array of registered providers. diff --git a/lib/experimental/fonts-api/fonts-api.php b/lib/experimental/fonts-api/fonts-api.php index 49b45392d3306..5f3f7a60ee883 100644 --- a/lib/experimental/fonts-api/fonts-api.php +++ b/lib/experimental/fonts-api/fonts-api.php @@ -22,7 +22,18 @@ function wp_fonts() { if ( ! ( $wp_fonts instanceof WP_Fonts ) ) { $wp_fonts = new WP_Fonts(); + + // Initialize. $wp_fonts->register_provider( 'local', 'WP_Fonts_Provider_Local' ); + add_action( 'wp_head', 'wp_print_fonts', 50 ); + + /* + * For themes without a theme.json, admin printing is initiated by the 'admin_print_styles' hook. + * For themes with theme.json, admin printing is initiated by _wp_get_iframed_editor_assets(). + */ + if ( ! wp_theme_has_theme_json() ) { + add_action( 'admin_print_styles', 'wp_print_fonts', 50 ); + } } return $wp_fonts; @@ -56,12 +67,6 @@ function wp_register_fonts( array $fonts ) { $registered = array(); $wp_fonts = wp_fonts(); - // BACKPORT NOTE: Do not backport this code block to Core. - if ( $wp_fonts->is_deprecated_structure( $fonts ) ) { - $fonts = $wp_fonts->migrate_deprecated_structure( $fonts ); - } - // BACKPORT NOTE: end of code block. - foreach ( $fonts as $font_family => $variations ) { $font_family_handle = $wp_fonts->add_font_family( $font_family ); if ( ! $font_family_handle ) { @@ -176,34 +181,52 @@ function wp_register_font_provider( $name, $classname ) { * * @since X.X.X * - * @param string|string[]|false $handles Optional. Items to be processed: queue (false), - * single item (string), or multiple items (array of strings). - * Default false. + * @param string|string[]|bool $handles Optional. Items to be processed: queue (false), + * for iframed editor assets (true), single item (string), + * or multiple items (array of strings). + * Default false. * @return array|string[] Array of font handles that have been processed. * An empty array if none were processed. */ function wp_print_fonts( $handles = false ) { - global $wp_fonts; + $wp_fonts = wp_fonts(); + $registered = $wp_fonts->get_registered_font_families(); + $in_iframed_editor = true === $handles; + + // Nothing to print, as no fonts are registered. + if ( empty( $registered ) ) { + return array(); + } - if ( empty( $handles ) ) { - $handles = false; + // Skip this reassignment decision-making when using the default of `false`. + if ( false !== $handles ) { + // When `true`, print all registered fonts for the iframed editor. + if ( $in_iframed_editor ) { + $queue = $wp_fonts->queue; + $done = $wp_fonts->done; + $wp_fonts->done = array(); + $wp_fonts->queue = $registered; + $handles = false; + } elseif ( empty( $handles ) ) { + // When falsey, assign `false` to print enqueued fonts. + $handles = false; + } } _wp_scripts_maybe_doing_it_wrong( __FUNCTION__ ); - if ( ! ( $wp_fonts instanceof WP_Fonts ) ) { - if ( ! $handles ) { - return array(); // No need to instantiate if nothing is there. - } + $printed = $wp_fonts->do_items( $handles ); + + // Reset the API. + if ( $in_iframed_editor ) { + $wp_fonts->done = $done; + $wp_fonts->queue = $queue; } - return wp_fonts()->do_items( $handles ); + return $printed; } } -add_action( 'admin_print_styles', 'wp_print_fonts', 50 ); -add_action( 'wp_head', 'wp_print_fonts', 50 ); - /** * Add webfonts mime types. */ diff --git a/lib/experimental/interactivity-api/navigation-block-interactivity.php b/lib/experimental/interactivity-api/navigation-block-interactivity.php new file mode 100644 index 0000000000000..21e1e0381b70e --- /dev/null +++ b/lib/experimental/interactivity-api/navigation-block-interactivity.php @@ -0,0 +1,240 @@ + + *
+ *
+ *
+ * + * + * @param string $block_content Markup of the navigation block. + * + * @return string Navigation block markup with the proper directives + */ +function gutenberg_block_core_navigation_add_directives_to_markup( $block_content ) { + $w = new WP_HTML_Tag_Processor( $block_content ); + // Add directives to the `
diff --git a/packages/components/src/modal/style.scss b/packages/components/src/modal/style.scss index 8f0cd5e3d56c0..acc43de8b7f90 100644 --- a/packages/components/src/modal/style.scss +++ b/packages/components/src/modal/style.scss @@ -135,7 +135,7 @@ &.hide-header { margin-top: 0; - padding-top: $grid-unit-30; + padding-top: $grid-unit-40; } &.is-scrollable:focus-visible { diff --git a/packages/components/src/navigable-container/README.md b/packages/components/src/navigable-container/README.md index de6c1103ae6de..a982219248f50 100644 --- a/packages/components/src/navigable-container/README.md +++ b/packages/components/src/navigable-container/README.md @@ -2,38 +2,49 @@ `NavigableContainer` is a React component to render a container navigable using the keyboard. Only things that are focusable can be navigated to. It will currently always be a `div`. -`NavigableContainer` is exported as two classes: `NavigableMenu` and `TabbableContainer`. `NavigableContainer` itself is **not** exported. `NavigableMenu` and `TabbableContainer` have the props listed below. Any other props will be passed through to the `div`. +`NavigableContainer` is exported as two components: `NavigableMenu` and `TabbableContainer`. `NavigableContainer` itself is **not** exported. `NavigableMenu` and `TabbableContainer` have the props listed below. Any other props will be passed through to the `div`. --- ## Props -These are the props that `NavigableMenu` and `TabbableContainer`. Any props which are specific to one class are labelled appropriately. +These are the props that `NavigableMenu` and `TabbableContainer`. Any props which are specific to one component are labelled appropriately. -### onNavigate +### `cycle`: `boolean` -A callback invoked when the menu navigates to one of its children passing the index and child as an argument +A boolean which tells the component whether or not to cycle from the end back to the beginning and vice versa. -- Type: `Function` - Required: No +- default: `true` -### cycle +### `eventToOffset`: `( event: KeyboardEvent ) => -1 | 0 | 1 | undefined` -A boolean which tells the component whether or not to cycle from the end back to the beginning and vice versa. +(TabbableContainer only) +Gets an offset, given an event. + +- Required: No + +### `onKeydown`: `( event: KeyboardEvent ) => void` + +A callback invoked on the keydown event. + +- Required: No + +### `onNavigate`: `( index: number, focusable: HTMLElement ) => void` + +A callback invoked when the menu navigates to one of its children passing the index and child as an argument -- Type: `Boolean` - Required: No -- default: true -### orientation (NavigableMenu only) +### `orientation`: `'vertical' | 'horizontal' | 'both'` -The orientation of the menu. It could be "vertical", "horizontal" or "both" +(NavigableMenu only) +The orientation of the menu. It could be "vertical", "horizontal", or "both". -- Type: `String` - Required: No - Default: `"vertical"` -## Classes +## Components ### NavigableMenu diff --git a/packages/components/src/navigable-container/container.js b/packages/components/src/navigable-container/container.tsx similarity index 71% rename from packages/components/src/navigable-container/container.js rename to packages/components/src/navigable-container/container.tsx index 6c9de468f0c59..c5f92f8effcad 100644 --- a/packages/components/src/navigable-container/container.js +++ b/packages/components/src/navigable-container/container.tsx @@ -1,14 +1,23 @@ -// @ts-nocheck +/** + * External dependencies + */ +import type { ForwardedRef } from 'react'; + /** * WordPress dependencies */ import { Component, forwardRef } from '@wordpress/element'; import { focus } from '@wordpress/dom'; +/** + * Internal dependencies + */ +import type { NavigableContainerProps } from './types'; + const noop = () => {}; const MENU_ITEM_ROLES = [ 'menuitem', 'menuitemradio', 'menuitemcheckbox' ]; -function cycleValue( value, total, offset ) { +function cycleValue( value: number, total: number, offset: number ) { const nextValue = value + offset; if ( nextValue < 0 ) { return total + nextValue; @@ -19,9 +28,11 @@ function cycleValue( value, total, offset ) { return nextValue; } -class NavigableContainer extends Component { - constructor() { - super( ...arguments ); +class NavigableContainer extends Component< NavigableContainerProps > { + container?: HTMLDivElement; + + constructor( args: NavigableContainerProps ) { + super( args ); this.onKeyDown = this.onKeyDown.bind( this ); this.bindContainer = this.bindContainer.bind( this ); @@ -30,21 +41,27 @@ class NavigableContainer extends Component { } componentDidMount() { + if ( ! this.container ) { + return; + } + // We use DOM event listeners instead of React event listeners // because we want to catch events from the underlying DOM tree // The React Tree can be different from the DOM tree when using // portals. Block Toolbars for instance are rendered in a separate // React Trees. this.container.addEventListener( 'keydown', this.onKeyDown ); - this.container.addEventListener( 'focus', this.onFocus ); } componentWillUnmount() { + if ( ! this.container ) { + return; + } + this.container.removeEventListener( 'keydown', this.onKeyDown ); - this.container.removeEventListener( 'focus', this.onFocus ); } - bindContainer( ref ) { + bindContainer( ref: HTMLDivElement ) { const { forwardedRef } = this.props; this.container = ref; @@ -55,10 +72,14 @@ class NavigableContainer extends Component { } } - getFocusableContext( target ) { + getFocusableContext( target: Element ) { + if ( ! this.container ) { + return null; + } + const { onlyBrowserTabstops } = this.props; const finder = onlyBrowserTabstops ? focus.tabbable : focus.focusable; - const focusables = finder.find( this.container ); + const focusables = finder.find( this.container ) as HTMLElement[]; const index = this.getFocusableIndex( focusables, target ); if ( index > -1 && target ) { @@ -67,14 +88,11 @@ class NavigableContainer extends Component { return null; } - getFocusableIndex( focusables, target ) { - const directIndex = focusables.indexOf( target ); - if ( directIndex !== -1 ) { - return directIndex; - } + getFocusableIndex( focusables: Element[], target: Element ) { + return focusables.indexOf( target ); } - onKeyDown( event ) { + onKeyDown( event: KeyboardEvent ) { if ( this.props.onKeyDown ) { this.props.onKeyDown( event ); } @@ -98,15 +116,13 @@ class NavigableContainer extends Component { // from scrolling. The preventDefault also prevents Voiceover from // 'handling' the event, as voiceover will try to use arrow keys // for highlighting text. - const targetRole = event.target.getAttribute( 'role' ); + const targetRole = ( + event.target as HTMLDivElement | null + )?.getAttribute( 'role' ); const targetHasMenuItemRole = - MENU_ITEM_ROLES.includes( targetRole ); - - // `preventDefault()` on tab to avoid having the browser move the focus - // after this component has already moved it. - const isTab = event.code === 'Tab'; + !! targetRole && MENU_ITEM_ROLES.includes( targetRole ); - if ( targetHasMenuItemRole || isTab ) { + if ( targetHasMenuItemRole ) { event.preventDefault(); } } @@ -115,9 +131,13 @@ class NavigableContainer extends Component { return; } - const context = getFocusableContext( - event.target.ownerDocument.activeElement - ); + const activeElement = ( event.target as HTMLElement | null ) + ?.ownerDocument?.activeElement; + if ( ! activeElement ) { + return; + } + + const context = getFocusableContext( activeElement ); if ( ! context ) { return; } @@ -126,9 +146,16 @@ class NavigableContainer extends Component { const nextIndex = cycle ? cycleValue( index, focusables.length, offset ) : index + offset; + if ( nextIndex >= 0 && nextIndex < focusables.length ) { focusables[ nextIndex ].focus(); onNavigate( nextIndex, focusables[ nextIndex ] ); + + // `preventDefault()` on tab to avoid having the browser move the focus + // after this component has already moved it. + if ( event.code === 'Tab' ) { + event.preventDefault(); + } } } @@ -152,7 +179,10 @@ class NavigableContainer extends Component { } } -const forwardedNavigableContainer = ( props, ref ) => { +const forwardedNavigableContainer = ( + props: NavigableContainerProps, + ref: ForwardedRef< HTMLDivElement > +) => { return ; }; forwardedNavigableContainer.displayName = 'NavigableContainer'; diff --git a/packages/components/src/navigable-container/index.js b/packages/components/src/navigable-container/index.tsx similarity index 90% rename from packages/components/src/navigable-container/index.js rename to packages/components/src/navigable-container/index.tsx index b47667fd39cd7..e98f1b1236d7b 100644 --- a/packages/components/src/navigable-container/index.js +++ b/packages/components/src/navigable-container/index.tsx @@ -1,4 +1,3 @@ -// @ts-nocheck /** * Internal Dependencies */ diff --git a/packages/components/src/navigable-container/menu.js b/packages/components/src/navigable-container/menu.js deleted file mode 100644 index cf19c23adcb9d..0000000000000 --- a/packages/components/src/navigable-container/menu.js +++ /dev/null @@ -1,62 +0,0 @@ -// @ts-nocheck - -/** - * WordPress dependencies - */ -import { forwardRef } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import NavigableContainer from './container'; - -export function NavigableMenu( - { role = 'menu', orientation = 'vertical', ...rest }, - ref -) { - const eventToOffset = ( evt ) => { - const { code } = evt; - - let next = [ 'ArrowDown' ]; - let previous = [ 'ArrowUp' ]; - - if ( orientation === 'horizontal' ) { - next = [ 'ArrowRight' ]; - previous = [ 'ArrowLeft' ]; - } - - if ( orientation === 'both' ) { - next = [ 'ArrowRight', 'ArrowDown' ]; - previous = [ 'ArrowLeft', 'ArrowUp' ]; - } - - if ( next.includes( code ) ) { - return 1; - } else if ( previous.includes( code ) ) { - return -1; - } else if ( - [ 'ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight' ].includes( - code - ) - ) { - // Key press should be handled, e.g. have event propagation and - // default behavior handled by NavigableContainer but not result - // in an offset. - return 0; - } - }; - - return ( - - ); -} - -export default forwardRef( NavigableMenu ); diff --git a/packages/components/src/navigable-container/menu.tsx b/packages/components/src/navigable-container/menu.tsx new file mode 100644 index 0000000000000..ae865fe443aae --- /dev/null +++ b/packages/components/src/navigable-container/menu.tsx @@ -0,0 +1,100 @@ +/** + * External dependencies + */ +import type { ForwardedRef } from 'react'; + +/** + * WordPress dependencies + */ +import { forwardRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import NavigableContainer from './container'; +import type { NavigableMenuProps } from './types'; + +export function UnforwardedNavigableMenu( + { role = 'menu', orientation = 'vertical', ...rest }: NavigableMenuProps, + ref: ForwardedRef< any > +) { + const eventToOffset = ( evt: KeyboardEvent ) => { + const { code } = evt; + + let next = [ 'ArrowDown' ]; + let previous = [ 'ArrowUp' ]; + + if ( orientation === 'horizontal' ) { + next = [ 'ArrowRight' ]; + previous = [ 'ArrowLeft' ]; + } + + if ( orientation === 'both' ) { + next = [ 'ArrowRight', 'ArrowDown' ]; + previous = [ 'ArrowLeft', 'ArrowUp' ]; + } + + if ( next.includes( code ) ) { + return 1; + } else if ( previous.includes( code ) ) { + return -1; + } else if ( + [ 'ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight' ].includes( + code + ) + ) { + // Key press should be handled, e.g. have event propagation and + // default behavior handled by NavigableContainer but not result + // in an offset. + return 0; + } + + return undefined; + }; + + return ( + + ); +} + +/** + * A container for a navigable menu. + * + * ```jsx + * import { + * NavigableMenu, + * Button, + * } from '@wordpress/components'; + * + * function onNavigate( index, target ) { + * console.log( `Navigates to ${ index }`, target ); + * } + * + * const MyNavigableContainer = () => ( + *
+ * Navigable Menu: + * + * + * + * + * + *
+ * ); + * ``` + */ +export const NavigableMenu = forwardRef( UnforwardedNavigableMenu ); + +export default NavigableMenu; diff --git a/packages/components/src/navigable-container/stories/navigable-menu.js b/packages/components/src/navigable-container/stories/navigable-menu.tsx similarity index 66% rename from packages/components/src/navigable-container/stories/navigable-menu.js rename to packages/components/src/navigable-container/stories/navigable-menu.tsx index af9b08fd9b032..5f8ee2e4e5d85 100644 --- a/packages/components/src/navigable-container/stories/navigable-menu.js +++ b/packages/components/src/navigable-container/stories/navigable-menu.tsx @@ -1,25 +1,30 @@ +/** + * External dependencies + */ +import type { ComponentMeta, ComponentStory } from '@storybook/react'; + /** * Internal dependencies */ import { NavigableMenu } from '..'; -export default { +const meta: ComponentMeta< typeof NavigableMenu > = { title: 'Components/NavigableMenu', component: NavigableMenu, argTypes: { - children: { type: null }, - cycle: { - type: 'boolean', - }, - onNavigate: { action: 'onNavigate' }, - orientation: { - options: [ 'horizontal', 'vertical' ], - control: { type: 'radio' }, + children: { control: { type: null } }, + }, + parameters: { + actions: { argTypesRegex: '^on.*' }, + controls: { + expanded: true, }, + docs: { source: { state: 'open' } }, }, }; +export default meta; -export const Default = ( args ) => { +export const Default: ComponentStory< typeof NavigableMenu > = ( args ) => { return ( <> diff --git a/packages/components/src/navigable-container/stories/tabbable-container.js b/packages/components/src/navigable-container/stories/tabbable-container.tsx similarity index 61% rename from packages/components/src/navigable-container/stories/tabbable-container.js rename to packages/components/src/navigable-container/stories/tabbable-container.tsx index 3e12825189f1a..b517019e29571 100644 --- a/packages/components/src/navigable-container/stories/tabbable-container.js +++ b/packages/components/src/navigable-container/stories/tabbable-container.tsx @@ -1,21 +1,30 @@ +/** + * External dependencies + */ +import type { ComponentMeta, ComponentStory } from '@storybook/react'; + /** * Internal dependencies */ import { TabbableContainer } from '..'; -export default { +const meta: ComponentMeta< typeof TabbableContainer > = { title: 'Components/TabbableContainer', component: TabbableContainer, argTypes: { - children: { type: null }, - cycle: { - type: 'boolean', + children: { control: { type: null } }, + }, + parameters: { + actions: { argTypesRegex: '^on.*' }, + controls: { + expanded: true, }, - onNavigate: { action: 'onNavigate' }, + docs: { source: { state: 'open' } }, }, }; +export default meta; -export const Default = ( args ) => { +export const Default: ComponentStory< typeof TabbableContainer > = ( args ) => { return ( <> diff --git a/packages/components/src/navigable-container/tabbable.js b/packages/components/src/navigable-container/tabbable.js deleted file mode 100644 index 8f38970bf0670..0000000000000 --- a/packages/components/src/navigable-container/tabbable.js +++ /dev/null @@ -1,46 +0,0 @@ -// @ts-nocheck -/** - * WordPress dependencies - */ -import { forwardRef } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import NavigableContainer from './container'; - -export function TabbableContainer( { eventToOffset, ...props }, ref ) { - const innerEventToOffset = ( evt ) => { - const { code, shiftKey } = evt; - if ( 'Tab' === code ) { - return shiftKey ? -1 : 1; - } - - // Allow custom handling of keys besides Tab. - // - // By default, TabbableContainer will move focus forward on Tab and - // backward on Shift+Tab. The handler below will be used for all other - // events. The semantics for `eventToOffset`'s return - // values are the following: - // - // - +1: move focus forward - // - -1: move focus backward - // - 0: don't move focus, but acknowledge event and thus stop it - // - undefined: do nothing, let the event propagate. - if ( eventToOffset ) { - return eventToOffset( evt ); - } - }; - - return ( - - ); -} - -export default forwardRef( TabbableContainer ); diff --git a/packages/components/src/navigable-container/tabbable.tsx b/packages/components/src/navigable-container/tabbable.tsx new file mode 100644 index 0000000000000..565007bf62d83 --- /dev/null +++ b/packages/components/src/navigable-container/tabbable.tsx @@ -0,0 +1,92 @@ +/** + * External dependencies + */ +import type { ForwardedRef } from 'react'; + +/** + * WordPress dependencies + */ +import { forwardRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import NavigableContainer from './container'; +import type { TabbableContainerProps } from './types'; + +export function UnforwardedTabbableContainer( + { eventToOffset, ...props }: TabbableContainerProps, + ref: ForwardedRef< any > +) { + const innerEventToOffset = ( evt: KeyboardEvent ) => { + const { code, shiftKey } = evt; + if ( 'Tab' === code ) { + return shiftKey ? -1 : 1; + } + + // Allow custom handling of keys besides Tab. + // + // By default, TabbableContainer will move focus forward on Tab and + // backward on Shift+Tab. The handler below will be used for all other + // events. The semantics for `eventToOffset`'s return + // values are the following: + // + // - +1: move focus forward + // - -1: move focus backward + // - 0: don't move focus, but acknowledge event and thus stop it + // - undefined: do nothing, let the event propagate. + if ( eventToOffset ) { + return eventToOffset( evt ); + } + + return undefined; + }; + + return ( + + ); +} + +/** + * A container for tabbable elements. + * + * ```jsx + * import { + * TabbableContainer, + * Button, + * } from '@wordpress/components'; + * + * function onNavigate( index, target ) { + * console.log( `Navigates to ${ index }`, target ); + * } + * + * const MyTabbableContainer = () => ( + *
+ * Tabbable Container: + * + * + * + * + * + * + *
+ * ); + * ``` + */ +export const TabbableContainer = forwardRef( UnforwardedTabbableContainer ); + +export default TabbableContainer; diff --git a/packages/components/src/navigable-container/test/navigable-menu.js b/packages/components/src/navigable-container/test/navigable-menu.tsx similarity index 97% rename from packages/components/src/navigable-container/test/navigable-menu.js rename to packages/components/src/navigable-container/test/navigable-menu.tsx index 8152ca61ed73c..a5ab228b0c2b2 100644 --- a/packages/components/src/navigable-container/test/navigable-menu.js +++ b/packages/components/src/navigable-container/test/navigable-menu.tsx @@ -8,8 +8,9 @@ import userEvent from '@testing-library/user-event'; * Internal dependencies */ import { NavigableMenu } from '../menu'; +import type { NavigableMenuProps } from '../types'; -const NavigableMenuTestCase = ( props ) => ( +const NavigableMenuTestCase = ( props: NavigableMenuProps ) => ( @@ -34,6 +35,7 @@ describe( 'NavigableMenu', () => { // Mocking `getClientRects()` is necessary to pass a check performed by // the `focus.tabbable.find()` and by the `focus.focusable.find()` functions // from the `@wordpress/dom` package. + // @ts-expect-error We're not trying to comply to the DOM spec, only mocking window.HTMLElement.prototype.getClientRects = function () { return [ 'trick-jsdom-into-having-size-for-element-rect' ]; }; diff --git a/packages/components/src/navigable-container/test/tababble-container.js b/packages/components/src/navigable-container/test/tababble-container.tsx similarity index 74% rename from packages/components/src/navigable-container/test/tababble-container.js rename to packages/components/src/navigable-container/test/tababble-container.tsx index f514df39a50c8..eb14842025bec 100644 --- a/packages/components/src/navigable-container/test/tababble-container.js +++ b/packages/components/src/navigable-container/test/tababble-container.tsx @@ -8,20 +8,25 @@ import userEvent from '@testing-library/user-event'; * Internal dependencies */ import { TabbableContainer } from '../tabbable'; - -const TabbableContainerTestCase = ( props ) => ( - - - - Item 2 (not tabbable) - - - Item 3 - -

I can not be tabbed

- - Item 4 -
+import type { TabbableContainerProps } from '../types'; + +const TabbableContainerTestCase = ( props: TabbableContainerProps ) => ( + <> + + + + + Item 2 (not tabbable) + + + Item 3 + +

I can not be tabbed

+ + Item 4 +
+ + ); const getTabbableContainerTabbables = () => [ @@ -37,6 +42,7 @@ describe( 'TabbableContainer', () => { // Mocking `getClientRects()` is necessary to pass a check performed by // the `focus.tabbable.find()` and by the `focus.focusable.find()` functions // from the `@wordpress/dom` package. + // @ts-expect-error We're not trying to comply to the DOM spec, only mocking window.HTMLElement.prototype.getClientRects = function () { return [ 'trick-jsdom-into-having-size-for-element-rect' ]; }; @@ -55,7 +61,11 @@ describe( 'TabbableContainer', () => { const tabbables = getTabbableContainerTabbables(); - // Move focus to first item. + await user.tab(); + expect( + screen.getByRole( 'button', { name: 'Before container' } ) + ).toHaveFocus(); + await user.tab(); expect( tabbables[ 0 ] ).toHaveFocus(); @@ -89,7 +99,11 @@ describe( 'TabbableContainer', () => { const lastTabbableIndex = tabbables.length - 1; const lastTabbable = tabbables[ lastTabbableIndex ]; - // Move focus to first item. + await user.tab(); + expect( + screen.getByRole( 'button', { name: 'Before container' } ) + ).toHaveFocus(); + await user.tab(); expect( firstTabbable ).toHaveFocus(); @@ -114,12 +128,17 @@ describe( 'TabbableContainer', () => { /> ); - // With the `cycle` prop set to `false`, cycling is not allowed. // By default, cycling from first to last and from last to first is allowed. + // With the `cycle` prop set to `false`, cycling is not allowed. + // Therefore, focus will escape the `TabbableContainer` and continue its + // natural path in the page. await user.tab( { shift: true } ); - expect( firstTabbable ).toHaveFocus(); + expect( + screen.getByRole( 'button', { name: 'Before container' } ) + ).toHaveFocus(); expect( onNavigateSpy ).toHaveBeenCalledTimes( 2 ); + await user.tab(); await user.tab(); await user.tab(); expect( lastTabbable ).toHaveFocus(); @@ -129,8 +148,12 @@ describe( 'TabbableContainer', () => { lastTabbable ); + // Focus will move to the next natively focusable elements after + // `TabbableContainer` await user.tab(); - expect( lastTabbable ).toHaveFocus(); + expect( + screen.getByRole( 'button', { name: 'After container' } ) + ).toHaveFocus(); expect( onNavigateSpy ).toHaveBeenCalledTimes( 4 ); } ); @@ -149,21 +172,27 @@ describe( 'TabbableContainer', () => { const tabbables = getTabbableContainerTabbables(); - // Move focus to first item + await user.tab(); + expect( + screen.getByRole( 'button', { name: 'Before container' } ) + ).toHaveFocus(); + expect( externalWrapperOnKeyDownSpy ).toHaveBeenCalledTimes( 0 ); + await user.tab(); expect( tabbables[ 0 ] ).toHaveFocus(); + expect( externalWrapperOnKeyDownSpy ).toHaveBeenCalledTimes( 1 ); await user.keyboard( '[Space]' ); - expect( externalWrapperOnKeyDownSpy ).toHaveBeenCalledTimes( 1 ); + expect( externalWrapperOnKeyDownSpy ).toHaveBeenCalledTimes( 2 ); await user.tab(); - expect( externalWrapperOnKeyDownSpy ).toHaveBeenCalledTimes( 1 ); + expect( externalWrapperOnKeyDownSpy ).toHaveBeenCalledTimes( 2 ); await user.tab( { shift: true } ); // This extra call is caused by the "shift" key being pressed // on its own before "tab" - expect( externalWrapperOnKeyDownSpy ).toHaveBeenCalledTimes( 2 ); + expect( externalWrapperOnKeyDownSpy ).toHaveBeenCalledTimes( 3 ); await user.keyboard( '[Escape]' ); - expect( externalWrapperOnKeyDownSpy ).toHaveBeenCalledTimes( 3 ); + expect( externalWrapperOnKeyDownSpy ).toHaveBeenCalledTimes( 4 ); } ); } ); diff --git a/packages/components/src/navigable-container/types.ts b/packages/components/src/navigable-container/types.ts new file mode 100644 index 0000000000000..e64ff575069ac --- /dev/null +++ b/packages/components/src/navigable-container/types.ts @@ -0,0 +1,76 @@ +/** + * External dependencies + */ +import type { ForwardedRef, ReactNode } from 'react'; + +/** + * Internal dependencies + */ +import type { WordPressComponentProps } from '../ui/context'; + +type BaseProps = { + /** + * The component children. + */ + children?: ReactNode; + /** + * A boolean which tells the component whether or not to cycle from the end back to the beginning and vice versa. + * + * @default true + */ + cycle?: boolean; + /** + * A callback invoked on the keydown event. + */ + onKeyDown?: ( event: KeyboardEvent ) => void; + /** + * A callback invoked when the menu navigates to one of its children passing the index and child as an argument + */ + onNavigate?: ( index: number, focusable: HTMLElement ) => void; +}; + +export type NavigableContainerProps = WordPressComponentProps< + BaseProps & { + /** + * Gets an offset, given an event. + */ + eventToOffset: ( event: KeyboardEvent ) => -1 | 0 | 1 | undefined; + /** + * The forwarded ref. + */ + forwardedRef?: ForwardedRef< any >; + /** + * Whether to only consider browser tab stops. + * + * @default false + */ + onlyBrowserTabstops: boolean; + /** + * Whether to stop navigation events. + * + * @default false + */ + stopNavigationEvents: boolean; + }, + 'div', + false +>; + +export type NavigableMenuProps = WordPressComponentProps< + BaseProps & { + /** + * The orientation of the menu. + * + * @default 'vertical' + */ + orientation?: 'vertical' | 'horizontal' | 'both'; + }, + 'div', + false +>; + +export type TabbableContainerProps = WordPressComponentProps< + BaseProps & Partial< Pick< NavigableContainerProps, 'eventToOffset' > >, + 'div', + false +>; diff --git a/packages/components/src/palette-edit/index.tsx b/packages/components/src/palette-edit/index.tsx index bd9818d5d8db2..9061222567103 100644 --- a/packages/components/src/palette-edit/index.tsx +++ b/packages/components/src/palette-edit/index.tsx @@ -1,12 +1,19 @@ /** * External dependencies */ +import classnames from 'classnames'; import { paramCase as kebabCase } from 'change-case'; /** * WordPress dependencies */ -import { useState, useRef, useEffect, useCallback } from '@wordpress/element'; +import { + useState, + useRef, + useEffect, + useCallback, + useMemo, +} from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { lineSolid, moreVertical, plus } from '@wordpress/icons'; import { @@ -106,15 +113,26 @@ function ColorPickerPopover< T extends Color | Gradient >( { isGradient, element, onChange, + popoverProps: receivedPopoverProps, onClose = () => {}, }: ColorPickerPopoverProps< T > ) { + const popoverProps: ColorPickerPopoverProps< T >[ 'popoverProps' ] = + useMemo( + () => ( { + shift: true, + offset: 20, + placement: 'left-start', + ...receivedPopoverProps, + className: classnames( + 'components-palette-edit__popover', + receivedPopoverProps?.className + ), + } ), + [ receivedPopoverProps ] + ); + return ( - + { ! isGradient && ( ( { onStartEditing, onRemove, onStopEditing, + popoverProps: receivedPopoverProps, slugPrefix, isGradient, }: OptionProps< T > ) { const focusOutsideProps = useFocusOutside( onStopEditing ); const value = isGradient ? element.gradient : element.color; + // Use internal state instead of a ref to make sure that the component + // re-renders when the popover's anchor updates. + const [ popoverAnchor, setPopoverAnchor ] = useState( null ); + const popoverProps = useMemo( + () => ( { + ...receivedPopoverProps, + // Use the custom palette color item as the popover anchor. + anchor: popoverAnchor, + } ), + [ popoverAnchor, receivedPopoverProps ] + ); + return ( ( { isGradient={ isGradient } onChange={ onChange } element={ element } + popoverProps={ popoverProps } /> ) } @@ -244,6 +277,7 @@ function PaletteEditListView< T extends Color | Gradient >( { canOnlyChangeValues, slugPrefix, isGradient, + popoverProps, }: PaletteEditListViewProps< T > ) { // When unmounting the component if there are empty elements (the user did not complete the insertion) clean them. const elementsReference = useRef< typeof elements >(); @@ -317,6 +351,7 @@ function PaletteEditListView< T extends Color | Gradient >( { } } } slugPrefix={ slugPrefix } + popoverProps={ popoverProps } /> ) ) } @@ -356,6 +391,7 @@ export function PaletteEdit( { canOnlyChangeValues, canReset, slugPrefix = '', + popoverProps, }: PaletteEditProps ) { const isGradient = !! gradients; const elements = isGradient ? gradients : colors; @@ -541,6 +577,7 @@ export function PaletteEdit( { setEditingElement={ setEditingElement } slugPrefix={ slugPrefix } isGradient={ isGradient } + popoverProps={ popoverProps } /> ) } { ! isEditing && editingElement !== null && ( @@ -568,6 +605,7 @@ export function PaletteEdit( { ); } } element={ elements[ editingElement ?? -1 ] } + popoverProps={ popoverProps } /> ) } { ! isEditing && diff --git a/packages/components/src/palette-edit/stories/index.tsx b/packages/components/src/palette-edit/stories/index.tsx index 5fd0d45592a82..5739964fbb276 100644 --- a/packages/components/src/palette-edit/stories/index.tsx +++ b/packages/components/src/palette-edit/stories/index.tsx @@ -59,6 +59,10 @@ Default.args = { ], paletteLabel: 'Colors', emptyMessage: 'Colors are empty', + popoverProps: { + placement: 'bottom-start', + offset: 8, + }, }; export const Gradients = Template.bind( {} ); diff --git a/packages/components/src/palette-edit/types.ts b/packages/components/src/palette-edit/types.ts index 5513b94553778..1cc0611628c2f 100644 --- a/packages/components/src/palette-edit/types.ts +++ b/packages/components/src/palette-edit/types.ts @@ -6,6 +6,7 @@ import type { Key, MouseEventHandler } from 'react'; /** * Internal dependencies */ +import type Popover from '../popover'; import type { HeadingSize } from '../heading/types'; export type Color = { @@ -58,6 +59,13 @@ export type BasePaletteEdit = { * @default '' */ slugPrefix?: string; + /** + * Props to pass through to the underlying Popover component. + */ + popoverProps?: Omit< + React.ComponentPropsWithoutRef< typeof Popover >, + 'children' + >; }; type PaletteEditColors = { @@ -94,6 +102,7 @@ export type ColorPickerPopoverProps< T extends Color | Gradient > = { onChange: ( newElement: T ) => void; isGradient?: T extends Gradient ? true : false; onClose?: () => void; + popoverProps?: PaletteEditProps[ 'popoverProps' ]; }; export type NameInputProps = { @@ -112,6 +121,7 @@ export type OptionProps< T extends Color | Gradient > = { onRemove: MouseEventHandler< HTMLButtonElement >; onStartEditing: () => void; onStopEditing: () => void; + popoverProps?: PaletteEditProps[ 'popoverProps' ]; slugPrefix: string; }; @@ -121,6 +131,7 @@ export type PaletteEditListViewProps< T extends Color | Gradient > = { isGradient: T extends Gradient ? true : false; canOnlyChangeValues: PaletteEditProps[ 'canOnlyChangeValues' ]; editingElement?: EditingElement; + popoverProps?: PaletteEditProps[ 'popoverProps' ]; setEditingElement: ( newEditingElement?: EditingElement ) => void; slugPrefix: string; }; diff --git a/packages/components/src/sandbox/index.native.js b/packages/components/src/sandbox/index.native.js index 2cbfe9bf1a9ba..0b1b0eb88f2de 100644 --- a/packages/components/src/sandbox/index.native.js +++ b/packages/components/src/sandbox/index.native.js @@ -186,6 +186,8 @@ const Sandbox = forwardRef( function Sandbox( url, onWindowEvents = {}, viewportProps = '', + onLoadEnd = () => {}, + testID, }, ref ) { @@ -372,6 +374,8 @@ const Sandbox = forwardRef( function Sandbox( showsHorizontalScrollIndicator={ false } showsVerticalScrollIndicator={ false } mediaPlaybackRequiresUserAction={ false } + onLoadEnd={ onLoadEnd } + testID={ testID } /> ); } ); diff --git a/packages/components/src/slot-fill/bubbles-virtually/fill.js b/packages/components/src/slot-fill/bubbles-virtually/fill.js index d13359eed7fb5..c86f3d0765fab 100644 --- a/packages/components/src/slot-fill/bubbles-virtually/fill.js +++ b/packages/components/src/slot-fill/bubbles-virtually/fill.js @@ -30,7 +30,8 @@ function useForceUpdate() { export default function Fill( { name, children } ) { const { registerFill, unregisterFill, ...slot } = useSlot( name ); - const ref = useRef( { rerender: useForceUpdate() } ); + const rerender = useForceUpdate(); + const ref = useRef( { rerender } ); useEffect( () => { // We register fills so we can keep track of their existence. diff --git a/packages/components/src/slot-fill/bubbles-virtually/slot-fill-provider.js b/packages/components/src/slot-fill/bubbles-virtually/slot-fill-provider.js index 47c31f1906cfa..9a00a90a7c835 100644 --- a/packages/components/src/slot-fill/bubbles-virtually/slot-fill-provider.js +++ b/packages/components/src/slot-fill/bubbles-virtually/slot-fill-provider.js @@ -8,7 +8,7 @@ import { proxyMap } from 'valtio/utils'; /** * WordPress dependencies */ -import { useMemo, useCallback, useRef } from '@wordpress/element'; +import { useState } from '@wordpress/element'; import isShallowEqual from '@wordpress/is-shallow-equal'; /** @@ -16,13 +16,13 @@ import isShallowEqual from '@wordpress/is-shallow-equal'; */ import SlotFillContext from './slot-fill-context'; -function useSlotRegistry() { - const slots = useRef( proxyMap() ); - const fills = useRef( proxyMap() ); +function createSlotRegistry() { + const slots = proxyMap(); + const fills = proxyMap(); - const registerSlot = useCallback( ( name, ref, fillProps ) => { - const slot = slots.current.get( name ) || {}; - slots.current.set( + function registerSlot( name, ref, fillProps ) { + const slot = slots.get( name ) || {}; + slots.set( name, valRef( { ...slot, @@ -30,77 +30,63 @@ function useSlotRegistry() { fillProps: fillProps || slot.fillProps || {}, } ) ); - }, [] ); + } - const unregisterSlot = useCallback( ( name, ref ) => { + function unregisterSlot( name, ref ) { // Make sure we're not unregistering a slot registered by another element // See https://github.com/WordPress/gutenberg/pull/19242#issuecomment-590295412 - if ( slots.current.get( name )?.ref === ref ) { - slots.current.delete( name ); + if ( slots.get( name )?.ref === ref ) { + slots.delete( name ); } - }, [] ); + } - const updateSlot = useCallback( ( name, fillProps ) => { - const slot = slots.current.get( name ); + function updateSlot( name, fillProps ) { + const slot = slots.get( name ); if ( ! slot ) { return; } - if ( ! isShallowEqual( slot.fillProps, fillProps ) ) { - slot.fillProps = fillProps; - const slotFills = fills.current.get( name ); - if ( slotFills ) { - // Force update fills. - slotFills.map( ( fill ) => fill.current.rerender() ); - } + if ( isShallowEqual( slot.fillProps, fillProps ) ) { + return; } - }, [] ); - const registerFill = useCallback( ( name, ref ) => { - fills.current.set( - name, - valRef( [ ...( fills.current.get( name ) || [] ), ref ] ) - ); - }, [] ); + slot.fillProps = fillProps; + const slotFills = fills.get( name ); + if ( slotFills ) { + // Force update fills. + slotFills.map( ( fill ) => fill.current.rerender() ); + } + } + + function registerFill( name, ref ) { + fills.set( name, valRef( [ ...( fills.get( name ) || [] ), ref ] ) ); + } - const unregisterFill = useCallback( ( name, ref ) => { - if ( fills.current.get( name ) ) { - fills.current.set( - name, - valRef( - fills.current - .get( name ) - .filter( ( fillRef ) => fillRef !== ref ) - ) - ); + function unregisterFill( name, ref ) { + const fillsForName = fills.get( name ); + if ( ! fillsForName ) { + return; } - }, [] ); - // Memoizing the return value so it can be directly passed to Provider value - const registry = useMemo( - () => ( { - slots: slots.current, - fills: fills.current, - registerSlot, - updateSlot, - unregisterSlot, - registerFill, - unregisterFill, - } ), - [ - registerSlot, - updateSlot, - unregisterSlot, - registerFill, - unregisterFill, - ] - ); + fills.set( + name, + valRef( fillsForName.filter( ( fillRef ) => fillRef !== ref ) ) + ); + } - return registry; + return { + slots, + fills, + registerSlot, + updateSlot, + unregisterSlot, + registerFill, + unregisterFill, + }; } export default function SlotFillProvider( { children } ) { - const registry = useSlotRegistry(); + const [ registry ] = useState( createSlotRegistry ); return ( { children } diff --git a/packages/components/src/slot-fill/bubbles-virtually/use-slot.js b/packages/components/src/slot-fill/bubbles-virtually/use-slot.js index 8c5a0182ea074..20b00c2cdf264 100644 --- a/packages/components/src/slot-fill/bubbles-virtually/use-slot.js +++ b/packages/components/src/slot-fill/bubbles-virtually/use-slot.js @@ -7,7 +7,7 @@ import { useSnapshot } from 'valtio'; /** * WordPress dependencies */ -import { useCallback, useContext } from '@wordpress/element'; +import { useMemo, useContext } from '@wordpress/element'; /** * Internal dependencies @@ -15,52 +15,25 @@ import { useCallback, useContext } from '@wordpress/element'; import SlotFillContext from './slot-fill-context'; export default function useSlot( name ) { - const { - updateSlot: registryUpdateSlot, - unregisterSlot: registryUnregisterSlot, - registerFill: registryRegisterFill, - unregisterFill: registryUnregisterFill, - ...registry - } = useContext( SlotFillContext ); + const registry = useContext( SlotFillContext ); const slots = useSnapshot( registry.slots, { sync: true } ); - // The important bit here is that this call ensures - // the hook only causes a re-render if the slot - // with the given name change, not any other slot. + // The important bit here is that the `useSnapshot` call ensures that the + // hook only causes a re-render if the slot with the given name changes, + // not any other slot. const slot = slots.get( name ); - const updateSlot = useCallback( - ( fillProps ) => { - registryUpdateSlot( name, fillProps ); - }, - [ name, registryUpdateSlot ] - ); - - const unregisterSlot = useCallback( - ( slotRef ) => { - registryUnregisterSlot( name, slotRef ); - }, - [ name, registryUnregisterSlot ] - ); - - const registerFill = useCallback( - ( fillRef ) => { - registryRegisterFill( name, fillRef ); - }, - [ name, registryRegisterFill ] - ); - - const unregisterFill = useCallback( - ( fillRef ) => { - registryUnregisterFill( name, fillRef ); - }, - [ name, registryUnregisterFill ] + const api = useMemo( + () => ( { + updateSlot: ( fillProps ) => registry.updateSlot( name, fillProps ), + unregisterSlot: ( ref ) => registry.unregisterSlot( name, ref ), + registerFill: ( ref ) => registry.registerFill( name, ref ), + unregisterFill: ( ref ) => registry.unregisterFill( name, ref ), + } ), + [ name, registry ] ); return { ...slot, - updateSlot, - unregisterSlot, - registerFill, - unregisterFill, + ...api, }; } diff --git a/packages/components/src/slot-fill/fill.js b/packages/components/src/slot-fill/fill.js index 745439a275dd1..e7ff943df07bf 100644 --- a/packages/components/src/slot-fill/fill.js +++ b/packages/components/src/slot-fill/fill.js @@ -3,7 +3,7 @@ /** * WordPress dependencies */ -import { createPortal, useLayoutEffect, useRef } from '@wordpress/element'; +import { useContext, useLayoutEffect, useRef } from '@wordpress/element'; /** * Internal dependencies @@ -11,7 +11,8 @@ import { createPortal, useLayoutEffect, useRef } from '@wordpress/element'; import SlotFillContext from './context'; import useSlot from './use-slot'; -function FillComponent( { name, children, registerFill, unregisterFill } ) { +export default function Fill( { name, children } ) { + const { registerFill, unregisterFill } = useContext( SlotFillContext ); const slot = useSlot( name ); const ref = useRef( { @@ -51,28 +52,5 @@ function FillComponent( { name, children, registerFill, unregisterFill } ) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [ name ] ); - if ( ! slot || ! slot.node ) { - return null; - } - - // If a function is passed as a child, provide it with the fillProps. - if ( typeof children === 'function' ) { - children = children( slot.props.fillProps ); - } - - return createPortal( children, slot.node ); + return null; } - -const Fill = ( props ) => ( - - { ( { registerFill, unregisterFill } ) => ( - - ) } - -); - -export default Fill; diff --git a/packages/components/src/slot-fill/index.js b/packages/components/src/slot-fill/index.js index 24e68aa887704..8deaa180492a7 100644 --- a/packages/components/src/slot-fill/index.js +++ b/packages/components/src/slot-fill/index.js @@ -13,7 +13,7 @@ import BubblesVirtuallyFill from './bubbles-virtually/fill'; import BubblesVirtuallySlot from './bubbles-virtually/slot'; import BubblesVirtuallySlotFillProvider from './bubbles-virtually/slot-fill-provider'; import SlotFillProvider from './provider'; -import useSlot from './bubbles-virtually/use-slot'; +export { default as useSlot } from './bubbles-virtually/use-slot'; export { default as useSlotFills } from './bubbles-virtually/use-slot-fills'; export function Fill( props ) { @@ -65,5 +65,3 @@ export const createPrivateSlotFill = ( name ) => { return { privateKey, ...privateSlotFill }; }; - -export { useSlot }; diff --git a/packages/components/src/slot-fill/provider.js b/packages/components/src/slot-fill/provider.js index 37b4560140516..94c83c3c35011 100644 --- a/packages/components/src/slot-fill/provider.js +++ b/packages/components/src/slot-fill/provider.js @@ -19,7 +19,6 @@ export default class SlotFillProvider extends Component { this.unregisterFill = this.unregisterFill.bind( this ); this.getSlot = this.getSlot.bind( this ); this.getFills = this.getFills.bind( this ); - this.hasFills = this.hasFills.bind( this ); this.subscribe = this.subscribe.bind( this ); this.slots = {}; @@ -32,7 +31,6 @@ export default class SlotFillProvider extends Component { unregisterFill: this.unregisterFill, getSlot: this.getSlot, getFills: this.getFills, - hasFills: this.hasFills, subscribe: this.subscribe, }; } @@ -91,10 +89,6 @@ export default class SlotFillProvider extends Component { return this.fills[ name ]; } - hasFills( name ) { - return this.fills[ name ] && !! this.fills[ name ].length; - } - forceUpdateSlot( name ) { const slot = this.getSlot( name ); diff --git a/packages/components/src/slot-fill/slot.js b/packages/components/src/slot-fill/slot.js index a314047600bd3..a960647c3ab64 100644 --- a/packages/components/src/slot-fill/slot.js +++ b/packages/components/src/slot-fill/slot.js @@ -29,7 +29,6 @@ class SlotComponent extends Component { super( ...arguments ); this.isUnmounted = false; - this.bindNode = this.bindNode.bind( this ); } componentDidMount() { @@ -53,10 +52,6 @@ class SlotComponent extends Component { } } - bindNode( node ) { - this.node = node; - } - forceUpdate() { if ( this.isUnmounted ) { return; diff --git a/packages/components/src/style.scss b/packages/components/src/style.scss index 1c19336552aba..02dce83f66dcc 100644 --- a/packages/components/src/style.scss +++ b/packages/components/src/style.scss @@ -1,3 +1,9 @@ +// Include the default WP Components color variables. +// TODO: Remove this once all admin-scheme variables are accounted for in the wp-components fallback values. +:root { + @include admin-scheme(#3858e9); +} + // Variables @import "./utils/theme-variables.scss"; diff --git a/packages/components/src/tab-panel/index.tsx b/packages/components/src/tab-panel/index.tsx index b2af12b90c85f..4ed4dc76da22d 100644 --- a/packages/components/src/tab-panel/index.tsx +++ b/packages/components/src/tab-panel/index.tsx @@ -101,7 +101,7 @@ export function TabPanel( { // to show the `tab-panel` associated with the clicked tab. const activateTabAutomatically = ( _childIndex: number, - child: HTMLButtonElement + child: HTMLElement ) => { child.click(); }; diff --git a/packages/components/src/theme/color-algorithms.ts b/packages/components/src/theme/color-algorithms.ts index c260b4ac5704d..b2f69e1db2128 100644 --- a/packages/components/src/theme/color-algorithms.ts +++ b/packages/components/src/theme/color-algorithms.ts @@ -48,7 +48,7 @@ export function checkContrasts( outputs: ThemeOutputValues[ 'colors' ] ) { const background = inputs.background || COLORS.white; - const accent = inputs.accent || '#007cba'; + const accent = inputs.accent || '#3858e9'; const foreground = outputs.foreground || COLORS.gray[ 900 ]; const gray = outputs.gray || COLORS.gray; diff --git a/packages/components/src/theme/stories/index.tsx b/packages/components/src/theme/stories/index.tsx index 923b4fff4cdac..c9a3f495e4835 100644 --- a/packages/components/src/theme/stories/index.tsx +++ b/packages/components/src/theme/stories/index.tsx @@ -103,7 +103,7 @@ export const ColorScheme: ComponentStory< typeof Theme > = ( { ); }; ColorScheme.args = { - accent: '#007cba', + accent: '#3858e9', background: '#fff', }; ColorScheme.argTypes = { diff --git a/packages/components/src/theme/test/color-algorithms.ts b/packages/components/src/theme/test/color-algorithms.ts index f901c8ad83a87..ff04c0b9511bf 100644 --- a/packages/components/src/theme/test/color-algorithms.ts +++ b/packages/components/src/theme/test/color-algorithms.ts @@ -53,9 +53,9 @@ describe( 'Theme color algorithms', () => { 'wp.components.Theme: The background color ("#000") does not have sufficient contrast against the accent color ("#111").' ); - generateThemeVariables( { background: '#eee' } ); + generateThemeVariables( { background: '#1a1a1a' } ); expect( console ).toHaveWarnedWith( - 'wp.components.Theme: The background color ("#eee") does not have sufficient contrast against the accent color ("#007cba").' + 'wp.components.Theme: The background color ("#1a1a1a") does not have sufficient contrast against the accent color ("#3858e9").' ); } ); diff --git a/packages/components/src/toggle-group-control/test/__snapshots__/index.tsx.snap b/packages/components/src/toggle-group-control/test/__snapshots__/index.tsx.snap index be5b20d593954..d9f173a571e48 100644 --- a/packages/components/src/toggle-group-control/test/__snapshots__/index.tsx.snap +++ b/packages/components/src/toggle-group-control/test/__snapshots__/index.tsx.snap @@ -65,8 +65,8 @@ exports[`ToggleGroupControl should render correctly with icons 1`] = ` } .emotion-8:focus-within { - border-color: var(--wp-components-color-accent-darker-10, var(--wp-admin-theme-color-darker-10, #006ba1)); - box-shadow: 0 0 0 0.5px var(--wp-components-color-accent, var(--wp-admin-theme-color, #007cba)); + border-color: var(--wp-components-color-accent-darker-10, var(--wp-admin-theme-color-darker-10, #2145e6)); + box-shadow: 0 0 0 0.5px var(--wp-components-color-accent, var(--wp-admin-theme-color, #3858e9)); outline: none; z-index: 1; } @@ -400,8 +400,8 @@ exports[`ToggleGroupControl should render correctly with text options 1`] = ` } .emotion-8:focus-within { - border-color: var(--wp-components-color-accent-darker-10, var(--wp-admin-theme-color-darker-10, #006ba1)); - box-shadow: 0 0 0 0.5px var(--wp-components-color-accent, var(--wp-admin-theme-color, #007cba)); + border-color: var(--wp-components-color-accent-darker-10, var(--wp-admin-theme-color-darker-10, #2145e6)); + box-shadow: 0 0 0 0.5px var(--wp-components-color-accent, var(--wp-admin-theme-color, #3858e9)); outline: none; z-index: 1; } diff --git a/packages/components/src/toolbar/stories/index.tsx b/packages/components/src/toolbar/stories/index.tsx index 73064e580a24c..fd0fb9587347d 100644 --- a/packages/components/src/toolbar/stories/index.tsx +++ b/packages/components/src/toolbar/stories/index.tsx @@ -83,30 +83,32 @@ Default.args = { { - // @ts-expect-error TODO: Remove when ToolbarItem/DropdownMenu is typed - ( toggleProps ) => ( - - ) + // @ts-expect-error TODO: Remove when DropdownMenu is typed + ( toggleProps ) => { + return ( + + ); + } } diff --git a/packages/components/src/toolbar/toolbar-button/index.tsx b/packages/components/src/toolbar/toolbar-button/index.tsx index 00c5c364e94ce..578867313a429 100644 --- a/packages/components/src/toolbar/toolbar-button/index.tsx +++ b/packages/components/src/toolbar/toolbar-button/index.tsx @@ -82,19 +82,16 @@ function UnforwardedToolbarButton( { ...props } ref={ ref } > - { - // @ts-expect-error - ( toolbarItemProps ) => ( - - ) - } + { ( toolbarItemProps ) => ( + + ) } ); } diff --git a/packages/components/src/toolbar/toolbar-item/index.js b/packages/components/src/toolbar/toolbar-item/index.tsx similarity index 80% rename from packages/components/src/toolbar/toolbar-item/index.js rename to packages/components/src/toolbar/toolbar-item/index.tsx index 595a14dbf32a8..6fc9bf0100967 100644 --- a/packages/components/src/toolbar/toolbar-item/index.js +++ b/packages/components/src/toolbar/toolbar-item/index.tsx @@ -1,9 +1,8 @@ -// @ts-nocheck - /** * External dependencies */ import { ToolbarItem as BaseToolbarItem } from 'reakit/Toolbar'; +import type { ForwardedRef } from 'react'; /** * WordPress dependencies @@ -16,7 +15,14 @@ import warning from '@wordpress/warning'; */ import ToolbarContext from '../toolbar-context'; -function ToolbarItem( { children, as: Component, ...props }, ref ) { +function ToolbarItem( + { + children, + as: Component, + ...props + }: React.ComponentPropsWithoutRef< typeof BaseToolbarItem >, + ref: ForwardedRef< any > +) { const accessibleToolbarState = useContext( ToolbarContext ); if ( typeof children !== 'function' && ! Component ) { @@ -33,6 +39,9 @@ function ToolbarItem( { children, as: Component, ...props }, ref ) { if ( Component ) { return { children }; } + if ( typeof children !== 'function' ) { + return null; + } return children( allProps ); } diff --git a/packages/components/src/tree-grid/README.md b/packages/components/src/tree-grid/README.md index 6ba8dcf45c85f..f6de1bab2ddcb 100644 --- a/packages/components/src/tree-grid/README.md +++ b/packages/components/src/tree-grid/README.md @@ -127,8 +127,26 @@ An integer value that represents the total number of items in the set, at this s An optional value that designates whether a row is expanded or collapsed. Currently this value only sets the correct aria-expanded property on a row, it has no other built-in behavior. +If there is a need to implement `aria-expanded` elsewhere in the row, cell, or element within a cell, you may pass `isExpanded={ undefined }`. In order for keyboard navigation to continue working, add the `data-expanded` attribute with either `true`/`false`. This allows the `TreeGrid` component to still manage keyboard interactions while allowing the `aria-expanded` attribute to be placed elsewhere. See the example below. + - Required: No +```jsx +function TreeMenu() { + return ( + + + + { ( props ) => ( + + ) } + + + + ); +} +``` + ### TreeGridCell #### Props diff --git a/packages/components/src/tree-grid/types.ts b/packages/components/src/tree-grid/types.ts index 47c2739f43dc7..31d04882d1a81 100644 --- a/packages/components/src/tree-grid/types.ts +++ b/packages/components/src/tree-grid/types.ts @@ -22,6 +22,13 @@ export type TreeGridRowProps = { * An optional value that designates whether a row is expanded or collapsed. * Currently this value only sets the correct aria-expanded property on a row, * it has no other built-in behavior. + * + * If there is a need to implement `aria-expanded` elsewhere in the row, cell, + * or element within a cell, you may pass `isExpanded={ undefined }`. + * In order for keyboard navigation to continue working, add the + * `data-expanded` attribute with either `true`/`false`. This allows the + * `TreeGrid` component to still manage keyboard interactions while allowing + * the `aria-expanded` attribute to be placed elsewhere. */ isExpanded?: boolean; }; diff --git a/packages/components/src/utils/colors-values.js b/packages/components/src/utils/colors-values.js index 06dc62b881c65..556455b09ce33 100644 --- a/packages/components/src/utils/colors-values.js +++ b/packages/components/src/utils/colors-values.js @@ -29,11 +29,11 @@ const ALERT = { green: '#4ab866', }; -// Matches @wordpress/base-styles +// Matches the Modern admin scheme in @wordpress/base-styles const ADMIN = { - theme: 'var(--wp-components-color-accent, var(--wp-admin-theme-color, #007cba))', + theme: 'var(--wp-components-color-accent, var(--wp-admin-theme-color, #3858e9))', themeDark10: - 'var(--wp-components-color-accent-darker-10, var(--wp-admin-theme-color-darker-10, #006ba1))', + 'var(--wp-components-color-accent-darker-10, var(--wp-admin-theme-color-darker-10, #2145e6))', }; const UI = { diff --git a/packages/components/src/utils/theme-variables.scss b/packages/components/src/utils/theme-variables.scss index 0b664b8cd72e3..14e44f3ddc7d2 100644 --- a/packages/components/src/utils/theme-variables.scss +++ b/packages/components/src/utils/theme-variables.scss @@ -2,10 +2,10 @@ // // If the `--wp-components-color-accent` variable is not defined, fallback to // `--wp-admin-theme-color`. If the `--wp-admin-theme-color` variable is not -// defined, fallback to the default theme color (WP blue). -$components-color-accent: var(--wp-components-color-accent, var(--wp-admin-theme-color, #007cba)); -$components-color-accent-darker-10: var(--wp-components-color-accent-darker-10, var(--wp-admin-theme-color-darker-10, #006ba1)); -$components-color-accent-darker-20: var(--wp-components-color-accent-darker-20, var(--wp-admin-theme-color-darker-20, #005a87)); +// defined, fallback to the default theme color (WP blueberry). +$components-color-accent: var(--wp-components-color-accent, var(--wp-admin-theme-color, #3858e9)); +$components-color-accent-darker-10: var(--wp-components-color-accent-darker-10, var(--wp-admin-theme-color-darker-10, #2145e6)); +$components-color-accent-darker-20: var(--wp-components-color-accent-darker-20, var(--wp-admin-theme-color-darker-20, #183ad6)); // Used when placing text on the accent color. $components-color-accent-inverted: var(--wp-components-color-accent-inverted, $white); diff --git a/packages/components/src/utils/use-deprecated-props.ts b/packages/components/src/utils/use-deprecated-props.ts new file mode 100644 index 0000000000000..ee5dcc67a7530 --- /dev/null +++ b/packages/components/src/utils/use-deprecated-props.ts @@ -0,0 +1,29 @@ +/** + * WordPress dependencies + */ +import deprecated from '@wordpress/deprecated'; + +export function useDeprecated36pxDefaultSizeProp< + P extends Record< string, any > & { + __next36pxDefaultSize?: boolean; + __next40pxDefaultSize?: boolean; + } +>( + props: P, + /** The component identifier in dot notation, e.g. `wp.components.ComponentName`. */ + componentIdentifier: string +) { + const { __next36pxDefaultSize, __next40pxDefaultSize, ...otherProps } = + props; + if ( typeof __next36pxDefaultSize !== 'undefined' ) { + deprecated( '`__next36pxDefaultSize` prop in ' + componentIdentifier, { + alternative: '`__next40pxDefaultSize`', + since: '6.3', + } ); + } + + return { + ...otherProps, + __next40pxDefaultSize: __next40pxDefaultSize ?? __next36pxDefaultSize, + }; +} diff --git a/packages/components/tsconfig.json b/packages/components/tsconfig.json index f7c78fd1859da..be473ffcc877d 100644 --- a/packages/components/tsconfig.json +++ b/packages/components/tsconfig.json @@ -34,6 +34,7 @@ { "path": "../primitives" }, { "path": "../private-apis" }, { "path": "../react-i18n" }, + { "path": "../rich-text" }, { "path": "../warning" } ], "include": [ "src/**/*" ], diff --git a/packages/core-commands/.npmrc b/packages/core-commands/.npmrc new file mode 100644 index 0000000000000..43c97e719a5a8 --- /dev/null +++ b/packages/core-commands/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/core-commands/CHANGELOG.md b/packages/core-commands/CHANGELOG.md new file mode 100644 index 0000000000000..6349399989da3 --- /dev/null +++ b/packages/core-commands/CHANGELOG.md @@ -0,0 +1,5 @@ + + +## Unreleased + +Initial release. \ No newline at end of file diff --git a/packages/core-commands/README.md b/packages/core-commands/README.md new file mode 100644 index 0000000000000..a7f8073c1f1de --- /dev/null +++ b/packages/core-commands/README.md @@ -0,0 +1,31 @@ +# Core Commands + +This package includes a list of reusable WordPress Admin commands. These commands can be used in multiple WP Admin pages. + +## Installation + +Install the module + +```bash +npm install @wordpress/core-commands --save +``` + +_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for such language features and APIs, you should include [the polyfill shipped in `@wordpress/babel-preset-default`](https://github.com/WordPress/gutenberg/tree/HEAD/packages/babel-preset-default#polyfill) in your code._ + +## API + + + +### privateApis + +Undocumented declaration. + + + +## Contributing to this package + +This is an individual package that's part of the Gutenberg project. The project is organized as a monorepo. It's made up of multiple self-contained software packages, each with a specific purpose. The packages in this monorepo are published to [npm](https://www.npmjs.com/) and used by [WordPress](https://make.wordpress.org/core/) as well as other software projects. + +To find out more about contributing to this package or Gutenberg as a whole, please read the project's main [contributor guide](https://github.com/WordPress/gutenberg/tree/HEAD/CONTRIBUTING.md). + +

Code is Poetry.

diff --git a/packages/core-commands/package.json b/packages/core-commands/package.json new file mode 100644 index 0000000000000..8bb52848b7916 --- /dev/null +++ b/packages/core-commands/package.json @@ -0,0 +1,46 @@ +{ + "name": "@wordpress/core-commands", + "version": "0.1.0", + "description": "WordPress core reusable commands.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "gutenberg", + "commands" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/HEAD/packages/core-commands/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/core-commands" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "engines": { + "node": ">=12" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "sideEffects": false, + "dependencies": { + "@babel/runtime": "^7.16.0", + "@wordpress/commands": "file:../commands", + "@wordpress/core-data": "file:../core-data", + "@wordpress/data": "file:../data", + "@wordpress/element": "file:../element", + "@wordpress/i18n": "file:../i18n", + "@wordpress/icons": "file:../icons", + "@wordpress/private-apis": "file:../private-apis", + "@wordpress/router": "file:../router", + "@wordpress/url": "file:../url" + }, + "peerDependencies": { + "react": "^18.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/core-commands/src/add-post-type-commands.js b/packages/core-commands/src/add-post-type-commands.js new file mode 100644 index 0000000000000..afd783c7c0cc3 --- /dev/null +++ b/packages/core-commands/src/add-post-type-commands.js @@ -0,0 +1,32 @@ +/** + * WordPress dependencies + */ +import { privateApis } from '@wordpress/commands'; +import { __ } from '@wordpress/i18n'; +import { plus } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import { unlock } from './lock-unlock'; + +const { useCommand } = unlock( privateApis ); + +export function useAddPostTypeCommands() { + useCommand( { + name: 'add new post', + label: __( 'Add new post' ), + icon: plus, + callback: () => { + document.location.href = 'post-new.php'; + }, + } ); + useCommand( { + name: 'add new page', + label: __( 'Add new page' ), + icon: plus, + callback: () => { + document.location.href = 'post-new.php?post_type=page'; + }, + } ); +} diff --git a/packages/core-commands/src/index.js b/packages/core-commands/src/index.js new file mode 100644 index 0000000000000..94878a556278a --- /dev/null +++ b/packages/core-commands/src/index.js @@ -0,0 +1 @@ +export { privateApis } from './private-apis'; diff --git a/packages/core-commands/src/lock-unlock.js b/packages/core-commands/src/lock-unlock.js new file mode 100644 index 0000000000000..24973274f1897 --- /dev/null +++ b/packages/core-commands/src/lock-unlock.js @@ -0,0 +1,10 @@ +/** + * WordPress dependencies + */ +import { __dangerousOptInToUnstableAPIsOnlyForCoreModules } from '@wordpress/private-apis'; + +export const { lock, unlock } = + __dangerousOptInToUnstableAPIsOnlyForCoreModules( + 'I know using unstable features means my plugin or theme will inevitably break on the next WordPress release.', + '@wordpress/core-commands' + ); diff --git a/packages/core-commands/src/private-apis.js b/packages/core-commands/src/private-apis.js new file mode 100644 index 0000000000000..b0e0cd87040f6 --- /dev/null +++ b/packages/core-commands/src/private-apis.js @@ -0,0 +1,16 @@ +/** + * Internal dependencies + */ +import { useAddPostTypeCommands } from './add-post-type-commands'; +import { useSiteEditorNavigationCommands } from './site-editor-navigation-commands'; +import { lock } from './lock-unlock'; + +function useCommands() { + useAddPostTypeCommands(); + useSiteEditorNavigationCommands(); +} + +export const privateApis = {}; +lock( privateApis, { + useCommands, +} ); diff --git a/packages/edit-site/src/hooks/commands/use-navigation-commands.js b/packages/core-commands/src/site-editor-navigation-commands.js similarity index 72% rename from packages/edit-site/src/hooks/commands/use-navigation-commands.js rename to packages/core-commands/src/site-editor-navigation-commands.js index fbcd9c7fb9af6..f6524e3ba0211 100644 --- a/packages/edit-site/src/hooks/commands/use-navigation-commands.js +++ b/packages/core-commands/src/site-editor-navigation-commands.js @@ -7,15 +7,16 @@ import { useMemo } from '@wordpress/element'; import { useSelect } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; import { post, page, layout, symbolFilled } from '@wordpress/icons'; +import { privateApis as routerPrivateApis } from '@wordpress/router'; +import { getQueryArg, addQueryArgs, getPath } from '@wordpress/url'; /** * Internal dependencies */ -import { store as editSiteStore } from '../../store'; -import { unlock } from '../../private-apis'; -import { useHistory } from '../../components/routes'; +import { unlock } from './lock-unlock'; const { useCommandLoader } = unlock( privateApis ); +const { useHistory } = unlock( routerPrivateApis ); const icons = { post, @@ -26,12 +27,12 @@ const icons = { const getNavigationCommandLoaderPerPostType = ( postType ) => function useNavigationCommandLoader( { search } ) { + const history = useHistory(); const supportsSearch = ! [ 'wp_template', 'wp_template_part' ].includes( postType ); const deps = supportsSearch ? [ search ] : []; - const history = useHistory(); - const { canvasMode, records, isLoading } = useSelect( ( select ) => { + const { records, isLoading } = useSelect( ( select ) => { const { getEntityRecords } = select( coreStore ); const query = supportsSearch ? { @@ -48,12 +49,20 @@ const getNavigationCommandLoaderPerPostType = ( postType ) => 'getEntityRecords', [ 'postType', postType, query ] ), - canvasMode: unlock( select( editSiteStore ) ).getCanvasMode(), + // We're using the string literal to check whether we're in the site editor. + /* eslint-disable-next-line @wordpress/data-no-store-string-literals */ + isSiteEditor: !! select( 'edit-site' ), }; }, deps ); const commands = useMemo( () => { return ( records ?? [] ).slice( 0, 10 ).map( ( record ) => { + const isSiteEditor = getPath( window.location.href )?.includes( + 'site-editor.php' + ); + const extraArgs = isSiteEditor + ? { canvas: getQueryArg( window.location.href, 'canvas' ) } + : {}; return { name: record.title?.rendered + ' ' + record.id, label: record.title?.rendered @@ -61,17 +70,25 @@ const getNavigationCommandLoaderPerPostType = ( postType ) => : __( '(no title)' ), icon: icons[ postType ], callback: ( { close } ) => { - history.push( { + const args = { postType, postId: record.id, - canvas: - canvasMode === 'edit' ? canvasMode : undefined, - } ); + ...extraArgs, + }; + const targetUrl = addQueryArgs( + 'site-editor.php', + args + ); + if ( isSiteEditor ) { + history.push( args ); + } else { + document.location = targetUrl; + } close(); }, }; } ); - }, [ records, history, canvasMode ] ); + }, [ records, history ] ); return { commands, @@ -88,7 +105,7 @@ const useTemplateNavigationCommandLoader = const useTemplatePartNavigationCommandLoader = getNavigationCommandLoaderPerPostType( 'wp_template_part' ); -export function useNavigationCommands() { +export function useSiteEditorNavigationCommands() { useCommandLoader( { name: 'core/edit-site/navigate-pages', group: __( 'Pages' ), diff --git a/packages/core-data/README.md b/packages/core-data/README.md index 642050a903f21..dddc3550e03b2 100644 --- a/packages/core-data/README.md +++ b/packages/core-data/README.md @@ -106,6 +106,18 @@ _Returns_ - `Object`: Action object. +### receiveNavigationFallbackId + +Returns an action object signalling that the fallback Navigation Menu id has been received. + +_Parameters_ + +- _fallbackId_ `integer`: the id of the fallback Navigation Menu + +_Returns_ + +- `Object`: Action object. + ### receiveThemeSupports > **Deprecated** since WP 5.9, this is not useful anymore, use the selector direclty. @@ -218,7 +230,7 @@ Returns all available authors. _Parameters_ - _state_ `State`: Data state. -- _query_ `GetRecordsHttpQuery`: Optional object of query parameters to include with request. +- _query_ `GetRecordsHttpQuery`: Optional object of query parameters to include with request. For valid query parameters see the [Users page](https://developer.wordpress.org/rest-api/reference/users/) in the REST API Handbook and see the arguments for [List Users](https://developer.wordpress.org/rest-api/reference/users/#list-users) and [Retrieve a User](https://developer.wordpress.org/rest-api/reference/users/#retrieve-a-user). _Returns_ @@ -291,6 +303,18 @@ _Returns_ - `any`: The current theme. +### getCurrentThemeGlobalStylesRevisions + +Returns the revisions of the current global styles theme. + +_Parameters_ + +- _state_ `State`: Data state. + +_Returns_ + +- `Object | null`: The current global styles. + ### getCurrentUser Returns the current user. @@ -399,7 +423,7 @@ _Parameters_ - _kind_ `string`: Entity kind. - _name_ `string`: Entity name. - _key_ `EntityRecordKey`: Record's key -- _query_ `GetRecordsHttpQuery`: Optional query. If requesting specific fields, fields must always include the ID. +- _query_ `GetRecordsHttpQuery`: Optional query. If requesting specific fields, fields must always include the ID. For valid query parameters see the [Reference](https://developer.wordpress.org/rest-api/reference/) in the REST API Handbook and select the entity kind. Then see the arguments available "Retrieve a [Entity kind]". _Returns_ @@ -446,7 +470,7 @@ _Parameters_ - _state_ `State`: State tree - _kind_ `string`: Entity kind. - _name_ `string`: Entity name. -- _query_ `GetRecordsHttpQuery`: Optional terms query. If requesting specific fields, fields must always include the ID. +- _query_ `GetRecordsHttpQuery`: Optional terms query. If requesting specific fields, fields must always include the ID. For valid query parameters see the [Reference](https://developer.wordpress.org/rest-api/reference/) in the REST API Handbook and select the entity kind. Then see the arguments available for "List [Entity kind]s". _Returns_ @@ -482,6 +506,18 @@ _Returns_ - `any`: The entity record's save error. +### getNavigationFallbackId + +Retrieve the fallback Navigation. + +_Parameters_ + +- _state_ `State`: Data state. + +_Returns_ + +- `EntityRecordKey | undefined`: The ID for the fallback Navigation post. + ### getRawEntityRecord Returns the entity's record object by key, with its attributes mapped to their raw values. @@ -589,7 +625,7 @@ _Parameters_ - _state_ `State`: State tree - _kind_ `string`: Entity kind. - _name_ `string`: Entity name. -- _query_ `GetRecordsHttpQuery`: Optional terms query. +- _query_ `GetRecordsHttpQuery`: Optional terms query. For valid query parameters see the [Reference](https://developer.wordpress.org/rest-api/reference/) in the REST API Handbook and select the entity kind. Then see the arguments available for "List [Entity kind]s". _Returns_ diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index 4c36e2505e7f3..ffae417a83cd1 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -210,6 +210,25 @@ export function receiveThemeSupports() { }; } +/** + * Returns an action object used in signalling that the theme global styles CPT post revisions have been received. + * Ignored from documentation as it's internal to the data store. + * + * @ignore + * + * @param {number} currentId The post id. + * @param {Array} revisions The global styles revisions. + * + * @return {Object} Action object. + */ +export function receiveThemeGlobalStyleRevisions( currentId, revisions ) { + return { + type: 'RECEIVE_THEME_GLOBAL_STYLE_REVISIONS', + currentId, + revisions, + }; +} + /** * Returns an action object used in signalling that the preview data for * a given URl has been received. @@ -834,3 +853,17 @@ export function receiveAutosaves( postId, autosaves ) { autosaves: Array.isArray( autosaves ) ? autosaves : [ autosaves ], }; } + +/** + * Returns an action object signalling that the fallback Navigation + * Menu id has been received. + * + * @param {integer} fallbackId the id of the fallback Navigation Menu + * @return {Object} Action object. + */ +export function receiveNavigationFallbackId( fallbackId ) { + return { + type: 'RECEIVE_NAVIGATION_FALLBACK_ID', + fallbackId, + }; +} diff --git a/packages/core-data/src/reducer.js b/packages/core-data/src/reducer.js index 6d5fefd52ddf4..f04d543919b8c 100644 --- a/packages/core-data/src/reducer.js +++ b/packages/core-data/src/reducer.js @@ -642,6 +642,35 @@ export function blockPatternCategories( state = [], action ) { return state; } +export function navigationFallbackId( state = null, action ) { + switch ( action.type ) { + case 'RECEIVE_NAVIGATION_FALLBACK_ID': + return action.fallbackId; + } + + return state; +} + +/** + * Reducer managing the theme global styles revisions. + * + * @param {Record} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Record} Updated state. + */ +export function themeGlobalStyleRevisions( state = {}, action ) { + switch ( action.type ) { + case 'RECEIVE_THEME_GLOBAL_STYLE_REVISIONS': + return { + ...state, + [ action.currentId ]: action.revisions, + }; + } + + return state; +} + export default combineReducers( { terms, users, @@ -650,6 +679,7 @@ export default combineReducers( { currentUser, themeGlobalStyleVariations, themeBaseGlobalStyles, + themeGlobalStyleRevisions, taxonomies, entities, undo, @@ -658,4 +688,5 @@ export default combineReducers( { autosaves, blockPatterns, blockPatternCategories, + navigationFallbackId, } ); diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index b33bb42e65337..6437b75997690 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -180,7 +180,7 @@ export const getEntityRecords = let records = Object.values( await apiFetch( { path } ) ); // If we request fields but the result doesn't contain the fields, - // explicitely set these fields as "undefined" + // explicitly set these fields as "undefined" // that way we consider the query "fullfilled". if ( query._fields ) { records = records.map( ( record ) => { @@ -409,15 +409,15 @@ export const getAutosave = export const __experimentalGetTemplateForLink = ( link ) => async ( { dispatch, resolveSelect } ) => { - // Ideally this should be using an apiFetch call - // We could potentially do so by adding a "filter" to the `wp_template` end point. - // Also it seems the returned object is not a regular REST API post type. let template; try { - template = await window - .fetch( addQueryArgs( link, { '_wp-find-template': true } ) ) - .then( ( res ) => res.json() ) - .then( ( { data } ) => data ); + // This is NOT calling a REST endpoint but rather ends up with a response from + // an Ajax function which has a different shape from a WP_REST_Response. + template = await apiFetch( { + url: addQueryArgs( link, { + '_wp-find-template': true, + } ), + } ).then( ( { data } ) => data ); } catch ( e ) { // For non-FSE themes, it is possible that this request returns an error. } @@ -500,6 +500,51 @@ export const __experimentalGetCurrentThemeGlobalStylesVariations = ); }; +/** + * Fetches and returns the revisions of the current global styles theme. + */ +export const getCurrentThemeGlobalStylesRevisions = + () => + async ( { resolveSelect, dispatch } ) => { + const globalStylesId = + await resolveSelect.__experimentalGetCurrentGlobalStylesId(); + const record = globalStylesId + ? await resolveSelect.getEntityRecord( + 'root', + 'globalStyles', + globalStylesId + ) + : undefined; + const revisionsURL = record?._links?.[ 'version-history' ]?.[ 0 ]?.href; + + if ( revisionsURL ) { + const resetRevisions = await apiFetch( { + url: revisionsURL, + } ); + const revisions = resetRevisions?.map( ( revision ) => + Object.fromEntries( + Object.entries( revision ).map( ( [ key, value ] ) => [ + camelCase( key ), + value, + ] ) + ) + ); + dispatch.receiveThemeGlobalStyleRevisions( + globalStylesId, + revisions + ); + } + }; + +getCurrentThemeGlobalStylesRevisions.shouldInvalidate = ( action ) => { + return ( + action.type === 'SAVE_ENTITY_RECORD_FINISH' && + action.kind === 'root' && + ! action.error && + action.name === 'globalStyles' + ); +}; + export const getBlockPatterns = () => async ( { dispatch } ) => { @@ -525,3 +570,32 @@ export const getBlockPatternCategories = } ); dispatch( { type: 'RECEIVE_BLOCK_PATTERN_CATEGORIES', categories } ); }; + +export const getNavigationFallbackId = + () => + async ( { dispatch } ) => { + const fallback = await apiFetch( { + path: addQueryArgs( '/wp-block-editor/v1/navigation-fallback', { + _embed: true, + } ), + } ); + + const record = fallback?._embedded?.self; + + dispatch.receiveNavigationFallbackId( fallback?.id ); + + if ( record ) { + dispatch.receiveEntityRecords( + 'postType', + 'wp_navigation', + record + ); + + // Resolve to avoid further network requests. + dispatch.finishResolution( 'getEntityRecord', [ + 'postType', + 'wp_navigation', + fallback?.id, + ] ); + } + }; diff --git a/packages/core-data/src/selectors.ts b/packages/core-data/src/selectors.ts index 07f3c9f48c5eb..7513d91810967 100644 --- a/packages/core-data/src/selectors.ts +++ b/packages/core-data/src/selectors.ts @@ -38,9 +38,11 @@ export interface State { entities: EntitiesState; themeBaseGlobalStyles: Record< string, Object >; themeGlobalStyleVariations: Record< string, string >; + themeGlobalStyleRevisions: Record< number, Object >; undo: UndoState; userPermissions: Record< string, boolean >; users: UserState; + navigationFallbackId: EntityRecordKey; } type EntityRecordKey = string | number; @@ -122,7 +124,7 @@ export const isRequestingEmbedPreview = createRegistrySelector( * * @param state Data state. * @param query Optional object of query parameters to - * include with request. + * include with request. For valid query parameters see the [Users page](https://developer.wordpress.org/rest-api/reference/users/) in the REST API Handbook and see the arguments for [List Users](https://developer.wordpress.org/rest-api/reference/users/#list-users) and [Retrieve a User](https://developer.wordpress.org/rest-api/reference/users/#retrieve-a-user). * @return Authors list. */ export function getAuthors( @@ -297,7 +299,7 @@ export interface GetEntityRecord { * @param name Entity name. * @param key Record's key * @param query Optional query. If requesting specific - * fields, fields must always include the ID. + * fields, fields must always include the ID. For valid query parameters see the [Reference](https://developer.wordpress.org/rest-api/reference/) in the REST API Handbook and select the entity kind. Then see the arguments available "Retrieve a [Entity kind]". * * @return Record. */ @@ -441,7 +443,7 @@ export const getRawEntityRecord = createSelector( * @param state State tree * @param kind Entity kind. * @param name Entity name. - * @param query Optional terms query. + * @param query Optional terms query. For valid query parameters see the [Reference](https://developer.wordpress.org/rest-api/reference/) in the REST API Handbook and select the entity kind. Then see the arguments available for "List [Entity kind]s". * * @return Whether entity records have been received. */ @@ -492,7 +494,7 @@ export interface GetEntityRecords { * @param kind Entity kind. * @param name Entity name. * @param query Optional terms query. If requesting specific - * fields, fields must always include the ID. + * fields, fields must always include the ID. For valid query parameters see the [Reference](https://developer.wordpress.org/rest-api/reference/) in the REST API Handbook and select the entity kind. Then see the arguments available for "List [Entity kind]s". * * @return Records. */ @@ -1234,3 +1236,35 @@ export function getBlockPatterns( state: State ): Array< any > { export function getBlockPatternCategories( state: State ): Array< any > { return state.blockPatternCategories; } + +/** + * Retrieve the fallback Navigation. + * + * @param state Data state. + * @return The ID for the fallback Navigation post. + */ +export function getNavigationFallbackId( + state: State +): EntityRecordKey | undefined { + return state.navigationFallbackId; +} + +/** + * Returns the revisions of the current global styles theme. + * + * @param state Data state. + * + * @return The current global styles. + */ +export function getCurrentThemeGlobalStylesRevisions( + state: State +): Object | null { + const currentGlobalStylesId = + __experimentalGetCurrentGlobalStylesId( state ); + + if ( ! currentGlobalStylesId ) { + return null; + } + + return state.themeGlobalStyleRevisions[ currentGlobalStylesId ]; +} diff --git a/packages/create-block-tutorial-template/block-templates/render.php.mustache b/packages/create-block-tutorial-template/block-templates/render.php.mustache index bd08be1004a07..b971a023985e5 100644 --- a/packages/create-block-tutorial-template/block-templates/render.php.mustache +++ b/packages/create-block-tutorial-template/block-templates/render.php.mustache @@ -1,4 +1,9 @@ {{#isDynamicVariant}} +

>

diff --git a/packages/create-block/lib/templates/block/render.php.mustache b/packages/create-block/lib/templates/block/render.php.mustache index 39ccdaa2b6747..43ba65e079056 100644 --- a/packages/create-block/lib/templates/block/render.php.mustache +++ b/packages/create-block/lib/templates/block/render.php.mustache @@ -1,4 +1,9 @@ {{#isDynamicVariant}} +

>

diff --git a/packages/create-block/lib/templates/es5/render.php.mustache b/packages/create-block/lib/templates/es5/render.php.mustache index 39ccdaa2b6747..43ba65e079056 100644 --- a/packages/create-block/lib/templates/es5/render.php.mustache +++ b/packages/create-block/lib/templates/es5/render.php.mustache @@ -1,4 +1,9 @@ {{#isDynamicVariant}} +

>

diff --git a/packages/data/README.md b/packages/data/README.md index 1ecdf5495779d..444548dfe982f 100644 --- a/packages/data/README.md +++ b/packages/data/README.md @@ -499,11 +499,11 @@ dispatch( myCustomStore ).setPrice( 'hammer', 9.75 ); _Parameters_ -- _storeNameOrDescriptor_ `StoreDescriptor|string`: The store descriptor. The legacy calling convention of passing the store name is also supported. +- _storeNameOrDescriptor_ `string | T`: The store descriptor. The legacy calling convention of passing the store name is also supported. _Returns_ -- `Object`: Object containing the action creators. +- `ActionCreatorsOf< ConfigOf< T > >`: Object containing the action creators. ### plugins @@ -638,11 +638,11 @@ select( myCustomStore ).getPrice( 'hammer' ); _Parameters_ -- _storeNameOrDescriptor_ `StoreDescriptor|string`: The store descriptor. The legacy calling convention of passing the store name is also supported. +- _storeNameOrDescriptor_ `string | T`: The store descriptor. The legacy calling convention of passing the store name is also supported. _Returns_ -- `Object`: Object containing the store's selectors. +- `CurriedSelectorsOf< T >`: Object containing the store's selectors. ### subscribe diff --git a/packages/data/src/dispatch.ts b/packages/data/src/dispatch.ts new file mode 100644 index 0000000000000..e75eb8eb7bffc --- /dev/null +++ b/packages/data/src/dispatch.ts @@ -0,0 +1,35 @@ +/** + * Internal dependencies + */ +import type { + ActionCreatorsOf, + AnyConfig, + ConfigOf, + StoreDescriptor, +} from './types'; +import defaultRegistry from './default-registry'; + +/** + * Given a store descriptor, returns an object of the store's action creators. + * Calling an action creator will cause it to be dispatched, updating the state value accordingly. + * + * Note: Action creators returned by the dispatch will return a promise when + * they are called. + * + * @param storeNameOrDescriptor The store descriptor. The legacy calling convention of passing + * the store name is also supported. + * + * @example + * ```js + * import { dispatch } from '@wordpress/data'; + * import { store as myCustomStore } from 'my-custom-store'; + * + * dispatch( myCustomStore ).setPrice( 'hammer', 9.75 ); + * ``` + * @return Object containing the action creators. + */ +export function dispatch< T extends StoreDescriptor< AnyConfig > >( + storeNameOrDescriptor: string | T +): ActionCreatorsOf< ConfigOf< T > > { + return defaultRegistry.dispatch( storeNameOrDescriptor ); +} diff --git a/packages/data/src/index.js b/packages/data/src/index.js index 7831f4b9249dd..770516e6ae0e3 100644 --- a/packages/data/src/index.js +++ b/packages/data/src/index.js @@ -29,6 +29,8 @@ export { createRegistry } from './registry'; export { createRegistrySelector, createRegistryControl } from './factory'; export { controls } from './controls'; export { default as createReduxStore } from './redux-store'; +export { dispatch } from './dispatch'; +export { select } from './select'; /** * Object of available plugins to use with a registry. @@ -80,29 +82,6 @@ export { plugins }; */ export const combineReducers = turboCombineReducers; -/** - * Given a store descriptor, returns an object of the store's selectors. - * The selector functions are been pre-bound to pass the current state automatically. - * As a consumer, you need only pass arguments of the selector, if applicable. - * - * @param {StoreDescriptor|string} storeNameOrDescriptor The store descriptor. The legacy calling - * convention of passing the store name is - * also supported. - * - * @example - * ```js - * import { select } from '@wordpress/data'; - * import { store as myCustomStore } from 'my-custom-store'; - * - * select( myCustomStore ).getPrice( 'hammer' ); - * ``` - * - * @return {Object} Object containing the store's selectors. - */ -export function select( storeNameOrDescriptor ) { - return defaultRegistry.select( storeNameOrDescriptor ); -} - /** * Given a store descriptor, returns an object containing the store's selectors pre-bound to state * so that you only need to supply additional arguments, and modified so that they return promises @@ -137,30 +116,6 @@ export const resolveSelect = defaultRegistry.resolveSelect; */ export const suspendSelect = defaultRegistry.suspendSelect; -/** - * Given a store descriptor, returns an object of the store's action creators. - * Calling an action creator will cause it to be dispatched, updating the state value accordingly. - * - * Note: Action creators returned by the dispatch will return a promise when - * they are called. - * - * @param {StoreDescriptor|string} storeNameOrDescriptor The store descriptor. The legacy calling - * convention of passing the store name is - * also supported. - * - * @example - * ```js - * import { dispatch } from '@wordpress/data'; - * import { store as myCustomStore } from 'my-custom-store'; - * - * dispatch( myCustomStore ).setPrice( 'hammer', 9.75 ); - * ``` - * @return {Object} Object containing the action creators. - */ -export function dispatch( storeNameOrDescriptor ) { - return defaultRegistry.dispatch( storeNameOrDescriptor ); -} - /** * Given a listener function, the function will be called any time the state value * of one of the registered stores has changed. If you specify the optional diff --git a/packages/data/src/redux-store/metadata/selectors.js b/packages/data/src/redux-store/metadata/selectors.js index 329074cd0a83b..4e5a999567302 100644 --- a/packages/data/src/redux-store/metadata/selectors.js +++ b/packages/data/src/redux-store/metadata/selectors.js @@ -131,3 +131,18 @@ export function isResolving( state, selectorName, args ) { export function getCachedResolvers( state ) { return state; } + +/** + * Whether the store has any currently resolving selectors. + * + * @param {State} state Data state. + * + * @return {boolean} True if one or more selectors are resolving, false otherwise. + */ +export function hasResolvingSelectors( state ) { + return [ ...Object.values( state ) ].some( ( selectorState ) => + [ ...selectorState._map.values() ].some( + ( resolution ) => resolution[ 1 ]?.status === 'resolving' + ) + ); +} diff --git a/packages/data/src/redux-store/metadata/test/selectors.js b/packages/data/src/redux-store/metadata/test/selectors.js index d84ef52bddd04..2eea14a7b6059 100644 --- a/packages/data/src/redux-store/metadata/test/selectors.js +++ b/packages/data/src/redux-store/metadata/test/selectors.js @@ -324,3 +324,35 @@ describe( 'getResolutionError', () => { ).toBeFalsy(); } ); } ); + +describe( 'hasResolvingSelectors', () => { + let registry; + beforeEach( () => { + registry = createRegistry(); + registry.registerStore( 'testStore', testStore ); + } ); + + it( 'returns false if no requests have started', () => { + const { hasResolvingSelectors } = registry.select( 'testStore' ); + const result = hasResolvingSelectors(); + + expect( result ).toBe( false ); + } ); + + it( 'returns false if all requests have finished', () => { + registry.dispatch( 'testStore' ).startResolution( 'getFoo', [] ); + registry.dispatch( 'testStore' ).finishResolution( 'getFoo', [] ); + const { hasResolvingSelectors } = registry.select( 'testStore' ); + const result = hasResolvingSelectors(); + + expect( result ).toBe( false ); + } ); + + it( 'returns true if has started but not finished', () => { + registry.dispatch( 'testStore' ).startResolution( 'getFoo', [] ); + const { hasResolvingSelectors } = registry.select( 'testStore' ); + const result = hasResolvingSelectors(); + + expect( result ).toBe( true ); + } ); +} ); diff --git a/packages/data/src/redux-store/test/index.js b/packages/data/src/redux-store/test/index.js index daca128480dae..e3cc2c727dbc5 100644 --- a/packages/data/src/redux-store/test/index.js +++ b/packages/data/src/redux-store/test/index.js @@ -281,6 +281,7 @@ describe( 'resolveSelect', () => { it( 'returns only store native selectors and excludes all meta ones', () => { expect( Object.keys( registry.resolveSelect( 'store' ) ) ).toEqual( [ + 'hasResolvingSelectors', 'getItems', 'getItemsNoResolver', ] ); diff --git a/packages/data/src/select.ts b/packages/data/src/select.ts new file mode 100644 index 0000000000000..263502a5a1fba --- /dev/null +++ b/packages/data/src/select.ts @@ -0,0 +1,30 @@ +/** + * Internal dependencies + */ +import type { AnyConfig, CurriedSelectorsOf, StoreDescriptor } from './types'; +import defaultRegistry from './default-registry'; + +/** + * Given a store descriptor, returns an object of the store's selectors. + * The selector functions are been pre-bound to pass the current state automatically. + * As a consumer, you need only pass arguments of the selector, if applicable. + * + * + * @param storeNameOrDescriptor The store descriptor. The legacy calling convention + * of passing the store name is also supported. + * + * @example + * ```js + * import { select } from '@wordpress/data'; + * import { store as myCustomStore } from 'my-custom-store'; + * + * select( myCustomStore ).getPrice( 'hammer' ); + * ``` + * + * @return Object containing the store's selectors. + */ +export function select< T extends StoreDescriptor< AnyConfig > >( + storeNameOrDescriptor: string | T +): CurriedSelectorsOf< T > { + return defaultRegistry.select( storeNameOrDescriptor ); +} diff --git a/packages/date/README.md b/packages/date/README.md index 7cd3116c73c4c..8edd4e94a8538 100644 --- a/packages/date/README.md +++ b/packages/date/README.md @@ -115,6 +115,19 @@ _Returns_ - `string`: Formatted date. +### humanTimeDiff + +Returns a human-readable time difference between two dates, like human_time_diff() in PHP. + +_Parameters_ + +- _from_ `Moment | Date | string`: From date, in the WP timezone. +- _to_ `Moment | Date | string | undefined`: To date, formatted in the WP timezone. + +_Returns_ + +- `string`: Human-readable time difference. + ### isInTheFuture Check whether a date is considered in the future according to the WordPress settings. diff --git a/packages/date/src/index.js b/packages/date/src/index.js index 2417d1b5edf85..9ac47f3a0a5f7 100644 --- a/packages/date/src/index.js +++ b/packages/date/src/index.js @@ -581,6 +581,20 @@ export function getDate( dateString ) { return momentLib.tz( dateString, WP_ZONE ).toDate(); } +/** + * Returns a human-readable time difference between two dates, like human_time_diff() in PHP. + * + * @param {Moment | Date | string} from From date, in the WP timezone. + * @param {Moment | Date | string | undefined} to To date, formatted in the WP timezone. + * + * @return {string} Human-readable time difference. + */ +export function humanTimeDiff( from, to ) { + const fromMoment = momentLib.tz( from, WP_ZONE ); + const toMoment = to ? momentLib.tz( to, WP_ZONE ) : momentLib.tz( WP_ZONE ); + return fromMoment.from( toMoment ); +} + /** * Creates a moment instance using the given timezone or, if none is provided, using global settings. * diff --git a/packages/date/src/test/index.js b/packages/date/src/test/index.js index 36414949af16a..ff82748e02f23 100644 --- a/packages/date/src/test/index.js +++ b/packages/date/src/test/index.js @@ -10,6 +10,7 @@ import { gmdateI18n, isInTheFuture, setSettings, + humanTimeDiff, } from '../'; describe( 'isInTheFuture', () => { @@ -620,4 +621,27 @@ describe( 'Moment.js Localization', () => { // Restore default settings. setSettings( settings ); } ); + + describe( 'humanTimeDiff', () => { + it( 'should return human readable time differences', () => { + expect( + humanTimeDiff( + '2023-04-28T11:00:00.000Z', + '2023-04-28T12:00:00.000Z' + ) + ).toBe( 'an hour ago' ); + expect( + humanTimeDiff( + '2023-04-28T11:00:00.000Z', + '2023-04-28T13:00:00.000Z' + ) + ).toBe( '2 hours ago' ); + expect( + humanTimeDiff( + '2023-04-28T11:00:00.000Z', + '2023-04-30T13:00:00.000Z' + ) + ).toBe( '2 days ago' ); + } ); + } ); } ); diff --git a/packages/e2e-test-utils-playwright/CHANGELOG.md b/packages/e2e-test-utils-playwright/CHANGELOG.md index 34c83ae9061ba..6b0e32d1b44d0 100644 --- a/packages/e2e-test-utils-playwright/CHANGELOG.md +++ b/packages/e2e-test-utils-playwright/CHANGELOG.md @@ -2,4 +2,4 @@ ## Unreleased --- Initial version of the package. +- Initial version of the package. diff --git a/packages/e2e-test-utils-playwright/README.md b/packages/e2e-test-utils-playwright/README.md index 50e9c0540b924..d03a6ced191f9 100644 --- a/packages/e2e-test-utils-playwright/README.md +++ b/packages/e2e-test-utils-playwright/README.md @@ -4,6 +4,10 @@ End-To-End (E2E) Playwright test utils for WordPress. _It works properly with the minimum version of Gutenberg `9.2.0` or the minimum version of WordPress `5.6.0`._ +
+This package is still under active development. Documentation might not be up-to-date, and the v0.x version can introduce breaking changes without a detailed migration guide. Early adopters are encouraged to use a lock file to prevent unexpected breakages. +
+ ## Installation Install the module diff --git a/packages/e2e-test-utils-playwright/package.json b/packages/e2e-test-utils-playwright/package.json index d168c7d70afbc..74288ea257af3 100644 --- a/packages/e2e-test-utils-playwright/package.json +++ b/packages/e2e-test-utils-playwright/package.json @@ -1,7 +1,6 @@ { "name": "@wordpress/e2e-test-utils-playwright", - "version": "0.0.0", - "private": true, + "version": "0.1.0-prerelease", "description": "End-To-End (E2E) test utils for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/e2e-test-utils-playwright/src/editor/open-document-settings-sidebar.ts b/packages/e2e-test-utils-playwright/src/editor/open-document-settings-sidebar.ts index ac21dcbba0ed6..a9ddffcd6cd5e 100644 --- a/packages/e2e-test-utils-playwright/src/editor/open-document-settings-sidebar.ts +++ b/packages/e2e-test-utils-playwright/src/editor/open-document-settings-sidebar.ts @@ -24,7 +24,7 @@ export async function openDocumentSettingsSidebar( this: Editor ) { await toggleButton.click(); await this.page .getByRole( 'region', { name: 'Editor settings' } ) - .getByRole( 'button', { name: 'Close settings' } ) + .getByRole( 'button', { name: 'Close Settings' } ) .waitFor(); } } diff --git a/packages/e2e-test-utils-playwright/src/editor/site-editor.ts b/packages/e2e-test-utils-playwright/src/editor/site-editor.ts index a6fb454b912d4..d3fb58f9aab40 100644 --- a/packages/e2e-test-utils-playwright/src/editor/site-editor.ts +++ b/packages/e2e-test-utils-playwright/src/editor/site-editor.ts @@ -14,9 +14,10 @@ export async function saveSiteEditorEntities( this: Editor ) { ); // Second Save button in the entities panel. await this.page.click( - 'role=region[name="Save sidebar"i] >> role=button[name="Save"i]' - ); - await this.page.waitForSelector( - 'role=region[name="Editor top bar"i] >> role=button[name="Save"i][disabled]' + 'role=region[name="Save panel"i] >> role=button[name="Save"i]' ); + // A role selector cannot be used here because it needs to check that the `is-busy` class is not present. + await this.page.waitForSelector( '[aria-label="Saved"].is-busy', { + state: 'hidden', + } ); } diff --git a/packages/e2e-test-utils-playwright/src/request-utils/index.ts b/packages/e2e-test-utils-playwright/src/request-utils/index.ts index e05e59bacc673..99a11bf995679 100644 --- a/packages/e2e-test-utils-playwright/src/request-utils/index.ts +++ b/packages/e2e-test-utils-playwright/src/request-utils/index.ts @@ -17,7 +17,11 @@ import { createUser, deleteAllUsers } from './users'; import { setupRest, rest, getMaxBatchSize, batchRest } from './rest'; import { getPluginsMap, activatePlugin, deactivatePlugin } from './plugins'; import { deleteAllTemplates } from './templates'; -import { activateTheme } from './themes'; +import { + activateTheme, + getCurrentThemeGlobalStylesPostId, + getThemeGlobalStylesRevisions, +} from './themes'; import { deleteAllBlocks } from './blocks'; import { createComment, deleteAllComments } from './comments'; import { createPost, deleteAllPosts } from './posts'; @@ -188,6 +192,12 @@ class RequestUtils { deleteAllPages: typeof deleteAllPages = deleteAllPages.bind( this ); /** @borrows createPage as this.createPage */ createPage: typeof createPage = createPage.bind( this ); + /** @borrows getCurrentThemeGlobalStylesPostId as this.getCurrentThemeGlobalStylesPostId */ + getCurrentThemeGlobalStylesPostId: typeof getCurrentThemeGlobalStylesPostId = + getCurrentThemeGlobalStylesPostId.bind( this ); + /** @borrows getThemeGlobalStylesRevisions as this.getThemeGlobalStylesRevisions */ + getThemeGlobalStylesRevisions: typeof getThemeGlobalStylesRevisions = + getThemeGlobalStylesRevisions.bind( this ); } export type { StorageState }; diff --git a/packages/e2e-test-utils-playwright/src/request-utils/themes.ts b/packages/e2e-test-utils-playwright/src/request-utils/themes.ts index 802530d947b0f..6b4fd17588737 100644 --- a/packages/e2e-test-utils-playwright/src/request-utils/themes.ts +++ b/packages/e2e-test-utils-playwright/src/request-utils/themes.ts @@ -37,4 +37,54 @@ async function activateTheme( await response.dispose(); } -export { activateTheme }; +// https://developer.wordpress.org/rest-api/reference/themes/#definition +async function getCurrentThemeGlobalStylesPostId( this: RequestUtils ) { + type ThemeItem = { + stylesheet: string; + status: string; + _links: { 'wp:user-global-styles': { href: string }[] }; + }; + const themes = await this.rest< ThemeItem[] >( { + path: '/wp/v2/themes', + } ); + let themeGlobalStylesId: string = ''; + if ( themes && themes.length ) { + const currentTheme: ThemeItem | undefined = themes.find( + ( { status } ) => status === 'active' + ); + + const globalStylesURL = + currentTheme?._links?.[ 'wp:user-global-styles' ]?.[ 0 ]?.href; + if ( globalStylesURL ) { + themeGlobalStylesId = globalStylesURL?.split( + 'rest_route=/wp/v2/global-styles/' + )[ 1 ]; + } + } + return themeGlobalStylesId; +} + +/** + * Deletes all post revisions using the REST API. + * + * @param {} this RequestUtils. + * @param {string|number} parentId Post attributes. + */ +async function getThemeGlobalStylesRevisions( + this: RequestUtils, + parentId: number | string +) { + // Lists all global styles revisions. + return await this.rest< Record< string, Object >[] >( { + path: `/wp/v2/global-styles/${ parentId }/revisions`, + params: { + per_page: 100, + }, + } ); +} + +export { + activateTheme, + getCurrentThemeGlobalStylesPostId, + getThemeGlobalStylesRevisions, +}; diff --git a/packages/e2e-test-utils/src/site-editor.js b/packages/e2e-test-utils/src/site-editor.js index 97f1c0e16bdda..619fc8b9cf630 100644 --- a/packages/e2e-test-utils/src/site-editor.js +++ b/packages/e2e-test-utils/src/site-editor.js @@ -10,6 +10,7 @@ import { addQueryArgs } from '@wordpress/url'; const SELECTORS = { visualEditor: '.edit-site-visual-editor iframe', + loadingSpinner: '.edit-site-canvas-spinner', }; /** @@ -128,6 +129,7 @@ export async function visitSiteEditor( query, skipWelcomeGuide = true ) { await visitAdminPage( 'site-editor.php', query ); await page.waitForSelector( SELECTORS.visualEditor ); + await page.waitForSelector( SELECTORS.loadingSpinner, { hidden: true } ); if ( skipWelcomeGuide ) { await disableSiteEditorWelcomeGuide(); diff --git a/packages/e2e-tests/plugins/plugins-api/annotations-sidebar.js b/packages/e2e-tests/plugins/plugins-api/annotations-sidebar.js index 402f719729928..f73ad5c68013b 100644 --- a/packages/e2e-tests/plugins/plugins-api/annotations-sidebar.js +++ b/packages/e2e-tests/plugins/plugins-api/annotations-sidebar.js @@ -94,7 +94,7 @@ PluginSidebar, { name: 'annotations-sidebar', - title: __( 'Annotations Sidebar' ), + title: __( 'Annotations' ), }, el( SidebarContents, {} ) ), @@ -103,7 +103,7 @@ { target: 'annotations-sidebar', }, - __( 'Annotations Sidebar' ) + __( 'Annotations' ) ) ); } diff --git a/packages/e2e-tests/plugins/plugins-api/sidebar.js b/packages/e2e-tests/plugins/plugins-api/sidebar.js index a97a940d846b8..01e0a80de7836 100644 --- a/packages/e2e-tests/plugins/plugins-api/sidebar.js +++ b/packages/e2e-tests/plugins/plugins-api/sidebar.js @@ -70,7 +70,7 @@ PluginSidebar, { name: 'title-sidebar', - title: __( 'Plugin sidebar title' ), + title: __( 'Plugin title' ), }, el( SidebarContents, {} ) ), @@ -79,7 +79,7 @@ { target: 'title-sidebar', }, - __( 'Plugin sidebar more menu title' ) + __( 'Plugin more menu title' ) ) ); } diff --git a/packages/e2e-tests/specs/editor/blocks/pullquote.test.js b/packages/e2e-tests/specs/editor/blocks/pullquote.test.js deleted file mode 100644 index 13d6431c6dbcb..0000000000000 --- a/packages/e2e-tests/specs/editor/blocks/pullquote.test.js +++ /dev/null @@ -1,47 +0,0 @@ -/** - * WordPress dependencies - */ -import { - clickBlockAppender, - getEditedPostContent, - createNewPost, - transformBlockTo, -} from '@wordpress/e2e-test-utils'; - -describe( 'Quote', () => { - beforeEach( async () => { - await createNewPost(); - } ); - - it( 'can be created by converting a quote and converted back to quote', async () => { - await clickBlockAppender(); - await page.keyboard.type( 'test' ); - await transformBlockTo( 'Quote' ); - - expect( await getEditedPostContent() ).toMatchInlineSnapshot( ` - " -
-

test

-
- " - ` ); - - await transformBlockTo( 'Pullquote' ); - - expect( await getEditedPostContent() ).toMatchInlineSnapshot( ` - " -

test

- " - ` ); - - await transformBlockTo( 'Quote' ); - - expect( await getEditedPostContent() ).toMatchInlineSnapshot( ` - " -
-

test

-
- " - ` ); - } ); -} ); diff --git a/packages/e2e-tests/specs/editor/plugins/__snapshots__/plugins-api.test.js.snap b/packages/e2e-tests/specs/editor/plugins/__snapshots__/plugins-api.test.js.snap index 34c57bf11e7bc..4cac5cd6893cd 100644 --- a/packages/e2e-tests/specs/editor/plugins/__snapshots__/plugins-api.test.js.snap +++ b/packages/e2e-tests/specs/editor/plugins/__snapshots__/plugins-api.test.js.snap @@ -2,6 +2,6 @@ exports[`Using Plugins API Document Setting Custom Panel Should render a custom panel inside Document Setting sidebar 1`] = `"My Custom Panel"`; -exports[`Using Plugins API Sidebar Medium screen Should open plugins sidebar using More Menu item and render content 1`] = `"
(no title)
Plugin sidebar title
"`; +exports[`Using Plugins API Sidebar Medium screen Should open plugins sidebar using More Menu item and render content 1`] = `"
(no title)
Plugin title
"`; -exports[`Using Plugins API Sidebar Should open plugins sidebar using More Menu item and render content 1`] = `"
(no title)
Plugin sidebar title
"`; +exports[`Using Plugins API Sidebar Should open plugins sidebar using More Menu item and render content 1`] = `"
(no title)
Plugin title
"`; diff --git a/packages/e2e-tests/specs/editor/plugins/annotations.test.js b/packages/e2e-tests/specs/editor/plugins/annotations.test.js index 0ab1fc0f99dff..85265bf424abc 100644 --- a/packages/e2e-tests/specs/editor/plugins/annotations.test.js +++ b/packages/e2e-tests/specs/editor/plugins/annotations.test.js @@ -100,7 +100,7 @@ describe( 'Annotations', () => { it( 'allows a block to be annotated', async () => { await page.keyboard.type( 'Title' + '\n' + 'Paragraph to annotate' ); - await clickOnMoreMenuItem( 'Annotations Sidebar' ); + await clickOnMoreMenuItem( 'Annotations' ); let annotations = await page.$$( ANNOTATIONS_SELECTOR ); expect( annotations ).toHaveLength( 0 ); @@ -128,7 +128,7 @@ describe( 'Annotations', () => { it( 'keeps the cursor in the same location when applying annotation', async () => { await page.keyboard.type( 'Title' + '\n' + 'ABC' ); - await clickOnMoreMenuItem( 'Annotations Sidebar' ); + await clickOnMoreMenuItem( 'Annotations' ); await annotateFirstBlock( 1, 2 ); @@ -146,7 +146,7 @@ describe( 'Annotations', () => { it( 'moves when typing before it', async () => { await page.keyboard.type( 'Title' + '\n' + 'ABC' ); - await clickOnMoreMenuItem( 'Annotations Sidebar' ); + await clickOnMoreMenuItem( 'Annotations' ); await annotateFirstBlock( 1, 2 ); @@ -168,7 +168,7 @@ describe( 'Annotations', () => { it( 'grows when typing inside it', async () => { await page.keyboard.type( 'Title' + '\n' + 'ABC' ); - await clickOnMoreMenuItem( 'Annotations Sidebar' ); + await clickOnMoreMenuItem( 'Annotations' ); await annotateFirstBlock( 1, 2 ); diff --git a/packages/e2e-tests/specs/editor/plugins/iframed-inline-styles.test.js b/packages/e2e-tests/specs/editor/plugins/iframed-inline-styles.test.js index 52b22e550f41c..6e6b3d670082c 100644 --- a/packages/e2e-tests/specs/editor/plugins/iframed-inline-styles.test.js +++ b/packages/e2e-tests/specs/editor/plugins/iframed-inline-styles.test.js @@ -32,7 +32,7 @@ describe( 'iframed inline styles', () => { } ); // Skip flaky test. See https://github.com/WordPress/gutenberg/issues/35172 - it( 'should load inline styles in iframe', async () => { + it.skip( 'should load inline styles in iframe', async () => { await insertBlock( 'Iframed Inline Styles' ); expect( await getEditedPostContent() ).toMatchSnapshot(); diff --git a/packages/e2e-tests/specs/editor/plugins/plugins-api.test.js b/packages/e2e-tests/specs/editor/plugins/plugins-api.test.js index 2e3f9758dc684..4ad8d0e634204 100644 --- a/packages/e2e-tests/specs/editor/plugins/plugins-api.test.js +++ b/packages/e2e-tests/specs/editor/plugins/plugins-api.test.js @@ -69,10 +69,10 @@ describe( 'Using Plugins API', () => { describe( 'Sidebar', () => { const SIDEBAR_PINNED_ITEM_BUTTON = - '.interface-pinned-items button[aria-label="Plugin sidebar title"]'; + '.interface-pinned-items button[aria-label="Plugin title"]'; const SIDEBAR_PANEL_SELECTOR = '.sidebar-title-plugin-panel'; it( 'Should open plugins sidebar using More Menu item and render content', async () => { - await clickOnMoreMenuItem( 'Plugin sidebar more menu title' ); + await clickOnMoreMenuItem( 'Plugin more menu title' ); const pluginSidebarContent = await page.$eval( '.edit-post-sidebar', @@ -105,7 +105,7 @@ describe( 'Using Plugins API', () => { await page.reload(); await page.waitForSelector( '.edit-post-layout' ); expect( await page.$( SIDEBAR_PINNED_ITEM_BUTTON ) ).toBeNull(); - await clickOnMoreMenuItem( 'Plugin sidebar more menu title' ); + await clickOnMoreMenuItem( 'Plugin more menu title' ); await page.click( 'button[aria-label="Pin to toolbar"]' ); expect( await page.$( SIDEBAR_PINNED_ITEM_BUTTON ) ).not.toBeNull(); await page.reload(); @@ -114,12 +114,12 @@ describe( 'Using Plugins API', () => { } ); it( 'Should close plugins sidebar using More Menu item', async () => { - await clickOnMoreMenuItem( 'Plugin sidebar more menu title' ); + await clickOnMoreMenuItem( 'Plugin more menu title' ); const pluginSidebarOpened = await page.$( '.edit-post-sidebar' ); expect( pluginSidebarOpened ).not.toBeNull(); - await clickOnMoreMenuItem( 'Plugin sidebar more menu title' ); + await clickOnMoreMenuItem( 'Plugin more menu title' ); const pluginSidebarClosed = await page.$( '.edit-post-sidebar' ); expect( pluginSidebarClosed ).toBeNull(); @@ -135,7 +135,7 @@ describe( 'Using Plugins API', () => { } ); it( 'Should open plugins sidebar using More Menu item and render content', async () => { - await clickOnMoreMenuItem( 'Plugin sidebar more menu title' ); + await clickOnMoreMenuItem( 'Plugin more menu title' ); const pluginSidebarContent = await page.$eval( '.edit-post-sidebar', diff --git a/packages/e2e-tests/specs/editor/various/__snapshots__/adding-patterns.test.js.snap b/packages/e2e-tests/specs/editor/various/__snapshots__/adding-patterns.test.js.snap deleted file mode 100644 index 0daa12f8a8660..0000000000000 --- a/packages/e2e-tests/specs/editor/various/__snapshots__/adding-patterns.test.js.snap +++ /dev/null @@ -1,11 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`adding patterns should insert a block pattern 1`] = ` -" - -" -`; diff --git a/packages/e2e-tests/specs/editor/various/__snapshots__/keep-styles-on-block-transforms.test.js.snap b/packages/e2e-tests/specs/editor/various/__snapshots__/keep-styles-on-block-transforms.test.js.snap deleted file mode 100644 index 45aaa187a2206..0000000000000 --- a/packages/e2e-tests/specs/editor/various/__snapshots__/keep-styles-on-block-transforms.test.js.snap +++ /dev/null @@ -1,29 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Keep styles on block transforms Should keep colors during a transform 1`] = ` -" -

Heading

-" -`; - -exports[`Keep styles on block transforms Should keep the font size during a transform from multiple blocks into multiple blocks 1`] = ` -" -

Line 1 to be made large

- - - -

Line 2 to be made large

- - - -

Line 3 to be made large

-" -`; - -exports[`Keep styles on block transforms Should not include styles in the group block when grouping a block 1`] = ` -" -
-

Line 1 to be made large

-
-" -`; diff --git a/packages/e2e-tests/specs/editor/various/__snapshots__/undo.test.js.snap b/packages/e2e-tests/specs/editor/various/__snapshots__/undo.test.js.snap deleted file mode 100644 index 5d1601b9f0d9a..0000000000000 --- a/packages/e2e-tests/specs/editor/various/__snapshots__/undo.test.js.snap +++ /dev/null @@ -1,31 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`undo should immediately create an undo level on typing 1`] = ` -" -

1

-" -`; - -exports[`undo should undo typing after a pause 1`] = ` -" -

before pause after pause

-" -`; - -exports[`undo should undo typing after a pause 2`] = ` -" -

before pause

-" -`; - -exports[`undo should undo typing after non input change 1`] = ` -" -

before keyboard after keyboard

-" -`; - -exports[`undo should undo typing after non input change 2`] = ` -" -

before keyboard

-" -`; diff --git a/packages/e2e-tests/specs/editor/various/adding-patterns.test.js b/packages/e2e-tests/specs/editor/various/adding-patterns.test.js deleted file mode 100644 index ca1cac98e2316..0000000000000 --- a/packages/e2e-tests/specs/editor/various/adding-patterns.test.js +++ /dev/null @@ -1,22 +0,0 @@ -/** - * WordPress dependencies - */ -import { - createNewPost, - insertPattern, - getEditedPostContent, -} from '@wordpress/e2e-test-utils'; - -/** @typedef {import('puppeteer-core').ElementHandle} ElementHandle */ - -describe( 'adding patterns', () => { - beforeEach( async () => { - await createNewPost(); - } ); - - it( 'should insert a block pattern', async () => { - await insertPattern( 'Social links with a shared background color' ); - - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); -} ); diff --git a/packages/e2e-tests/specs/editor/various/keep-styles-on-block-transforms.test.js b/packages/e2e-tests/specs/editor/various/keep-styles-on-block-transforms.test.js deleted file mode 100644 index 56b2ea6263d92..0000000000000 --- a/packages/e2e-tests/specs/editor/various/keep-styles-on-block-transforms.test.js +++ /dev/null @@ -1,64 +0,0 @@ -/** - * WordPress dependencies - */ -import { - clickBlockAppender, - createNewPost, - getEditedPostContent, - pressKeyWithModifier, - transformBlockTo, -} from '@wordpress/e2e-test-utils'; - -describe( 'Keep styles on block transforms', () => { - beforeEach( async () => { - await createNewPost(); - } ); - - it( 'Should keep colors during a transform', async () => { - await clickBlockAppender(); - await page.keyboard.type( '## Heading' ); - - const textColorButton = await page.waitForSelector( - '.block-editor-panel-color-gradient-settings__dropdown' - ); - await textColorButton.click(); - - const colorButtonSelector = `//button[@aria-label='Color: Luminous vivid orange']`; - const [ colorButton ] = await page.$x( colorButtonSelector ); - await colorButton.click(); - await page.waitForXPath( - `${ colorButtonSelector }[@aria-pressed='true']` - ); - await page.click( 'h2[data-type="core/heading"]' ); - await transformBlockTo( 'Paragraph' ); - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - it( 'Should keep the font size during a transform from multiple blocks into multiple blocks', async () => { - // Create a paragraph block with some content. - await clickBlockAppender(); - await page.keyboard.type( 'Line 1 to be made large' ); - await page.keyboard.press( 'Enter' ); - await page.keyboard.type( 'Line 2 to be made large' ); - await page.keyboard.press( 'Enter' ); - await page.keyboard.type( 'Line 3 to be made large' ); - await pressKeyWithModifier( 'shift', 'ArrowUp' ); - await pressKeyWithModifier( 'shift', 'ArrowUp' ); - await page.click( - '[role="radiogroup"][aria-label="Font size"] [aria-label="Large"]' - ); - await transformBlockTo( 'Heading' ); - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - it( 'Should not include styles in the group block when grouping a block', async () => { - // Create a paragraph block with some content. - await clickBlockAppender(); - await page.keyboard.type( 'Line 1 to be made large' ); - await page.click( - '[role="radiogroup"][aria-label="Font size"] [aria-label="Large"]' - ); - await transformBlockTo( 'Group' ); - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); -} ); diff --git a/packages/e2e-tests/specs/editor/various/preferences.test.js b/packages/e2e-tests/specs/editor/various/preferences.test.js index 59445ecbe5552..98249637c7e96 100644 --- a/packages/e2e-tests/specs/editor/various/preferences.test.js +++ b/packages/e2e-tests/specs/editor/various/preferences.test.js @@ -46,7 +46,7 @@ describe( 'preferences', () => { // Dismiss. await page.click( - '.edit-post-sidebar__panel-tabs [aria-label="Close settings"]' + '.edit-post-sidebar__panel-tabs [aria-label="Close Settings"]' ); expect( await getActiveSidebarTabText() ).toBe( null ); diff --git a/packages/e2e-tests/specs/editor/various/undo.test.js b/packages/e2e-tests/specs/editor/various/undo.test.js deleted file mode 100644 index 9402ce8624c1a..0000000000000 --- a/packages/e2e-tests/specs/editor/various/undo.test.js +++ /dev/null @@ -1,444 +0,0 @@ -/** - * WordPress dependencies - */ -import { - clickBlockAppender, - getEditedPostContent, - createNewPost, - pressKeyWithModifier, - selectBlockByClientId, - getAllBlocks, - saveDraft, - publishPost, -} from '@wordpress/e2e-test-utils'; - -const getSelection = async () => { - return await page.evaluate( () => { - const selectedBlock = document.activeElement.closest( '.wp-block' ); - const blocks = Array.from( document.querySelectorAll( '.wp-block' ) ); - const blockIndex = blocks.indexOf( selectedBlock ); - - if ( blockIndex === -1 ) { - return {}; - } - - let editables; - - if ( selectedBlock.getAttribute( 'contenteditable' ) ) { - editables = [ selectedBlock ]; - } else { - editables = Array.from( - selectedBlock.querySelectorAll( '[contenteditable]' ) - ); - } - - const editableIndex = editables.indexOf( document.activeElement ); - const selection = window.getSelection(); - - if ( editableIndex === -1 || ! selection.rangeCount ) { - return { blockIndex }; - } - - const range = selection.getRangeAt( 0 ); - const cloneStart = range.cloneRange(); - const cloneEnd = range.cloneRange(); - - cloneStart.setStart( document.activeElement, 0 ); - cloneEnd.setStart( document.activeElement, 0 ); - - /** - * Zero width non-breaking space, used as padding in the editable DOM - * tree when it is empty otherwise. - */ - const ZWNBSP = '\ufeff'; - - return { - blockIndex, - editableIndex, - startOffset: cloneStart.toString().replace( ZWNBSP, '' ).length, - endOffset: cloneEnd.toString().replace( ZWNBSP, '' ).length, - }; - } ); -}; - -describe( 'undo', () => { - beforeEach( async () => { - await createNewPost(); - } ); - - it( 'should undo typing after a pause', async () => { - await clickBlockAppender(); - - await page.keyboard.type( 'before pause' ); - await new Promise( ( resolve ) => setTimeout( resolve, 1000 ) ); - await page.keyboard.type( ' after pause' ); - - const after = await getEditedPostContent(); - - expect( after ).toMatchSnapshot(); - - await pressKeyWithModifier( 'primary', 'z' ); - - const before = await getEditedPostContent(); - - expect( before ).toMatchSnapshot(); - expect( await getSelection() ).toEqual( { - blockIndex: 1, - editableIndex: 0, - startOffset: 'before pause'.length, - endOffset: 'before pause'.length, - } ); - - await pressKeyWithModifier( 'primary', 'z' ); - - expect( await getEditedPostContent() ).toBe( '' ); - expect( await getSelection() ).toEqual( { - blockIndex: 1, - editableIndex: 0, - startOffset: 0, - endOffset: 0, - } ); - - await pressKeyWithModifier( 'primaryShift', 'z' ); - - expect( await getEditedPostContent() ).toBe( before ); - expect( await getSelection() ).toEqual( { - blockIndex: 1, - editableIndex: 0, - startOffset: 'before pause'.length, - endOffset: 'before pause'.length, - } ); - - await pressKeyWithModifier( 'primaryShift', 'z' ); - - expect( await getEditedPostContent() ).toBe( after ); - expect( await getSelection() ).toEqual( { - blockIndex: 1, - editableIndex: 0, - startOffset: 'before pause after pause'.length, - endOffset: 'before pause after pause'.length, - } ); - } ); - - it( 'should undo typing after non input change', async () => { - await clickBlockAppender(); - - await page.keyboard.type( 'before keyboard ' ); - await pressKeyWithModifier( 'primary', 'b' ); - await page.keyboard.type( 'after keyboard' ); - - const after = await getEditedPostContent(); - - expect( after ).toMatchSnapshot(); - - await pressKeyWithModifier( 'primary', 'z' ); - - const before = await getEditedPostContent(); - - expect( before ).toMatchSnapshot(); - expect( await getSelection() ).toEqual( { - blockIndex: 1, - editableIndex: 0, - startOffset: 'before keyboard '.length, - endOffset: 'before keyboard '.length, - } ); - - await pressKeyWithModifier( 'primary', 'z' ); - - expect( await getEditedPostContent() ).toBe( '' ); - expect( await getSelection() ).toEqual( { - blockIndex: 1, - editableIndex: 0, - startOffset: 0, - endOffset: 0, - } ); - - await pressKeyWithModifier( 'primaryShift', 'z' ); - - expect( await getEditedPostContent() ).toBe( before ); - expect( await getSelection() ).toEqual( { - blockIndex: 1, - editableIndex: 0, - startOffset: 'before keyboard '.length, - endOffset: 'before keyboard '.length, - } ); - - await pressKeyWithModifier( 'primaryShift', 'z' ); - - expect( await getEditedPostContent() ).toBe( after ); - expect( await getSelection() ).toEqual( { - blockIndex: 1, - editableIndex: 0, - startOffset: 'before keyboard after keyboard'.length, - endOffset: 'before keyboard after keyboard'.length, - } ); - } ); - - it( 'should undo bold', async () => { - await clickBlockAppender(); - await page.keyboard.type( 'test' ); - await saveDraft(); - await page.reload(); - await page.waitForSelector( '.edit-post-layout' ); - await page.click( '[data-type="core/paragraph"]' ); - await pressKeyWithModifier( 'primary', 'a' ); - await pressKeyWithModifier( 'primary', 'b' ); - await pressKeyWithModifier( 'primary', 'z' ); - - const visibleResult = await page.evaluate( - () => document.activeElement.innerHTML - ); - expect( visibleResult ).toBe( 'test' ); - } ); - - it( 'Should undo/redo to expected level intervals', async () => { - await clickBlockAppender(); - - const firstBlock = await getEditedPostContent(); - - await page.keyboard.type( 'This' ); - - const firstText = await getEditedPostContent(); - - await page.keyboard.press( 'Enter' ); - - const secondBlock = await getEditedPostContent(); - - await page.keyboard.type( 'is' ); - - const secondText = await getEditedPostContent(); - - await page.keyboard.press( 'Enter' ); - - const thirdBlock = await getEditedPostContent(); - - await page.keyboard.type( 'test' ); - - const thirdText = await getEditedPostContent(); - - await pressKeyWithModifier( 'primary', 'z' ); // Undo 3rd paragraph text. - - expect( await getEditedPostContent() ).toBe( thirdBlock ); - expect( await getSelection() ).toEqual( { - blockIndex: 3, - editableIndex: 0, - startOffset: 0, - endOffset: 0, - } ); - - await pressKeyWithModifier( 'primary', 'z' ); // Undo 3rd block. - - expect( await getEditedPostContent() ).toBe( secondText ); - expect( await getSelection() ).toEqual( { - blockIndex: 2, - editableIndex: 0, - startOffset: 'is'.length, - endOffset: 'is'.length, - } ); - - await pressKeyWithModifier( 'primary', 'z' ); // Undo 2nd paragraph text. - - expect( await getEditedPostContent() ).toBe( secondBlock ); - expect( await getSelection() ).toEqual( { - blockIndex: 2, - editableIndex: 0, - startOffset: 0, - endOffset: 0, - } ); - - await pressKeyWithModifier( 'primary', 'z' ); // Undo 2nd block. - - expect( await getEditedPostContent() ).toBe( firstText ); - expect( await getSelection() ).toEqual( { - blockIndex: 1, - editableIndex: 0, - startOffset: 'This'.length, - endOffset: 'This'.length, - } ); - - await pressKeyWithModifier( 'primary', 'z' ); // Undo 1st paragraph text. - - expect( await getEditedPostContent() ).toBe( firstBlock ); - expect( await getSelection() ).toEqual( { - blockIndex: 1, - editableIndex: 0, - startOffset: 0, - endOffset: 0, - } ); - - await pressKeyWithModifier( 'primary', 'z' ); // Undo 1st block. - - expect( await getEditedPostContent() ).toBe( '' ); - expect( await getSelection() ).toEqual( {} ); - // After undoing every action, there should be no more undo history. - expect( - await page.$( '.editor-history__undo[aria-disabled="true"]' ) - ).not.toBeNull(); - - await pressKeyWithModifier( 'primaryShift', 'z' ); // Redo 1st block. - - expect( await getEditedPostContent() ).toBe( firstBlock ); - expect( await getSelection() ).toEqual( { - blockIndex: 1, - editableIndex: 0, - startOffset: 0, - endOffset: 0, - } ); - // After redoing one change, the undo button should be enabled again. - expect( - await page.$( '.editor-history__undo[aria-disabled="true"]' ) - ).toBeNull(); - - await pressKeyWithModifier( 'primaryShift', 'z' ); // Redo 1st paragraph text. - - expect( await getEditedPostContent() ).toBe( firstText ); - expect( await getSelection() ).toEqual( { - blockIndex: 1, - editableIndex: 0, - startOffset: 'This'.length, - endOffset: 'This'.length, - } ); - - await pressKeyWithModifier( 'primaryShift', 'z' ); // Redo 2nd block. - - expect( await getEditedPostContent() ).toBe( secondBlock ); - expect( await getSelection() ).toEqual( { - blockIndex: 2, - editableIndex: 0, - startOffset: 0, - endOffset: 0, - } ); - - await pressKeyWithModifier( 'primaryShift', 'z' ); // Redo 2nd paragraph text. - - expect( await getEditedPostContent() ).toBe( secondText ); - expect( await getSelection() ).toEqual( { - blockIndex: 2, - editableIndex: 0, - startOffset: 'is'.length, - endOffset: 'is'.length, - } ); - - await pressKeyWithModifier( 'primaryShift', 'z' ); // Redo 3rd block. - - expect( await getEditedPostContent() ).toBe( thirdBlock ); - expect( await getSelection() ).toEqual( { - blockIndex: 3, - editableIndex: 0, - startOffset: 0, - endOffset: 0, - } ); - - await pressKeyWithModifier( 'primaryShift', 'z' ); // Redo 3rd paragraph text. - - expect( await getEditedPostContent() ).toBe( thirdText ); - expect( await getSelection() ).toEqual( { - blockIndex: 3, - editableIndex: 0, - startOffset: 'test'.length, - endOffset: 'test'.length, - } ); - } ); - - it( 'should undo for explicit persistence editing post', async () => { - // Regression test: An issue had occurred where the creation of an - // explicit undo level would interfere with blocks values being synced - // correctly to the block editor. - // - // See: https://github.com/WordPress/gutenberg/issues/14950 - - // Issue is demonstrated from an edited post: create, save, and reload. - await clickBlockAppender(); - await page.keyboard.type( 'original' ); - await saveDraft(); - await page.reload(); - await page.waitForSelector( '.edit-post-layout' ); - - // Issue is demonstrated by forcing state merges (multiple inputs) on - // an existing text after a fresh reload. - await selectBlockByClientId( ( await getAllBlocks() )[ 0 ].clientId ); - await page.keyboard.type( 'modified' ); - - // The issue is demonstrated after the one second delay to trigger the - // creation of an explicit undo persistence level. - await new Promise( ( resolve ) => setTimeout( resolve, 1000 ) ); - - await pressKeyWithModifier( 'primary', 'z' ); - - // Assert against the _visible_ content. Since editor state with the - // regression present was accurate, it would produce the correct - // content. The issue had manifested in the form of what was shown to - // the user since the blocks state failed to sync to block editor. - const visibleContent = await page.evaluate( - () => document.activeElement.textContent - ); - expect( visibleContent ).toBe( 'original' ); - } ); - - it( 'should not create undo levels when saving', async () => { - await clickBlockAppender(); - await page.keyboard.type( '1' ); - await saveDraft(); - await pressKeyWithModifier( 'primary', 'z' ); - - expect( await getEditedPostContent() ).toBe( '' ); - } ); - - it( 'should not create undo levels when publishing', async () => { - await clickBlockAppender(); - await page.keyboard.type( '1' ); - await publishPost(); - await pressKeyWithModifier( 'primary', 'z' ); - - expect( await getEditedPostContent() ).toBe( '' ); - } ); - - it( 'should immediately create an undo level on typing', async () => { - await clickBlockAppender(); - - await page.keyboard.type( '1' ); - await saveDraft(); - await page.reload(); - await page.waitForSelector( '.edit-post-layout' ); - - // Expect undo button to be disabled. - expect( - await page.$( '.editor-history__undo[aria-disabled="true"]' ) - ).not.toBeNull(); - - await page.click( '[data-type="core/paragraph"]' ); - - await page.keyboard.type( '2' ); - - // Expect undo button to be enabled. - expect( - await page.$( '.editor-history__undo[aria-disabled="true"]' ) - ).toBeNull(); - - await pressKeyWithModifier( 'primary', 'z' ); - - // Expect "1". - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - it( 'should be able to undo and redo when transient changes have been made and we update/publish', async () => { - // Typing consecutive characters in a `Paragraph` block updates the same - // block attribute as in the previous action and results in transient edits - // and skipping `undo` history steps. - const text = 'tonis'; - await clickBlockAppender(); - await page.keyboard.type( text ); - await publishPost(); - await pressKeyWithModifier( 'primary', 'z' ); - expect( await getEditedPostContent() ).toBe( '' ); - await page.waitForSelector( - '.editor-history__redo[aria-disabled="false"]' - ); - await page.click( '.editor-history__redo[aria-disabled="false"]' ); - expect( await getEditedPostContent() ).toMatchInlineSnapshot( ` - " -

tonis

- " - ` ); - } ); -} ); diff --git a/packages/e2e-tests/specs/site-editor/multi-entity-saving.test.js b/packages/e2e-tests/specs/site-editor/multi-entity-saving.test.js index fa039fb10fd2e..d54f9c78dd8b8 100644 --- a/packages/e2e-tests/specs/site-editor/multi-entity-saving.test.js +++ b/packages/e2e-tests/specs/site-editor/multi-entity-saving.test.js @@ -275,7 +275,7 @@ describe( 'Multi-entity save flow', () => { '//a[contains(@class, "block-editor-list-view-block-select-button")][contains(., "header")]' ); headerTemplatePartListViewButton.click(); - await page.click( 'button[aria-label="Close List View Sidebar"]' ); + await page.click( 'button[aria-label="Close"]' ); // Insert something to dirty the editor. await insertBlock( 'Paragraph' ); diff --git a/packages/edit-post/package.json b/packages/edit-post/package.json index fbbc10f5c252c..1df3f566ab9d5 100644 --- a/packages/edit-post/package.json +++ b/packages/edit-post/package.json @@ -32,8 +32,10 @@ "@wordpress/block-editor": "file:../block-editor", "@wordpress/block-library": "file:../block-library", "@wordpress/blocks": "file:../blocks", + "@wordpress/commands": "file:../commands", "@wordpress/components": "file:../components", "@wordpress/compose": "file:../compose", + "@wordpress/core-commands": "file:../core-commands", "@wordpress/core-data": "file:../core-data", "@wordpress/data": "file:../data", "@wordpress/deprecated": "file:../deprecated", diff --git a/packages/edit-post/src/components/header/header-toolbar/index.js b/packages/edit-post/src/components/header/header-toolbar/index.js index e28591f78e601..c479741317459 100644 --- a/packages/edit-post/src/components/header/header-toolbar/index.js +++ b/packages/edit-post/src/components/header/header-toolbar/index.js @@ -41,27 +41,15 @@ function HeaderToolbar() { showIconLabels, isListViewOpen, listViewShortcut, - selectedBlockId, - hasFixedToolbar, } = useSelect( ( select ) => { - const { - hasInserterItems, - getBlockRootClientId, - getBlockSelectionEnd, - getSelectedBlockClientId, - getFirstMultiSelectedBlockClientId, - getSettings, - } = select( blockEditorStore ); + const { hasInserterItems, getBlockRootClientId, getBlockSelectionEnd } = + select( blockEditorStore ); const { getEditorSettings } = select( editorStore ); const { getEditorMode, isFeatureActive, isListViewOpened } = select( editPostStore ); const { getShortcutRepresentation } = select( keyboardShortcutsStore ); return { - hasFixedToolbar: getSettings().hasFixedToolbar, - selectedBlockId: - getSelectedBlockClientId() || - getFirstMultiSelectedBlockClientId(), // This setting (richEditingEnabled) should not live in the block editor's setting. isInserterEnabled: getEditorMode() === 'visual' && @@ -83,14 +71,17 @@ function HeaderToolbar() { const isLargeViewport = useViewportMatch( 'medium' ); const isWideViewport = useViewportMatch( 'wide' ); - const { shouldShowContextualToolbar, canFocusHiddenToolbar } = - useShouldContextualToolbarShow( selectedBlockId ); + const { + shouldShowContextualToolbar, + canFocusHiddenToolbar, + fixedToolbarCanBeFocused, + } = useShouldContextualToolbarShow(); // If there's a block toolbar to be focused, disable the focus shortcut for the document toolbar. // There's a fixed block toolbar when the fixed toolbar option is enabled or when the browser width is less than the large viewport. const blockToolbarCanBeFocused = shouldShowContextualToolbar || canFocusHiddenToolbar || - ( ( hasFixedToolbar || ! isLargeViewport ) && selectedBlockId ); + fixedToolbarCanBeFocused; /* translators: accessibility text for the editor toolbar */ const toolbarAriaLabel = __( 'Document tools' ); diff --git a/packages/edit-post/src/components/header/index.js b/packages/edit-post/src/components/header/index.js index a7bdccbfe44d9..09a93424f6903 100644 --- a/packages/edit-post/src/components/header/index.js +++ b/packages/edit-post/src/components/header/index.js @@ -15,6 +15,7 @@ import HeaderToolbar from './header-toolbar'; import MoreMenu from './more-menu'; import PostPublishButtonOrToggle from './post-publish-button-or-toggle'; import { default as DevicePreview } from '../device-preview'; +import ViewLink from '../view-link'; import MainDashboardButton from './main-dashboard-button'; import { store as editPostStore } from '../../store'; import TemplateTitle from './template-title'; @@ -88,6 +89,7 @@ function Header( { setEntitiesSavedStatesCallback } ) { showIconLabels={ showIconLabels } /> ) } + -
+
diff --git a/packages/edit-post/src/components/keyboard-shortcuts/index.js b/packages/edit-post/src/components/keyboard-shortcuts/index.js index 13a989de7fd07..5344d9155c8a4 100644 --- a/packages/edit-post/src/components/keyboard-shortcuts/index.js +++ b/packages/edit-post/src/components/keyboard-shortcuts/index.js @@ -130,7 +130,7 @@ function KeyboardShortcuts() { registerShortcut( { name: 'core/edit-post/toggle-sidebar', category: 'global', - description: __( 'Show or hide the settings sidebar.' ), + description: __( 'Show or hide the Settings sidebar.' ), keyCombination: { modifier: 'primaryShift', character: ',', diff --git a/packages/edit-post/src/components/layout/style.scss b/packages/edit-post/src/components/layout/style.scss index 3fdb90dea8555..5e8cdb4fb76e1 100644 --- a/packages/edit-post/src/components/layout/style.scss +++ b/packages/edit-post/src/components/layout/style.scss @@ -109,5 +109,4 @@ z-index: 19; } } - } diff --git a/packages/edit-post/src/components/preferences-modal/test/__snapshots__/index.js.snap b/packages/edit-post/src/components/preferences-modal/test/__snapshots__/index.js.snap index c666ab81e0724..5a6ff1e5fffee 100644 --- a/packages/edit-post/src/components/preferences-modal/test/__snapshots__/index.js.snap +++ b/packages/edit-post/src/components/preferences-modal/test/__snapshots__/index.js.snap @@ -102,7 +102,9 @@ exports[`EditPostPreferencesModal should match snapshot when the modal is active
-
+
@@ -608,13 +610,13 @@ exports[`EditPostPreferencesModal should match snapshot when the modal is active } .emotion-13:hover { - color: var(--wp-components-color-accent, var(--wp-admin-theme-color, #007cba)); + color: var(--wp-components-color-accent, var(--wp-admin-theme-color, #3858e9)); } .emotion-13:focus { background-color: transparent; - color: var(--wp-components-color-accent, var(--wp-admin-theme-color, #007cba)); - border-color: var(--wp-components-color-accent, var(--wp-admin-theme-color, #007cba)); + color: var(--wp-components-color-accent, var(--wp-admin-theme-color, #3858e9)); + border-color: var(--wp-components-color-accent, var(--wp-admin-theme-color, #3858e9)); outline: 3px solid transparent; } @@ -723,7 +725,9 @@ exports[`EditPostPreferencesModal should match snapshot when the modal is active
-
+
{ fills } - + + + + ) } diff --git a/packages/edit-post/src/components/sidebar/post-trash/index.js b/packages/edit-post/src/components/sidebar/post-trash/index.js index a7094385c0265..885be537952c0 100644 --- a/packages/edit-post/src/components/sidebar/post-trash/index.js +++ b/packages/edit-post/src/components/sidebar/post-trash/index.js @@ -1,15 +1,15 @@ /** * WordPress dependencies */ -import { PanelRow } from '@wordpress/components'; import { PostTrash as PostTrashLink, PostTrashCheck } from '@wordpress/editor'; +import { FlexItem } from '@wordpress/components'; export default function PostTrash() { return ( - + - + ); } diff --git a/packages/edit-post/src/components/sidebar/settings-sidebar/index.js b/packages/edit-post/src/components/sidebar/settings-sidebar/index.js index 677161c6b5a38..8450fec022593 100644 --- a/packages/edit-post/src/components/sidebar/settings-sidebar/index.js +++ b/packages/edit-post/src/components/sidebar/settings-sidebar/index.js @@ -73,7 +73,7 @@ const SettingsSidebar = () => { } - closeLabel={ __( 'Close settings' ) } + closeLabel={ __( 'Close Settings' ) } headerClassName="edit-post-sidebar__panel-tabs" /* translators: button label text should, if possible, be under 16 characters. */ title={ __( 'Settings' ) } diff --git a/packages/edit-post/src/components/start-page-options/style.scss b/packages/edit-post/src/components/start-page-options/style.scss index bd1261e86a897..0e26c93e38637 100644 --- a/packages/edit-post/src/components/start-page-options/style.scss +++ b/packages/edit-post/src/components/start-page-options/style.scss @@ -4,7 +4,7 @@ column-gap: $grid-unit-30; // Small top padding required to avoid cutting off the visible outline when hovering items - padding-top: $border-width-focus; + padding-top: $border-width-focus-fallback; @include break-medium() { column-count: 3; diff --git a/packages/edit-post/src/components/view-link/index.js b/packages/edit-post/src/components/view-link/index.js new file mode 100644 index 0000000000000..10b10d8af1ee3 --- /dev/null +++ b/packages/edit-post/src/components/view-link/index.js @@ -0,0 +1,37 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Button } from '@wordpress/components'; +import { external } from '@wordpress/icons'; +import { store as editorStore } from '@wordpress/editor'; +import { store as coreStore } from '@wordpress/core-data'; +import { useSelect } from '@wordpress/data'; + +export default function ViewLink() { + const { permalink, isPublished, label } = useSelect( ( select ) => { + // Grab post type to retrieve the view_item label. + const postTypeSlug = select( editorStore ).getCurrentPostType(); + const postType = select( coreStore ).getPostType( postTypeSlug ); + + return { + permalink: select( editorStore ).getPermalink(), + isPublished: select( editorStore ).isCurrentPostPublished(), + label: postType?.labels.view_item, + }; + }, [] ); + + // Only render the view button if the post is published and has a permalink. + if ( ! isPublished || ! permalink ) { + return null; + } + + return ( + diff --git a/packages/edit-site/src/components/save-hub/index.js b/packages/edit-site/src/components/save-hub/index.js index 4d2df343d5ebe..0c99cfd9d4d44 100644 --- a/packages/edit-site/src/components/save-hub/index.js +++ b/packages/edit-site/src/components/save-hub/index.js @@ -1,41 +1,38 @@ /** * WordPress dependencies */ -import { useSelect, useDispatch } from '@wordpress/data'; -import { Button, __experimentalHStack as HStack } from '@wordpress/components'; -import { sprintf, __, _n } from '@wordpress/i18n'; +import { useSelect } from '@wordpress/data'; +import { __experimentalHStack as HStack } from '@wordpress/components'; +import { sprintf, _n } from '@wordpress/i18n'; import { store as coreStore } from '@wordpress/core-data'; -import { displayShortcut } from '@wordpress/keycodes'; import { check } from '@wordpress/icons'; /** * Internal dependencies */ -import { store as editSiteStore } from '../../store'; +import SaveButton from '../save-button'; +import { isPreviewingTheme } from '../../utils/is-previewing-theme'; -export default function SaveButton() { - const { countUnsavedChanges, isDirty, isSaving, isSaveViewOpen } = - useSelect( ( select ) => { +export default function SaveHub() { + const { countUnsavedChanges, isDirty, isSaving } = useSelect( + ( select ) => { const { __experimentalGetDirtyEntityRecords, isSavingEntityRecord, } = select( coreStore ); const dirtyEntityRecords = __experimentalGetDirtyEntityRecords(); - const { isSaveViewOpened } = select( editSiteStore ); return { isDirty: dirtyEntityRecords.length > 0, isSaving: dirtyEntityRecords.some( ( record ) => isSavingEntityRecord( record.kind, record.name, record.key ) ), - isSaveViewOpen: isSaveViewOpened(), countUnsavedChanges: dirtyEntityRecords.length, }; - }, [] ); - const { setIsSaveViewOpened } = useDispatch( editSiteStore ); - - const disabled = ! isDirty || isSaving; + }, + [] + ); - const label = disabled ? __( 'Saved' ) : __( 'Save' ); + const disabled = isSaving || ( ! isDirty && ! isPreviewingTheme() ); return ( @@ -52,27 +49,12 @@ export default function SaveButton() { ) } ) } - + variant={ disabled ? null : 'primary' } + showTooltip={ false } + icon={ disabled ? check : null } + /> ); } diff --git a/packages/edit-site/src/components/save-panel/index.js b/packages/edit-site/src/components/save-panel/index.js index 7b8993c0be495..fa9516f3599d9 100644 --- a/packages/edit-site/src/components/save-panel/index.js +++ b/packages/edit-site/src/components/save-panel/index.js @@ -17,6 +17,7 @@ import { NavigableRegion } from '@wordpress/interface'; */ import { store as editSiteStore } from '../../store'; import { unlock } from '../../private-apis'; +import { useActivateTheme } from '../../utils/use-activate-theme'; export default function SavePanel() { const { isSaveViewOpen, canvasMode } = useSelect( ( select ) => { @@ -32,7 +33,18 @@ export default function SavePanel() { }; }, [] ); const { setIsSaveViewOpened } = useDispatch( editSiteStore ); + const activateTheme = useActivateTheme(); const onClose = () => setIsSaveViewOpened( false ); + const onSave = async ( values ) => { + await activateTheme(); + return values; + }; + + const entitySavedStates = window?.__experimentalEnableThemePreviews ? ( + + ) : ( + + ); if ( canvasMode === 'view' ) { return isSaveViewOpen ? ( @@ -44,7 +56,7 @@ export default function SavePanel() { 'Save site, content, and template changes' ) } > - + { entitySavedStates } ) : null; } @@ -54,10 +66,10 @@ export default function SavePanel() { className={ classnames( 'edit-site-layout__actions', { 'is-entity-save-view-open': isSaveViewOpen, } ) } - ariaLabel={ __( 'Save sidebar' ) } + ariaLabel={ __( 'Save panel' ) } > { isSaveViewOpen ? ( - + entitySavedStates ) : (
diff --git a/packages/edit-site/src/components/sidebar-edit-mode/global-styles-sidebar.js b/packages/edit-site/src/components/sidebar-edit-mode/global-styles-sidebar.js index d22c08efb6f8c..ae124422f66ce 100644 --- a/packages/edit-site/src/components/sidebar-edit-mode/global-styles-sidebar.js +++ b/packages/edit-site/src/components/sidebar-edit-mode/global-styles-sidebar.js @@ -21,27 +21,29 @@ export default function GlobalStylesSidebar() { const { shouldClearCanvasContainerView, isStyleBookOpened } = useSelect( ( select ) => { const { getActiveComplementaryArea } = select( interfaceStore ); + const { getEditorCanvasContainerView, getCanvasMode } = unlock( + select( editSiteStore ) + ); const _isVisualEditorMode = 'visual' === select( editSiteStore ).getEditorMode(); + const _isEditCanvasMode = 'edit' === getCanvasMode(); return { isStyleBookOpened: - 'style-book' === - unlock( - select( editSiteStore ) - ).getEditorCanvasContainerView(), + 'style-book' === getEditorCanvasContainerView(), shouldClearCanvasContainerView: 'edit-site/global-styles' !== getActiveComplementaryArea( 'core/edit-site' ) || - ! _isVisualEditorMode, + ! _isVisualEditorMode || + ! _isEditCanvasMode, }; }, [] ); - const { setEditorCanvasContainerView } = unlock( useDispatch( editSiteStore ) ); + useEffect( () => { if ( shouldClearCanvasContainerView ) { setEditorCanvasContainerView( undefined ); @@ -54,7 +56,7 @@ export default function GlobalStylesSidebar() { identifier="edit-site/global-styles" title={ __( 'Styles' ) } icon={ styles } - closeLabel={ __( 'Close Styles sidebar' ) } + closeLabel={ __( 'Close Styles' ) } panelClassName="edit-site-global-styles-sidebar__panel" header={ diff --git a/packages/edit-site/src/components/sidebar-edit-mode/index.js b/packages/edit-site/src/components/sidebar-edit-mode/index.js index 48574b783f5a2..5086981f87144 100644 --- a/packages/edit-site/src/components/sidebar-edit-mode/index.js +++ b/packages/edit-site/src/components/sidebar-edit-mode/index.js @@ -1,10 +1,10 @@ /** * WordPress dependencies */ -import { createSlotFill, PanelBody } from '@wordpress/components'; +import { createSlotFill, PanelBody, PanelRow } from '@wordpress/components'; import { isRTL, __ } from '@wordpress/i18n'; import { drawerLeft, drawerRight } from '@wordpress/icons'; -import { useEffect, Fragment } from '@wordpress/element'; +import { useEffect } from '@wordpress/element'; import { useSelect, useDispatch } from '@wordpress/data'; import { store as interfaceStore } from '@wordpress/interface'; import { store as blockEditorStore } from '@wordpress/block-editor'; @@ -16,7 +16,9 @@ import DefaultSidebar from './default-sidebar'; import GlobalStylesSidebar from './global-styles-sidebar'; import { STORE_NAME } from '../../store/constants'; import SettingsHeader from './settings-header'; +import LastRevision from './template-revisions'; import TemplateCard from './template-card'; +import PluginTemplateSettingPanel from '../plugin-template-setting-panel'; import { SIDEBAR_BLOCK, SIDEBAR_TEMPLATE } from './constants'; import { store as editSiteStore } from '../../store'; @@ -69,14 +71,23 @@ export function SidebarComplementaryAreaFills() { identifier={ sidebarName } title={ __( 'Settings' ) } icon={ isRTL() ? drawerLeft : drawerRight } - closeLabel={ __( 'Close settings' ) } + closeLabel={ __( 'Close Settings' ) } header={ } headerClassName="edit-site-sidebar-edit-mode__panel-tabs" > { sidebarName === SIDEBAR_TEMPLATE && ( - - - + <> + + + + + + + + ) } { sidebarName === SIDEBAR_BLOCK && ( diff --git a/packages/edit-site/src/components/sidebar-edit-mode/sidebar-fixed-bottom.js b/packages/edit-site/src/components/sidebar-edit-mode/sidebar-fixed-bottom.js new file mode 100644 index 0000000000000..c44b8c9c85c7f --- /dev/null +++ b/packages/edit-site/src/components/sidebar-edit-mode/sidebar-fixed-bottom.js @@ -0,0 +1,26 @@ +/** + * WordPress dependencies + */ +import { privateApis as componentsPrivateApis } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { unlock } from '../../private-apis'; + +const { createPrivateSlotFill } = unlock( componentsPrivateApis ); +const SIDEBAR_FIXED_BOTTOM_SLOT_FILL_NAME = 'SidebarFixedBottom'; +const { Slot: SidebarFixedBottomSlot, Fill: SidebarFixedBottomFill } = + createPrivateSlotFill( SIDEBAR_FIXED_BOTTOM_SLOT_FILL_NAME ); + +export default function SidebarFixedBottom( { children } ) { + return ( + +
+ { children } +
+
+ ); +} + +export { SidebarFixedBottomSlot }; diff --git a/packages/edit-site/src/components/sidebar-edit-mode/style.scss b/packages/edit-site/src/components/sidebar-edit-mode/style.scss index eeb5dc2d170cd..544c38e0ef07b 100644 --- a/packages/edit-site/src/components/sidebar-edit-mode/style.scss +++ b/packages/edit-site/src/components/sidebar-edit-mode/style.scss @@ -99,3 +99,13 @@ } } } + +.edit-site-sidebar-fixed-bottom-slot { + position: sticky; + bottom: 0; + background: $white; + display: flex; + padding: $grid-unit-20; + border-top: $border-width solid $gray-300; + box-sizing: content-box; +} diff --git a/packages/edit-site/src/components/sidebar-edit-mode/template-card/index.js b/packages/edit-site/src/components/sidebar-edit-mode/template-card/index.js index da03a769b306c..d43dca3b803f5 100644 --- a/packages/edit-site/src/components/sidebar-edit-mode/template-card/index.js +++ b/packages/edit-site/src/components/sidebar-edit-mode/template-card/index.js @@ -1,9 +1,8 @@ /** * WordPress dependencies */ -import { __ } from '@wordpress/i18n'; import { useSelect } from '@wordpress/data'; -import { PanelRow, Icon } from '@wordpress/components'; +import { Icon } from '@wordpress/components'; import { store as editorStore } from '@wordpress/editor'; import { store as coreStore } from '@wordpress/core-data'; import { decodeEntities } from '@wordpress/html-entities'; @@ -14,7 +13,6 @@ import { decodeEntities } from '@wordpress/html-entities'; import { store as editSiteStore } from '../../../store'; import TemplateActions from './template-actions'; import TemplateAreas from './template-areas'; -import LastRevision from './last-revision'; export default function TemplateCard() { const { @@ -56,12 +54,6 @@ export default function TemplateCard() {
- - - ); } diff --git a/packages/edit-site/src/components/sidebar-edit-mode/template-card/last-revision.js b/packages/edit-site/src/components/sidebar-edit-mode/template-revisions/index.js similarity index 100% rename from packages/edit-site/src/components/sidebar-edit-mode/template-card/last-revision.js rename to packages/edit-site/src/components/sidebar-edit-mode/template-revisions/index.js diff --git a/packages/edit-site/src/components/sidebar-navigation-item/index.js b/packages/edit-site/src/components/sidebar-navigation-item/index.js index fb5cfdf1b32f8..244efdf0f869c 100644 --- a/packages/edit-site/src/components/sidebar-navigation-item/index.js +++ b/packages/edit-site/src/components/sidebar-navigation-item/index.js @@ -11,7 +11,7 @@ import { __experimentalHStack as HStack, FlexBlock, } from '@wordpress/components'; -import { chevronRight, Icon } from '@wordpress/icons'; +import { chevronRightSmall, Icon } from '@wordpress/icons'; export default function SidebarNavigationItem( { className, @@ -28,24 +28,23 @@ export default function SidebarNavigationItem( { ) } { ...props } > - { icon && ( - + + { icon && ( - { children } - { withChevron && ( - - ) } - - ) } - { ! icon && children } + ) } + { children } + { withChevron && ( + + ) } + ); } diff --git a/packages/edit-site/src/components/sidebar-navigation-item/style.scss b/packages/edit-site/src/components/sidebar-navigation-item/style.scss index 04c11aa57f5a1..fd9da59eaa888 100644 --- a/packages/edit-site/src/components/sidebar-navigation-item/style.scss +++ b/packages/edit-site/src/components/sidebar-navigation-item/style.scss @@ -1,6 +1,9 @@ .edit-site-sidebar-navigation-item.components-item { color: $gray-600; - margin: 0 $grid-unit-05; + // 6px right padding to align with + button + padding: $grid-unit-10 6px $grid-unit-10 $grid-unit-20; + border: none; + min-height: $grid-unit-50; &:hover, &:focus, @@ -12,6 +15,10 @@ &[aria-current] { background: var(--wp-admin-theme-color); } + + .edit-site-sidebar-navigation-item__drilldown-indicator { + fill: $gray-700; + } } .edit-site-sidebar-navigation-screen__content .block-editor-list-view-block-select-button { diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-main/index.js b/packages/edit-site/src/components/sidebar-navigation-screen-main/index.js index 8f419c0919666..a35e07220d52c 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-main/index.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen-main/index.js @@ -6,7 +6,7 @@ import { __experimentalNavigatorButton as NavigatorButton, } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; -import { layout, symbolFilled, navigation, styles } from '@wordpress/icons'; +import { layout, symbol, navigation, styles, page } from '@wordpress/icons'; import { useSelect } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; @@ -17,23 +17,33 @@ import SidebarNavigationScreen from '../sidebar-navigation-screen'; import SidebarNavigationItem from '../sidebar-navigation-item'; export default function SidebarNavigationScreenMain() { - const hasNavigationMenus = useSelect( ( select ) => { - // The query needs to be the same as in the "SidebarNavigationScreenNavigationMenus" component, - // to avoid double network calls. - const navigationMenus = select( coreStore ).getEntityRecords( - 'postType', - 'wp_navigation', - { - per_page: 1, - status: 'publish', - order: 'desc', - orderby: 'date', - } - ); - - return navigationMenus?.length > 0; - } ); - + const { hasNavigationMenus, hasGlobalStyleVariations } = useSelect( + ( select ) => { + const { + getEntityRecords, + __experimentalGetCurrentThemeGlobalStylesVariations, + } = select( coreStore ); + // The query needs to be the same as in the "SidebarNavigationScreenNavigationMenus" component, + // to avoid double network calls. + const navigationMenus = getEntityRecords( + 'postType', + 'wp_navigation', + { + per_page: 1, + status: 'publish', + order: 'desc', + orderby: 'date', + } + ); + return { + hasNavigationMenus: !! navigationMenus?.length, + hasGlobalStyleVariations: + !! __experimentalGetCurrentThemeGlobalStylesVariations() + ?.length, + }; + }, + [] + ); const showNavigationScreen = process.env.IS_GUTENBERG_PLUGIN ? hasNavigationMenus : false; @@ -56,13 +66,24 @@ export default function SidebarNavigationScreenMain() { { __( 'Navigation' ) } ) } + { hasGlobalStyleVariations && ( + + { __( 'Styles' ) } + + ) } + - { __( 'Styles' ) } + { __( 'Pages' ) } { __( 'Template Parts' ) } diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-navigation-item/index.js b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-item/index.js index 549ed42d408a6..35e662a23c33d 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-navigation-item/index.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-item/index.js @@ -36,15 +36,9 @@ export default function SidebarNavigationScreenNavigationItem() { icon={ pencil } /> } - description={ - postType === 'page' - ? __( - 'Pages are static and are not listed by date. Pages do not use tags or categories.' - ) - : __( - 'Posts are entries listed in reverse chronological order on the site homepage or on the posts page.' - ) - } + description={ __( + 'Posts are entries listed in reverse chronological order on the site homepage or on the posts page.' + ) } content={ <> { record?.link ? ( diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/index.js b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/index.js index 50175a695283a..8cf795adad096 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/index.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/index.js @@ -7,16 +7,22 @@ import { useSelect } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; import { BlockEditorProvider } from '@wordpress/block-editor'; import { createBlock } from '@wordpress/blocks'; +import { privateApis as routerPrivateApis } from '@wordpress/router'; /** * Internal dependencies */ import SidebarNavigationScreen from '../sidebar-navigation-screen'; -import { useHistory } from '../routes'; import NavigationMenuContent from './navigation-menu-content'; import { NavigationMenuLoader } from './loader'; import { unlock } from '../../private-apis'; import { store as editSiteStore } from '../../store'; +import { + isPreviewingTheme, + currentlyPreviewingTheme, +} from '../../utils/is-previewing-theme'; + +const { useHistory } = unlock( routerPrivateApis ); const noop = () => {}; const NAVIGATION_MENUS_QUERY = { @@ -84,12 +90,18 @@ export default function SidebarNavigationScreenNavigationMenus() { history.push( { postType: attributes.type, postId: attributes.id, + ...( isPreviewingTheme() && { + theme_preview: currentlyPreviewingTheme(), + } ), } ); } if ( name === 'core/page-list-item' && attributes.id && history ) { history.push( { postType: 'page', postId: attributes.id, + ...( isPreviewingTheme() && { + theme_preview: currentlyPreviewingTheme(), + } ), } ); } }, diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/navigation-menu-content.js b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/navigation-menu-content.js index 2981c57c19fd1..d7d4e18fc2b51 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/navigation-menu-content.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/navigation-menu-content.js @@ -144,7 +144,7 @@ export default function NavigationMenuContent( { rootClientId, onSelect } ) { }; }, [ shouldKeepLoading, clientIdsTree, isLoading ] ); - const { OffCanvasEditor, LeafMoreMenu } = unlock( blockEditorPrivateApis ); + const { PrivateListView, LeafMoreMenu } = unlock( blockEditorPrivateApis ); const offCanvasOnselect = useCallback( ( block ) => { @@ -181,14 +181,14 @@ export default function NavigationMenuContent( { rootClientId, onSelect } ) { <> { isLoading && } { ! isLoading && ( - diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-page/index.js b/packages/edit-site/src/components/sidebar-navigation-screen-page/index.js new file mode 100644 index 0000000000000..5acfc98ffe52c --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-page/index.js @@ -0,0 +1,59 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useDispatch } from '@wordpress/data'; +import { + __experimentalUseNavigator as useNavigator, + ExternalLink, +} from '@wordpress/components'; +import { useEntityRecord } from '@wordpress/core-data'; +import { decodeEntities } from '@wordpress/html-entities'; +import { pencil } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import SidebarNavigationScreen from '../sidebar-navigation-screen'; +import { unlock } from '../../private-apis'; +import { store as editSiteStore } from '../../store'; +import SidebarButton from '../sidebar-button'; + +export default function SidebarNavigationScreenPage() { + const { setCanvasMode } = unlock( useDispatch( editSiteStore ) ); + const { + params: { postId }, + } = useNavigator(); + const { record } = useEntityRecord( 'postType', 'page', postId ); + + return ( + setCanvasMode( 'edit' ) } + label={ __( 'Edit' ) } + icon={ pencil } + /> + } + description={ __( + 'Pages are static and are not listed by date. Pages do not use tags or categories.' + ) } + content={ + <> + { record?.link ? ( + + { record.link } + + ) : null } + { record + ? decodeEntities( record?.description?.rendered ) + : null } + + } + /> + ); +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-pages/index.js b/packages/edit-site/src/components/sidebar-navigation-screen-pages/index.js new file mode 100644 index 0000000000000..5f4958f44d251 --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-pages/index.js @@ -0,0 +1,82 @@ +/** + * WordPress dependencies + */ +import { + __experimentalItemGroup as ItemGroup, + __experimentalItem as Item, +} from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { useEntityRecords } from '@wordpress/core-data'; +import { decodeEntities } from '@wordpress/html-entities'; + +/** + * Internal dependencies + */ +import SidebarNavigationScreen from '../sidebar-navigation-screen'; +import { useLink } from '../routes/link'; +import SidebarNavigationItem from '../sidebar-navigation-item'; +import SidebarNavigationSubtitle from '../sidebar-navigation-subtitle'; + +const PageItem = ( { postId, ...props } ) => { + const linkInfo = useLink( { + postType: 'page', + postId, + } ); + return ; +}; + +export default function SidebarNavigationScreenPages() { + const { records: pages, isResolving: isLoading } = useEntityRecords( + 'postType', + 'page' + ); + + return ( + + { isLoading && ( + + { __( 'Loading pages' ) } + + ) } + { ! isLoading && ( + <> + + { __( 'Recent' ) } + + + { ! pages?.length && ( + { __( 'No page found' ) } + ) } + { pages?.map( ( page ) => ( + + { decodeEntities( + page.title?.rendered + ) ?? __( '(no title)' ) } + + ) ) } + { + document.location = + 'edit.php?post_type=page'; + } } + > + { __( 'Manage all pages' ) } + + + + ) } + + } + /> + ); +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-pages/style.scss b/packages/edit-site/src/components/sidebar-navigation-screen-pages/style.scss new file mode 100644 index 0000000000000..7bbdd103b6bce --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-pages/style.scss @@ -0,0 +1,4 @@ +.edit-site-sidebar-navigation-screen-pages__see-all { + /* Overrides the margin that comes from the Item component */ + margin-top: $grid-unit-20 !important; +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-templates/index.js b/packages/edit-site/src/components/sidebar-navigation-screen-templates/index.js index 1a0ce5e050cb9..8c9c563888473 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-templates/index.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen-templates/index.js @@ -74,7 +74,9 @@ export default function SidebarNavigationScreenTemplates() { } ); const sortedTemplates = templates ? [ ...templates ] : []; - sortedTemplates.sort( ( a, b ) => a.slug.localeCompare( b.slug ) ); + sortedTemplates.sort( ( a, b ) => + a.title.rendered.localeCompare( b.title.rendered ) + ); const browseAllLink = useLink( { path: '/' + postType + '/all', @@ -112,6 +114,7 @@ export default function SidebarNavigationScreenTemplates() { postType={ postType } postId={ template.id } key={ template.id } + withChevron > { decodeEntities( template.title?.rendered || @@ -126,6 +129,7 @@ export default function SidebarNavigationScreenTemplates() { children={ config[ postType ].labels.manage } + withChevron /> ) } diff --git a/packages/edit-site/src/components/sidebar-navigation-screen/index.js b/packages/edit-site/src/components/sidebar-navigation-screen/index.js index 4e41317f3074c..77b9bedba3e65 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen/index.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen/index.js @@ -7,8 +7,9 @@ import { __experimentalNavigatorToParentButton as NavigatorToParentButton, __experimentalHeading as Heading, } from '@wordpress/components'; -import { isRTL, __ } from '@wordpress/i18n'; +import { isRTL, __, sprintf } from '@wordpress/i18n'; import { chevronRight, chevronLeft } from '@wordpress/icons'; +import { store as coreStore } from '@wordpress/core-data'; import { useSelect } from '@wordpress/data'; /** @@ -17,6 +18,10 @@ import { useSelect } from '@wordpress/data'; import { store as editSiteStore } from '../../store'; import { unlock } from '../../private-apis'; import SidebarButton from '../sidebar-button'; +import { + isPreviewingTheme, + currentlyPreviewingTheme, +} from '../../utils/is-previewing-theme'; export default function SidebarNavigationScreen( { isRoot, @@ -31,6 +36,8 @@ export default function SidebarNavigationScreen( { dashboardLink: getSettings().__experimentalDashboardLink, }; }, [] ); + const { getTheme } = useSelect( coreStore ); + const theme = getTheme( currentlyPreviewingTheme() ); return ( @@ -43,13 +50,21 @@ export default function SidebarNavigationScreen( { ) : ( ) } - { title } + { ! isPreviewingTheme() + ? title + : sprintf( + 'Previewing %1$s: %2$s', + theme?.name?.rendered, + title + ) } { actions } diff --git a/packages/edit-site/src/components/sidebar-navigation-subtitle/index.js b/packages/edit-site/src/components/sidebar-navigation-subtitle/index.js new file mode 100644 index 0000000000000..2a20f31ce7fb4 --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-subtitle/index.js @@ -0,0 +1,5 @@ +export default function SidebarNavigationSubtitle( { children } ) { + return ( +

{ children }

+ ); +} diff --git a/packages/edit-site/src/components/sidebar-navigation-subtitle/style.scss b/packages/edit-site/src/components/sidebar-navigation-subtitle/style.scss new file mode 100644 index 0000000000000..735145ca1d80c --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-subtitle/style.scss @@ -0,0 +1,7 @@ +.edit-site-sidebar-navigation-subtitle { + color: $gray-100; + text-transform: uppercase; + font-weight: 500; + font-size: 11px; + padding: $grid-unit-20 0 0 $grid-unit-20; +} diff --git a/packages/edit-site/src/components/sidebar/index.js b/packages/edit-site/src/components/sidebar/index.js index a247083e2152d..9571d6b54b403 100644 --- a/packages/edit-site/src/components/sidebar/index.js +++ b/packages/edit-site/src/components/sidebar/index.js @@ -6,6 +6,7 @@ import { __experimentalNavigatorProvider as NavigatorProvider, __experimentalNavigatorScreen as NavigatorScreen, } from '@wordpress/components'; +import { privateApis as routerPrivateApis } from '@wordpress/router'; /** * Internal dependencies @@ -21,7 +22,11 @@ import SidebarNavigationScreenGlobalStyles from '../sidebar-navigation-screen-gl import SidebarNavigationScreenTemplatesBrowse from '../sidebar-navigation-screen-templates-browse'; import SaveHub from '../save-hub'; import SidebarNavigationScreenNavigationItem from '../sidebar-navigation-screen-navigation-item'; -import { useLocation } from '../routes'; +import { unlock } from '../../private-apis'; +import SidebarNavigationScreenPages from '../sidebar-navigation-screen-pages'; +import SidebarNavigationScreenPage from '../sidebar-navigation-screen-page'; + +const { useLocation } = unlock( routerPrivateApis ); function SidebarScreens() { useSyncPathWithURL(); @@ -40,6 +45,12 @@ function SidebarScreens() { + + + + + + diff --git a/packages/edit-site/src/components/site-hub/index.js b/packages/edit-site/src/components/site-hub/index.js index c20a6ad202f5d..eadd9bbad68b9 100644 --- a/packages/edit-site/src/components/site-hub/index.js +++ b/packages/edit-site/src/components/site-hub/index.js @@ -19,6 +19,8 @@ import { store as blockEditorStore } from '@wordpress/block-editor'; import { store as coreStore } from '@wordpress/core-data'; import { decodeEntities } from '@wordpress/html-entities'; import { forwardRef } from '@wordpress/element'; +import { search } from '@wordpress/icons'; +import { privateApis as commandsPrivateApis } from '@wordpress/commands'; /** * Internal dependencies @@ -27,30 +29,46 @@ import { store as editSiteStore } from '../../store'; import SiteIcon from '../site-icon'; import { unlock } from '../../private-apis'; +const { store: commandsStore } = unlock( commandsPrivateApis ); + const HUB_ANIMATION_DURATION = 0.3; const SiteHub = forwardRef( ( props, ref ) => { - const { canvasMode } = useSelect( ( select ) => { + const { canvasMode, dashboardLink } = useSelect( ( select ) => { const { getCanvasMode, getSettings } = unlock( select( editSiteStore ) ); + return { canvasMode: getCanvasMode(), - dashboardLink: getSettings().__experimentalDashboardLink, + dashboardLink: + getSettings().__experimentalDashboardLink || 'index.php', }; }, [] ); + const { open: openCommandCenter } = useDispatch( commandsStore ); + const disableMotion = useReducedMotion(); const { setCanvasMode } = unlock( useDispatch( editSiteStore ) ); const { clearSelectedBlock } = useDispatch( blockEditorStore ); - const siteIconButtonProps = { - label: __( 'Open Admin Sidebar' ), - onClick: () => { - if ( canvasMode === 'edit' ) { - clearSelectedBlock(); - setCanvasMode( 'view' ); - } - }, - }; + const isBackToDashboardButton = canvasMode === 'view'; + const siteIconButtonProps = isBackToDashboardButton + ? { + href: dashboardLink, + label: __( 'Go back to the Dashboard' ), + } + : { + href: dashboardLink, // We need to keep the `href` here so the component doesn't remount as a ` + + + - + { decodeEntities( siteTitle ) } - - - - - - { decodeEntities( siteTitle ) } - - + + + { window?.__experimentalEnableCommandCenter && + canvasMode === 'view' && ( +
+ + + + + ); } diff --git a/packages/edit-site/src/components/start-template-options/style.scss b/packages/edit-site/src/components/start-template-options/style.scss index 9537eaf389a61..715e829ecab3a 100644 --- a/packages/edit-site/src/components/start-template-options/style.scss +++ b/packages/edit-site/src/components/start-template-options/style.scss @@ -1,9 +1,37 @@ +.edit-site-start-template-options__modal { + .components-modal__content { + padding-bottom: 0; + } + + .components-modal__children-container { + display: flex; + height: 100%; + flex-direction: column; + + .edit-site-start-template-options__modal__actions { + margin-top: auto; + position: sticky; + bottom: 0; + background-color: $white; + margin-left: - $grid-unit-40; + margin-right: - $grid-unit-40; + padding: $grid-unit-30 $grid-unit-40 $grid-unit-40; + border-top: 1px solid $gray-300; + z-index: z-index(".edit-site-start-template-options__modal__actions"); + } + } + + .block-editor-block-patterns-list { + padding-bottom: $grid-unit-40; + } +} + .edit-site-start-template-options__modal-content .block-editor-block-patterns-list { column-count: 2; column-gap: $grid-unit-30; // Small top padding required to avoid cutting off the visible outline when hovering items - padding-top: $border-width-focus; + padding-top: $border-width-focus-fallback; @include break-medium() { column-count: 3; @@ -28,24 +56,4 @@ box-shadow: 0 0 0 1px $gray-300; } } - - // The start blank pattern is the last and we are selecting it. - .block-editor-block-patterns-list__list-item:nth-last-child(2) { - .block-editor-block-preview__container { - position: absolute; - padding: 0; - background: #f0f0f0; - min-height: $grid-unit-50 * 4; - &::after { - width: 100%; - top: 50%; - margin-top: -1em; - content: var(--wp-edit-site-start-template-options-start-blank); - text-align: center; - } - } - iframe { - display: none; - } - } } diff --git a/packages/edit-site/src/components/sync-state-with-url/use-init-edited-entity-from-url.js b/packages/edit-site/src/components/sync-state-with-url/use-init-edited-entity-from-url.js index 256280859c044..4acd381fca85f 100644 --- a/packages/edit-site/src/components/sync-state-with-url/use-init-edited-entity-from-url.js +++ b/packages/edit-site/src/components/sync-state-with-url/use-init-edited-entity-from-url.js @@ -4,12 +4,15 @@ import { useEffect } from '@wordpress/element'; import { useSelect, useDispatch } from '@wordpress/data'; import { store as coreDataStore } from '@wordpress/core-data'; +import { privateApis as routerPrivateApis } from '@wordpress/router'; /** * Internal dependencies */ -import { useLocation } from '../routes'; import { store as editSiteStore } from '../../store'; +import { unlock } from '../../private-apis'; + +const { useLocation } = unlock( routerPrivateApis ); export default function useInitEditedEntityFromURL() { const { params: { postId, postType } = {} } = useLocation(); diff --git a/packages/edit-site/src/components/sync-state-with-url/use-sync-canvas-mode-with-url.js b/packages/edit-site/src/components/sync-state-with-url/use-sync-canvas-mode-with-url.js index 98dd124604fe5..a541ec652fee8 100644 --- a/packages/edit-site/src/components/sync-state-with-url/use-sync-canvas-mode-with-url.js +++ b/packages/edit-site/src/components/sync-state-with-url/use-sync-canvas-mode-with-url.js @@ -3,14 +3,16 @@ */ import { useEffect, useRef } from '@wordpress/element'; import { useSelect, useDispatch } from '@wordpress/data'; +import { privateApis as routerPrivateApis } from '@wordpress/router'; /** * Internal dependencies */ import { store as editSiteStore } from '../../store'; -import { useLocation, useHistory } from '../routes'; import { unlock } from '../../private-apis'; +const { useLocation, useHistory } = unlock( routerPrivateApis ); + export default function useSyncCanvasModeWithURL() { const history = useHistory(); const { params } = useLocation(); diff --git a/packages/edit-site/src/components/sync-state-with-url/use-sync-path-with-url.js b/packages/edit-site/src/components/sync-state-with-url/use-sync-path-with-url.js index e56d0dd5f2724..04cdef425716f 100644 --- a/packages/edit-site/src/components/sync-state-with-url/use-sync-path-with-url.js +++ b/packages/edit-site/src/components/sync-state-with-url/use-sync-path-with-url.js @@ -3,11 +3,14 @@ */ import { __experimentalUseNavigator as useNavigator } from '@wordpress/components'; import { useEffect, useRef } from '@wordpress/element'; +import { privateApis as routerPrivateApis } from '@wordpress/router'; /** * Internal dependencies */ -import { useLocation, useHistory } from '../routes'; +import { unlock } from '../../private-apis'; + +const { useLocation, useHistory } = unlock( routerPrivateApis ); export function getPathFromURL( urlParams ) { let path = urlParams?.path ?? '/'; @@ -17,6 +20,7 @@ export function getPathFromURL( urlParams ) { switch ( urlParams.postType ) { case 'wp_template': case 'wp_template_part': + case 'page': path = `/${ encodeURIComponent( urlParams.postType ) }/${ encodeURIComponent( urlParams.postId ) }`; @@ -73,6 +77,15 @@ export default function useSyncPathWithURL() { postId: navigatorParams?.postId, path: undefined, } ); + } else if ( + navigatorLocation.path.startsWith( '/page/' ) && + navigatorParams?.postId + ) { + updateUrlParams( { + postType: 'page', + postId: navigatorParams?.postId, + path: undefined, + } ); } else { updateUrlParams( { postType: undefined, diff --git a/packages/edit-site/src/components/template-details/template-areas.js b/packages/edit-site/src/components/template-details/template-areas.js index dfd8e33ad577d..2c6a509ddc9d6 100644 --- a/packages/edit-site/src/components/template-details/template-areas.js +++ b/packages/edit-site/src/components/template-details/template-areas.js @@ -7,14 +7,17 @@ import { useSelect, useDispatch } from '@wordpress/data'; import { store as editorStore } from '@wordpress/editor'; import { store as blockEditorStore } from '@wordpress/block-editor'; import { moreVertical } from '@wordpress/icons'; +import { privateApis as routerPrivateApis } from '@wordpress/router'; /** * Internal dependencies */ import { store as editSiteStore } from '../../store'; import isTemplateRevertable from '../../utils/is-template-revertable'; -import { useLocation } from '../routes'; import { useLink } from '../routes/link'; +import { unlock } from '../../private-apis'; + +const { useLocation } = unlock( routerPrivateApis ); function TemplatePartItemMore( { onClose, diff --git a/packages/edit-site/src/hooks/commands/index.js b/packages/edit-site/src/hooks/commands/index.js deleted file mode 100644 index 3396f12232b8d..0000000000000 --- a/packages/edit-site/src/hooks/commands/index.js +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Internal dependencies - */ -import { useNavigationCommands } from './use-navigation-commands'; -import { useWPAdminCommands } from './use-wp-admin-commands'; - -export function useCommands() { - useWPAdminCommands(); - useNavigationCommands(); -} diff --git a/packages/edit-site/src/hooks/commands/use-wp-admin-commands.js b/packages/edit-site/src/hooks/commands/use-wp-admin-commands.js deleted file mode 100644 index 81337b85684ea..0000000000000 --- a/packages/edit-site/src/hooks/commands/use-wp-admin-commands.js +++ /dev/null @@ -1,79 +0,0 @@ -/** - * WordPress dependencies - */ -import { privateApis } from '@wordpress/commands'; -import { __, sprintf } from '@wordpress/i18n'; -import { useSelect } from '@wordpress/data'; -import { addQueryArgs } from '@wordpress/url'; -import { useMemo } from '@wordpress/element'; -import { plus } from '@wordpress/icons'; - -/** - * Internal dependencies - */ -import { store as editSiteStore } from '../../store'; -import { unlock } from '../../private-apis'; - -const { useCommandLoader } = unlock( privateApis ); - -const getWPAdminAddCommandLoader = ( postType ) => - function useAddCommandLoader( { search } ) { - let label; - if ( postType === 'post' ) { - label = __( 'Add a new post' ); - } else if ( postType === 'page' ) { - label = __( 'Add a new page' ); - } else { - throw 'unsupported post type ' + postType; - } - const hasRecordTitle = - !! search && ! label.toLowerCase().includes( search.toLowerCase() ); - if ( postType === 'post' && hasRecordTitle ) { - /* translators: %s: Post title placeholder */ - label = sprintf( __( 'Add a new post "%s"' ), search ); - } else if ( postType === 'page' && hasRecordTitle ) { - /* translators: %s: Page title placeholder */ - label = sprintf( __( 'Add a new page "%s"' ), search ); - } - - const newPostLink = useSelect( ( select ) => { - const { getSettings } = unlock( select( editSiteStore ) ); - return getSettings().newPostLink ?? 'post-new.php'; - }, [] ); - - const commands = useMemo( - () => [ - { - name: 'core/wp-admin/add-' + postType + '-' + search, - label, - icon: plus, - callback: () => { - document.location.href = addQueryArgs( newPostLink, { - post_type: postType, - post_title: hasRecordTitle ? search : undefined, - } ); - }, - }, - ], - [ newPostLink, hasRecordTitle, search, label ] - ); - - return { - isLoading: false, - commands, - }; - }; - -const useAddPostLoader = getWPAdminAddCommandLoader( 'post' ); -const useAddPageLoader = getWPAdminAddCommandLoader( 'page' ); - -export function useWPAdminCommands() { - useCommandLoader( { - name: 'core/wp-admin/add-post-loader', - hook: useAddPostLoader, - } ); - useCommandLoader( { - name: 'core/wp-admin/add-page-loader', - hook: useAddPageLoader, - } ); -} diff --git a/packages/edit-site/src/hooks/template-part-edit.js b/packages/edit-site/src/hooks/template-part-edit.js index c99daa2d084b6..e59cf49acefc2 100644 --- a/packages/edit-site/src/hooks/template-part-edit.js +++ b/packages/edit-site/src/hooks/template-part-edit.js @@ -8,12 +8,15 @@ import { store as coreStore } from '@wordpress/core-data'; import { ToolbarButton } from '@wordpress/components'; import { addFilter } from '@wordpress/hooks'; import { createHigherOrderComponent } from '@wordpress/compose'; +import { privateApis as routerPrivateApis } from '@wordpress/router'; /** * Internal dependencies */ -import { useLocation } from '../components/routes'; import { useLink } from '../components/routes/link'; +import { unlock } from '../private-apis'; + +const { useLocation } = unlock( routerPrivateApis ); function EditTemplatePartMenuItem( { attributes } ) { const { theme, slug } = attributes; diff --git a/packages/edit-site/src/index.js b/packages/edit-site/src/index.js index 264288b5ec627..e696a441bb10d 100644 --- a/packages/edit-site/src/index.js +++ b/packages/edit-site/src/index.js @@ -105,3 +105,4 @@ export function reinitializeEditor() { export { default as PluginSidebar } from './components/sidebar-edit-mode/plugin-sidebar'; export { default as PluginSidebarMoreMenuItem } from './components/header-edit-mode/plugin-sidebar-more-menu-item'; export { default as PluginMoreMenuItem } from './components/header-edit-mode/plugin-more-menu-item'; +export { default as PluginTemplateSettingPanel } from './components/plugin-template-setting-panel'; diff --git a/packages/edit-site/src/store/test/actions.js b/packages/edit-site/src/store/test/actions.js index 48b7d1ed71d70..2df1cc72b6611 100644 --- a/packages/edit-site/src/store/test/actions.js +++ b/packages/edit-site/src/store/test/actions.js @@ -168,21 +168,13 @@ describe( 'actions', () => { const ID = 'emptytheme//single'; const SLUG = 'single'; - window.fetch = async ( path ) => { - if ( path === '/?_wp-find-template=true' ) { - return { - json: async () => ( { data: { id: ID, slug: SLUG } } ), - }; - } - - throw { - code: 'unknown_path', - message: `Unknown path: ${ path }`, - }; - }; - apiFetch.setFetchHandler( async ( options ) => { - const { method = 'GET', path } = options; + const { method = 'GET', path, url } = options; + + // Called with url arg in `__experimentalGetTemplateForLink` + if ( url ) { + return { data: { id: ID, slug: SLUG } }; + } if ( method === 'GET' ) { if ( path.startsWith( '/wp/v2/types' ) ) { diff --git a/packages/edit-site/src/style.scss b/packages/edit-site/src/style.scss index 30abf4057b80e..679d13a08277a 100644 --- a/packages/edit-site/src/style.scss +++ b/packages/edit-site/src/style.scss @@ -5,6 +5,7 @@ @import "./components/canvas-spinner/style.scss"; @import "./components/code-editor/style.scss"; @import "./components/global-styles/style.scss"; +@import "./components/global-styles/screen-revisions/style.scss"; @import "./components/header-edit-mode/style.scss"; @import "./components/header-edit-mode/document-actions/style.scss"; @import "./components/list/style.scss"; @@ -25,8 +26,10 @@ @import "./components/sidebar-button/style.scss"; @import "./components/sidebar-navigation-item/style.scss"; @import "./components/sidebar-navigation-screen/style.scss"; +@import "./components/sidebar-navigation-screen-pages/style.scss"; @import "./components/sidebar-navigation-screen-template/style.scss"; @import "./components/sidebar-navigation-screen-templates/style.scss"; +@import "./components/sidebar-navigation-subtitle/style.scss"; @import "./components/site-hub/style.scss"; @import "./components/sidebar-navigation-screen-navigation-menus/style.scss"; @import "./components/site-icon/style.scss"; @@ -85,7 +88,7 @@ body.site-editor-php { } .interface-interface-skeleton__content { - background-color: $gray-800; + background-color: $gray-900; } } diff --git a/packages/edit-site/src/utils/is-previewing-theme.js b/packages/edit-site/src/utils/is-previewing-theme.js new file mode 100644 index 0000000000000..69388b67212a2 --- /dev/null +++ b/packages/edit-site/src/utils/is-previewing-theme.js @@ -0,0 +1,18 @@ +/** + * WordPress dependencies + */ +import { getQueryArg } from '@wordpress/url'; + +export function isPreviewingTheme() { + return ( + window?.__experimentalEnableThemePreviews && + getQueryArg( window.location.href, 'theme_preview' ) !== undefined + ); +} + +export function currentlyPreviewingTheme() { + if ( isPreviewingTheme() ) { + return getQueryArg( window.location.href, 'theme_preview' ); + } + return null; +} diff --git a/packages/edit-site/src/utils/use-activate-theme.js b/packages/edit-site/src/utils/use-activate-theme.js new file mode 100644 index 0000000000000..6d59a18e385ba --- /dev/null +++ b/packages/edit-site/src/utils/use-activate-theme.js @@ -0,0 +1,38 @@ +/** + * WordPress dependencies + */ +import { privateApis as routerPrivateApis } from '@wordpress/router'; + +/** + * Internal dependencies + */ +import { unlock } from '../private-apis'; +import { + isPreviewingTheme, + currentlyPreviewingTheme, +} from './is-previewing-theme'; + +const { useHistory, useLocation } = unlock( routerPrivateApis ); + +/** + * This should be refactored to use the REST API, once the REST API can activate themes. + * + * @return {Function} A function that activates the theme. + */ +export function useActivateTheme() { + const history = useHistory(); + const location = useLocation(); + + return async () => { + if ( isPreviewingTheme() ) { + const activationURL = + 'themes.php?action=activate&stylesheet=' + + currentlyPreviewingTheme() + + '&_wpnonce=' + + window.BLOCK_THEME_ACTIVATE_NONCE; + await window.fetch( activationURL ); + const { theme_preview: themePreview, ...params } = location.params; + history.replace( params ); + } + }; +} diff --git a/packages/edit-widgets/src/components/header/index.js b/packages/edit-widgets/src/components/header/index.js index 8e8580674d173..8bd1e226aeda6 100644 --- a/packages/edit-widgets/src/components/header/index.js +++ b/packages/edit-widgets/src/components/header/index.js @@ -7,6 +7,7 @@ import { Button, ToolbarItem, VisuallyHidden } from '@wordpress/components'; import { NavigableToolbar, store as blockEditorStore, + privateApis as blockEditorPrivateApis, } from '@wordpress/block-editor'; import { PinnedItems } from '@wordpress/interface'; import { listView, plus } from '@wordpress/icons'; @@ -22,6 +23,7 @@ import RedoButton from './undo-redo/redo'; import MoreMenu from '../more-menu'; import useLastSelectedWidgetArea from '../../hooks/use-last-selected-widget-area'; import { store as editWidgetsStore } from '../../store'; +import { unlock } from '../../private-apis'; function Header() { const isMediumViewport = useViewportMatch( 'medium' ); @@ -70,6 +72,19 @@ function Header() { [ setIsListViewOpened, isListViewOpen ] ); + const { useShouldContextualToolbarShow } = unlock( blockEditorPrivateApis ); + const { + shouldShowContextualToolbar, + canFocusHiddenToolbar, + fixedToolbarCanBeFocused, + } = useShouldContextualToolbarShow(); + // If there's a block toolbar to be focused, disable the focus shortcut for the document toolbar. + // There's a fixed block toolbar when the fixed toolbar option is enabled or when the browser width is less than the large viewport. + const blockToolbarCanBeFocused = + shouldShowContextualToolbar || + canFocusHiddenToolbar || + fixedToolbarCanBeFocused; + return ( <>
@@ -90,6 +105,9 @@ function Header() { @@ -46,10 +41,10 @@ export default function ListViewSidebar() { className="edit-widgets-editor__list-view-panel-header" ref={ headerFocusReturnRef } > - { __( 'List View' ) } + { __( 'List View' ) }
diff --git a/packages/edit-widgets/src/components/sidebar/index.js b/packages/edit-widgets/src/components/sidebar/index.js index a4268df794462..087f781f69a5b 100644 --- a/packages/edit-widgets/src/components/sidebar/index.js +++ b/packages/edit-widgets/src/components/sidebar/index.js @@ -165,7 +165,7 @@ export default function Sidebar() { headerClassName="edit-widgets-sidebar__panel-tabs" /* translators: button label text should, if possible, be under 16 characters. */ title={ __( 'Settings' ) } - closeLabel={ __( 'Close settings' ) } + closeLabel={ __( 'Close Settings' ) } scope="core/edit-widgets" identifier={ currentArea } icon={ cog } diff --git a/packages/editor/src/components/entities-saved-states/index.js b/packages/editor/src/components/entities-saved-states/index.js index fda61faeeb7bc..05b031f1e29e6 100644 --- a/packages/editor/src/components/entities-saved-states/index.js +++ b/packages/editor/src/components/entities-saved-states/index.js @@ -2,13 +2,14 @@ * WordPress dependencies */ import { Button, Flex, FlexItem } from '@wordpress/components'; -import { __ } from '@wordpress/i18n'; +import { __, sprintf } from '@wordpress/i18n'; import { useSelect, useDispatch } from '@wordpress/data'; import { useState, useCallback, useRef } from '@wordpress/element'; import { store as coreStore } from '@wordpress/core-data'; import { store as blockEditorStore } from '@wordpress/block-editor'; import { __experimentalUseDialog as useDialog } from '@wordpress/compose'; import { store as noticesStore } from '@wordpress/notices'; +import { getQueryArg } from '@wordpress/url'; /** * Internal dependencies @@ -31,8 +32,28 @@ const PUBLISH_ON_SAVE_ENTITIES = [ }, ]; -export default function EntitiesSavedStates( { close } ) { +function identity( values ) { + return values; +} + +function isPreviewingTheme() { + return ( + window?.__experimentalEnableThemePreviews && + getQueryArg( window.location.href, 'theme_preview' ) !== undefined + ); +} + +function currentlyPreviewingTheme() { + if ( isPreviewingTheme() ) { + return getQueryArg( window.location.href, 'theme_preview' ); + } + return null; +} + +export default function EntitiesSavedStates( { close, onSave = identity } ) { const saveButtonRef = useRef(); + const { getTheme } = useSelect( coreStore ); + const theme = getTheme( currentlyPreviewingTheme() ); const { dirtyEntityRecords } = useSelect( ( select ) => { const dirtyRecords = select( coreStore ).__experimentalGetDirtyEntityRecords(); @@ -126,7 +147,7 @@ export default function EntitiesSavedStates( { close } ) { } }; - const saveCheckedEntities = () => { + const saveCheckedEntitiesAndActivate = () => { const entitiesToSave = dirtyEntityRecords.filter( ( { kind, name, key, property } ) => { return ! unselectedEntities.some( @@ -176,6 +197,9 @@ export default function EntitiesSavedStates( { close } ) { __unstableMarkLastChangeAsPersistent(); Promise.all( pendingSavedRecords ) + .then( ( values ) => { + return onSave( values ); + } ) .then( ( values ) => { if ( values.some( ( value ) => typeof value === 'undefined' ) @@ -200,6 +224,18 @@ export default function EntitiesSavedStates( { close } ) { onClose: () => dismissPanel(), } ); + const isDirty = dirtyEntityRecords.length - unselectedEntities.length > 0; + const activateSaveEnabled = isPreviewingTheme() || isDirty; + + let activateSaveLabel; + if ( isPreviewingTheme() && isDirty ) { + activateSaveLabel = __( 'Activate & Save' ); + } else if ( isPreviewingTheme() ) { + activateSaveLabel = __( 'Activate' ); + } else { + activateSaveLabel = __( 'Save' ); + } + return (
- { __( 'Save' ) } + { activateSaveLabel } { __( 'Are you ready to save?' ) } -

- { __( - 'The following changes have been made to your site, templates, and content.' - ) } -

+ { isPreviewingTheme() && ( +

+ { sprintf( + 'Saving your changes will change your active theme to %1$s.', + theme?.name?.rendered + ) } +

+ ) } + { isDirty && ( +

+ { __( + 'The following changes have been made to your site, templates, and content.' + ) } +

+ ) }
{ sortedPartitionedSavables.map( ( list ) => { diff --git a/packages/editor/src/components/post-featured-image/index.js b/packages/editor/src/components/post-featured-image/index.js index b46e77ea6a572..9304115b0b7f1 100644 --- a/packages/editor/src/components/post-featured-image/index.js +++ b/packages/editor/src/components/post-featured-image/index.js @@ -10,9 +10,10 @@ import { ResponsiveWrapper, withNotices, withFilters, + __experimentalHStack as HStack, } from '@wordpress/components'; import { isBlobURL } from '@wordpress/blob'; -import { useState } from '@wordpress/element'; +import { useState, useRef } from '@wordpress/element'; import { compose } from '@wordpress/compose'; import { useSelect, withDispatch, withSelect } from '@wordpress/data'; import { @@ -33,7 +34,6 @@ const ALLOWED_MEDIA_TYPES = [ 'image' ]; // Used when labels from post type were not yet loaded or when they are not present. const DEFAULT_FEATURE_IMAGE_LABEL = __( 'Featured image' ); const DEFAULT_SET_FEATURE_IMAGE_LABEL = __( 'Set featured image' ); -const DEFAULT_REMOVE_FEATURE_IMAGE_LABEL = __( 'Remove image' ); const instructions = (

@@ -96,6 +96,7 @@ function PostFeaturedImage( { noticeUI, noticeOperations, } ) { + const toggleRef = useRef(); const [ isLoading, setIsLoading ] = useState( false ); const mediaUpload = useSelect( ( select ) => { return select( blockEditorStore ).getSettings().mediaUpload; @@ -163,6 +164,7 @@ function PostFeaturedImage( { render={ ( { open } ) => (

+ + + ) }
) } value={ featuredImageId } /> - { !! featuredImageId && ( - - { media && ( - ( - - ) } - /> - ) } - - - ) }
); diff --git a/packages/editor/src/components/post-featured-image/style.scss b/packages/editor/src/components/post-featured-image/style.scss index 965780179e6ef..3b14662bf1d42 100644 --- a/packages/editor/src/components/post-featured-image/style.scss +++ b/packages/editor/src/components/post-featured-image/style.scss @@ -1,10 +1,6 @@ .editor-post-featured-image { padding: 0; - &__container { - margin-bottom: 1em; - position: relative; - } .components-spinner { position: absolute; @@ -14,12 +10,6 @@ margin-left: -9px; } - // Stack consecutive buttons. - .components-button + .components-button { - display: block; - margin-top: 1em; - } - // This keeps images at their intrinsic size (eg. a 50px // image will never be wider than 50px). .components-responsive-wrapper__content { @@ -28,22 +18,40 @@ } } +.editor-post-featured-image__container { + position: relative; + + &:hover, + &:focus, + &:focus-within { + .editor-post-featured-image__actions { + opacity: 1; + } + } +} + .editor-post-featured-image__toggle, .editor-post-featured-image__preview { - display: block; width: 100%; padding: 0; transition: all 0.1s ease-out; @include reduce-motion("transition"); box-shadow: 0 0 0 0 var(--wp-admin-theme-color); + overflow: hidden; // Ensure the focus style properly encapsulates the image. + + // Apply a max-height. + display: flex; + justify-content: center; + max-height: 150px; } .editor-post-featured-image__preview { height: auto; -} -.editor-post-featured-image__preview:not(:disabled):not([aria-disabled="true"]):focus { - box-shadow: 0 0 0 4px var(--wp-admin-theme-color); + .components-responsive-wrapper { + width: 100%; + background: $gray-100; + } } .editor-post-featured-image__toggle { @@ -59,3 +67,19 @@ color: $gray-900; } } + +.editor-post-featured-image__actions { + @include reduce-motion("transition"); + bottom: 0; + opacity: 0; // Use opacity instead of visibility so that the buttons remain in the tab order. + padding: $grid-unit-10; + position: absolute; + transition: opacity 50ms ease-out; +} + +.editor-post-featured-image__action { + backdrop-filter: blur(16px) saturate(180%); + background: rgba(255, 255, 255, 0.75); + flex-grow: 1; + justify-content: center; +} diff --git a/packages/editor/src/components/post-publish-panel/test/__snapshots__/index.js.snap b/packages/editor/src/components/post-publish-panel/test/__snapshots__/index.js.snap index 50bd6c2bd9bd8..c5aad5aa3be9b 100644 --- a/packages/editor/src/components/post-publish-panel/test/__snapshots__/index.js.snap +++ b/packages/editor/src/components/post-publish-panel/test/__snapshots__/index.js.snap @@ -593,7 +593,7 @@ exports[`PostPublishPanel should render the spinner if the post is being saved 1 display: inline-block; margin: 5px 11px 0; position: relative; - color: var(--wp-components-color-accent, var(--wp-admin-theme-color, #007cba)); + color: var(--wp-components-color-accent, var(--wp-admin-theme-color, #3858e9)); overflow: visible; opacity: 1; background-color: transparent; diff --git a/packages/editor/src/components/post-saved-state/index.js b/packages/editor/src/components/post-saved-state/index.js index 7d57b2a630e4e..d3c15b5596e3d 100644 --- a/packages/editor/src/components/post-saved-state/index.js +++ b/packages/editor/src/components/post-saved-state/index.js @@ -20,7 +20,6 @@ import { displayShortcut } from '@wordpress/keycodes'; /** * Internal dependencies */ -import PostSwitchToDraftButton from '../post-switch-to-draft-button'; import { store as editorStore } from '../../store'; /** @@ -48,10 +47,8 @@ export default function PostSavedState( { isDirty, isNew, isPending, - isPublished, isSaveable, isSaving, - isScheduled, hasPublishAction, } = useSelect( ( select ) => { @@ -106,10 +103,6 @@ export default function PostSavedState( { return null; } - if ( isPublished || isScheduled ) { - return ; - } - /* translators: button label text should, if possible, be under 16 characters. */ const label = isPending ? __( 'Save as pending' ) : __( 'Save draft' ); diff --git a/packages/editor/src/components/post-saved-state/test/__snapshots__/index.js.snap b/packages/editor/src/components/post-saved-state/test/__snapshots__/index.js.snap index d24ed1370537a..16fdd70e06573 100644 --- a/packages/editor/src/components/post-saved-state/test/__snapshots__/index.js.snap +++ b/packages/editor/src/components/post-saved-state/test/__snapshots__/index.js.snap @@ -22,15 +22,6 @@ exports[`PostSavedState returns a disabled button if the post is not saveable 1` `; -exports[`PostSavedState returns a switch to draft link if the post is published 1`] = ` - -`; - exports[`PostSavedState should return Save button if edits to be saved 1`] = ` { alertMessage } - + ); } diff --git a/packages/editor/src/components/post-taxonomies/style.scss b/packages/editor/src/components/post-taxonomies/style.scss index 87bdef2ef7548..ce912573c7948 100644 --- a/packages/editor/src/components/post-taxonomies/style.scss +++ b/packages/editor/src/components/post-taxonomies/style.scss @@ -3,10 +3,10 @@ overflow: auto; // Extra left padding prevents checkbox focus borders from being cut off. - margin-left: -$border-width * 4 - $border-width-focus; - padding-left: $border-width * 4 + $border-width-focus; - margin-top: -$border-width * 4 - $border-width-focus; - padding-top: $border-width * 4 + $border-width-focus; + margin-left: -$border-width * 4 - $border-width-focus-fallback; + padding-left: $border-width * 4 + $border-width-focus-fallback; + margin-top: -$border-width * 4 - $border-width-focus-fallback; + padding-top: $border-width * 4 + $border-width-focus-fallback; } .editor-post-taxonomies__hierarchical-terms-choice { diff --git a/packages/editor/src/components/post-trash/style.scss b/packages/editor/src/components/post-trash/style.scss index dddda0421edb3..f24a6eb2743dd 100644 --- a/packages/editor/src/components/post-trash/style.scss +++ b/packages/editor/src/components/post-trash/style.scss @@ -1,6 +1,4 @@ .editor-post-trash.components-button { - display: flex; - justify-content: center; - margin-top: $grid-unit-05; width: 100%; + display: block; } diff --git a/packages/element/src/react.js b/packages/element/src/react.js index 4f2b8e150303e..1eb49ac6742c2 100644 --- a/packages/element/src/react.js +++ b/packages/element/src/react.js @@ -52,6 +52,13 @@ import { * @typedef {import('react').SyntheticEvent} WPSyntheticEvent */ +/** + * Object containing a React synthetic event. + * + * @template T + * @typedef {import('react').RefObject} RefObject + */ + /** * Object that provides utilities for dealing with React children. */ diff --git a/packages/env/CHANGELOG.md b/packages/env/CHANGELOG.md index 5e7c12c4955a3..f7769b044d933 100644 --- a/packages/env/CHANGELOG.md +++ b/packages/env/CHANGELOG.md @@ -2,6 +2,29 @@ ## Unreleased +### Breaking Change + +- Docker containers now run as the host user. This should resolve problems with permissions arising from different owners between the host, web container, and cli container. If you still encounter permissions issues, try running `npx wp-env destroy` so that the environment can be recreated with the correct permissions. +- Remove the `composer` and `phpunit` Docker containers. If you are currently using the `run composer` or `run phpunit` command you can migrate to `run cli composer` or `run tests-cli phpunit` respectively. Note that with `composer`, you will need to use the `--env-cwd` option to navigate to your plugin's directory as it is no longer the default working directory. + +### New feature + +- Create an `afterSetup` option in `.wp-env.json` files for setting arbitrary commands to run after setting up WordPress when using `npx wp-env start` and `npx wp-env clean`. +- Add a `WP_ENV_AFTER_SETUP` environment variable to override the `afterSetup` option. +- Execute the `afterSetup` command on `npx wp-env start` after the environment is set up. This can happen when your config changes, WordPress updates, or you pass the `--update` flag. +- Execute the `afterSetup` command on `npx wp-env clean`. +- Globally install `composer` and the correct version of `phpunit` in all of the Docker containers. + +### Bug fix + +- Ensure `wordpress`, `tests-wordpress`, `cli`, and `tests-cli` always build the correct Docker image. +- Fix Xdebug while using PHP 7.2x. + +### Enhancement + +- `wp-env run ...` now uses docker-compose exec instead of docker-compose run. As a result, it is much faster, since commands are executed against existing services, rather than creating them from scratch each time. +- Increase the maximum upload size to 1GB. + ## 6.0.0 (2023-04-26) ### Breaking Change @@ -49,52 +72,63 @@ ## 5.2.0 (2022-08-16) ### Enhancement + - Query parameters can now be used in .zip source URLs. ## 5.1.1 (2022-08-16) ### Bug Fix + - Fix a crash when "core" was set to `null` in a `.wp-env.json` file. We now use the latest stable WordPress version in that case. This also restores the previous behavior of `"core": null` in `.wp-env.override.json`, which was to use the latest stable WordPress version. ## 5.1.0 (2022-08-10) ### Enhancement + - Previously, wp-env used the WordPress version provided by Docker in the WordPress image for installations which don't specify a WordPress version. Now, wp-env will find the latest stable version on WordPress.org and check out the https://github.com/WordPress/WordPress repository at the tag matching that version. In most cases, this will match what Docker provides. The benefit is that wp-env (and WordPress.org) now controls the default WordPress version rather than Docker. ### Bug Fix + - Downloading a default WordPress version also resolves a bug where the wrong WordPress test files were used if no core source was specified in wp-env.json. The current trunk test files were downloaded rather than the stable version. Now, the test files will match the default stable version. ## 5.0.0 (2022-07-27) ### Breaking Changes + - Removed the `WP_PHPUNIT__TESTS_CONFIG` environment variable from the `phpunit` container. **This removes automatic support for the `wp-phpunit/wp-phpunit` Composer package. To continue using the package, set the following two environment variables in your `phpunit.xml` file or similar: `WP_TESTS_DIR=""` and `WP_PHPUNIT__TESTS_CONFIG="/wordpress-phpunit/wp-tests-config.php"`.** - Removed the generated `/var/www/html/phpunit-wp-config.php` file from the environment. ### Enhancement + - Read WordPress' version and include the corresponding PHPUnit test files in the environment. - Set the `WP_TESTS_DIR` environment variable in all containers to point at the PHPUnit test files. ### Bug Fix + - Restrict `WP_TESTS_DOMAIN` constant to just hostname rather than an entire URL (e.g. it now excludes scheme, port, etc.) ([#41039](https://github.com/WordPress/gutenberg/pull/41039)). ## 4.8.0 (2022-06-01) ### Enhancement + - Removed the need for quotation marks when passing options to `wp-env run`. - Setting a `config` key to `null` will prevent adding the constant to `wp-config.php` even if a default value is defined by `wp-env`. ## 4.7.0 (2022-05-18) ### Enhancement + - Added SSH protocol support for git sources ## 4.2.0 (2022-01-27) ### Enhancement + - Added command `wp-env install-path` to list the directory used for the environment. - The help entry is now shown when no subcommand is passed to `wp-env`. ### Bug Fix + - Updated `yargs` to fix [CVE-2021-3807](https://nvd.nist.gov/vuln/detail/CVE-2021-3807). ## 4.1.3 (2021-11-07) diff --git a/packages/env/README.md b/packages/env/README.md index 61ef9eb03a546..3493c08bf6495 100644 --- a/packages/env/README.md +++ b/packages/env/README.md @@ -175,32 +175,33 @@ $ wp-env destroy $ wp-env start ``` -### 7. Debug mode and inspecting the generated dockerfile. +## Using included WordPress PHPUnit test files -`wp-env` uses docker behind the scenes. Inspecting the generated docker-compose file can help to understand what's going on. +Out of the box `wp-env` includes the [WordPress' PHPUnit test files](https://develop.svn.wordpress.org/trunk/tests/phpunit/) corresponding to the version of WordPress installed. There is an environment variable, `WP_TESTS_DIR`, which points to the location of these files within each container. By including these files in the environment, we remove the need for you to use a package or install and mount them yourself. If you do not want to use these files, you should ignore the `WP_TESTS_DIR` environment variable and load them from the location of your choosing. -Start `wp-env` in debug mode +### Customizing the `wp-tests-config.php` file -```sh -wp-env start --debug -``` +While we do provide a default `wp-tests-config.php` file within the environment, there may be cases where you want to use your own. WordPress provides a `WP_TESTS_CONFIG_FILE_PATH` constant that you can use to change the `wp-config.php` file used for testing. Set this to a desired path in your `bootstrap.php` file and the file you've chosen will be used instead of the one included in the environment. -`wp-env` will output its config which includes `dockerComposeConfigPath`. +## Using `composer`, `phpunit`, and `wp-cli` tools. -```sh -ℹ Config: - ... - "dockerComposeConfigPath": "/Users/$USERNAME/.wp-env/5a619d332a92377cd89feb339c67b833/docker-compose.yml", - ... -``` +For ease of use, Composer, PHPUnit, and wp-cli are available for in the environment. To run these executables, use `wp-env run `. For example, `wp-env run cli composer install`, or `wp-env run tests-cli phpunit`. You can also access various shells like `wp-env run cli bash` or `wp-env run cli wp shell`. -## Using included WordPress PHPUnit test files +For the `env` part, `cli` and `wordpress` share a database and mapped volumes, but more tools are available in the cli environment. You should use the `tests-cli` / `tests-wordpress` environments for a separate testing database. -Out of the box `wp-env` includes the [WordPress' PHPUnit test files](https://develop.svn.wordpress.org/trunk/tests/phpunit/) corresponding to the version of WordPress installed. There is an environment variable, `WP_TESTS_DIR`, which points to the location of these files within each container. By including these files in the environment, we remove the need for you to use a package or install and mount them yourself. If you do not want to use these files, you should ignore the `WP_TESTS_DIR` environment variable and load them from the location of your choosing. +By default, the cwd of the run command is the root of the WordPress install. If you're working on a plugin, you likely need to pass `--env-cwd` to make sure composer/phpunit commands are executed relative to the plugin you're working on. For example, `wp-env run cli --env-cwd=wp-content/plugins/gutenberg composer install`. -### Customizing the `wp-tests-config.php` file +To make this easier, it's often helpful to add scripts in your `package.json` file: -While we do provide a default `wp-tests-config.php` file within the environment, there may be cases where you want to use your own. WordPress provides a `WP_TESTS_CONFIG_FILE_PATH` constant that you can use to change the `wp-config.php` file used for testing. Set this to a desired path in your `bootstrap.php` file and the file you've chosen will be used instead of the one included in the environment. +```json +{ + "scripts": { + "composer": "wp-env run cli --env-cwd=wp-content/plugins/gutenberg composer" + } +} +``` + +Then, `npm run composer install` would run composer install in the environment. You could also do this for phpunit, wp-cli, etc. ## Using Xdebug @@ -272,15 +273,14 @@ The start command installs and initializes the WordPress environment, which incl ```sh wp-env start -Starts WordPress for development on port 8888 (override with WP_ENV_PORT) and -tests on port 8889 (override with WP_ENV_TESTS_PORT). The current working -directory must be a WordPress installation, a plugin, a theme, or contain a -.wp-env.json file. After first install, use the '--update' flag to download -updates to mapped sources and to re-apply WordPress configuration options. +Starts WordPress for development on port 8888 (​http://localhost:8888​) +(override with WP_ENV_PORT) and tests on port 8889 (​http://localhost:8889​) +(override with WP_ENV_TESTS_PORT). The current working directory must be a +WordPress installation, a plugin, a theme, or contain a .wp-env.json file. After +first install, use the '--update' flag to download updates to mapped sources and +to re-apply WordPress configuration options. Options: - --help Show help [boolean] - --version Show version number [boolean] --debug Enable debug output. [boolean] [default: false] --update Download source updates and apply WordPress configuration. [boolean] [default: false] @@ -289,6 +289,7 @@ Options: them in a comma-separated list: `--xdebug=develop,coverage`. See https://xdebug.org/docs/all_settings#mode for information about Xdebug modes. [string] + --scripts Execute any configured lifecycle scripts. [boolean] [default: true] ``` ### `wp-env stop` @@ -297,6 +298,9 @@ Options: wp-env stop Stops running WordPress for development and tests and frees the ports. + +Options: + --debug Enable debug output. [boolean] [default: false] ``` ### `wp-env clean [environment]` @@ -309,6 +313,10 @@ Cleans the WordPress databases. Positionals: environment Which environments' databases to clean. [string] [choices: "all", "development", "tests"] [default: "tests"] + +Options: + --debug Enable debug output. [boolean] [default: false] + --scripts Execute any configured lifecycle scripts. [boolean] [default: true] ``` ### `wp-env run [container] [command]` @@ -322,9 +330,10 @@ back to using quotation marks; wp-env considers everything inside t quotation marks to be command argument. For example, to ask WP-CLI for its help text: +
sh
 wp-env run cli "wp --help"
- + Without the quotation marks, wp-env will print its own help text instead of passing it to the container. If you experience any problems where the command is not being passed correctly, fall back to using quotation marks. @@ -346,8 +355,6 @@ Positionals: command The command to run. [array] [default: []] Options: - --help Show help [boolean] - --version Show version number [boolean] --debug Enable debug output. [boolean] [default: false] --env-cwd The command's working directory inside of the container. Paths without a leading slash are relative to the WordPress root. @@ -409,11 +416,22 @@ Success: Installed 1 of 1 plugins. ✔ Ran `plugin install custom-post-type-ui` in 'cli'. (in 6s 483ms) ``` -**NOTE**: Depending on your host OS, you may experience errors when trying to install plugins or themes (e.g. `Warning: Could not create directory.`). This is typically because the user ID used within the container does not have write access to the mounted directories created by `wp-env`. To resolve this, run the `docker-compose` command directly from the directory created by `wp-env` and add `-u $(id -u)` and `-e HOME=/tmp` the `run` command as options: +#### Changing the permalink structure -```sh -$ cd ~/wp-env/500cd328b649d63e882d5c4695871d04 -$ docker-compose run --rm -u $(id -u) -e HOME=/tmp cli [plugin|theme] install +You might want to do this to enable access to the REST API (`wp-env/wp/v2/`) endpoint in your wp-env environment. The endpoint is not available with plain permalinks. + +**Examples** + +To set the permalink to just the post name: + +``` +wp-env run cli "wp rewrite structure /%postname%/" +``` + +To set the permalink to the year, month, and post name: + +``` +wp-env run cli "wp rewrite structure /%year%/%monthnum%/%postname%/" ``` ### `wp-env destroy` @@ -423,6 +441,9 @@ wp-env destroy Destroy the WordPress environment. Deletes docker containers, volumes, and networks associated with the WordPress environment and removes local files. + +Options: + --debug Enable debug output. [boolean] [default: false] ``` ### `wp-env logs [environment]` @@ -437,15 +458,13 @@ Positionals: [string] [choices: "development", "tests", "all"] [default: "development"] Options: - --help Show help [boolean] - --version Show version number [boolean] --debug Enable debug output. [boolean] [default: false] --watch Watch for logs as they happen. [boolean] [default: true] ``` ### `wp-env install-path` -Outputs the absolute path to the WordPress environment files. +Get the path where all of the environment files are stored. This includes the Docker files, WordPress, PHPUnit files, and any sources that were downloaded. Example: @@ -540,9 +559,23 @@ These can be overridden by setting a value within the `config` configuration. Se Additionally, the values referencing a URL include the specified port for the given environment. So if you set `testsPort: 3000, port: 2000`, `WP_HOME` (for example) will be `http://localhost:3000` on the tests instance and `http://localhost:2000` on the development instance. -### Examples +## Lifecycle Hooks + +These hooks are executed at certain points during the lifecycle of a command's execution. Keep in mind that these will be executed on both fresh and existing +environments, so, ensure any commands you build won't break on subsequent executions. + +### After Setup + +Using the `afterSetup` option in `.wp-env.json` files will allow you to configure an arbitrary command to execute after the environment's setup is complete: + +- `wp-env start`: Runs when the config changes, WordPress updates, or you pass the `--update` flag. +- `wp-env clean`: Runs after the selected environments have been cleaned. -#### Latest stable WordPress + current directory as a plugin +You can override the `afterSetup` option using the `WP_ENV_AFTER_SETUP` environment variable. + +## Examples + +### Latest stable WordPress + current directory as a plugin This is useful for plugin development. @@ -564,7 +597,7 @@ This is useful for plugin development when upstream Core changes need to be test } ``` -#### Local `wordpress-develop` + current directory as a plugin +### Local `wordpress-develop` + current directory as a plugin This is useful for working on plugins and WordPress Core at the same time. @@ -586,7 +619,7 @@ If you are running `wordpress-develop` in a dev mode (e.g. the watch command `de } ``` -#### A complete testing environment +### A complete testing environment This is useful for integration testing: that is, testing how old versions of WordPress and different combinations of plugins and themes impact each other. @@ -598,7 +631,7 @@ This is useful for integration testing: that is, testing how old versions of Wor } ``` -#### Add mu-plugins and other mapped directories +### Add mu-plugins and other mapped directories You can add mu-plugins via the mapping config. The mapping config also allows you to mount a directory to any location in the wordpress install, so you could even mount a subdirectory. Note here that theme-1, will not be activated. @@ -613,7 +646,7 @@ You can add mu-plugins via the mapping config. The mapping config also allows yo } ``` -#### Avoid activating plugins or themes on the instance +### Avoid activating plugins or themes on the instance Since all plugins in the `plugins` key are activated by default, you should use the `mappings` key to avoid this behavior. This might be helpful if you have a test plugin that should not be activated all the time. @@ -626,7 +659,7 @@ Since all plugins in the `plugins` key are activated by default, you should use } ``` -#### Map a plugin only in the tests environment +### Map a plugin only in the tests environment If you need a plugin active in one environment but not the other, you can use `env.` to set options specific to one environment. Here, we activate cwd and a test plugin on the tests instance. This plugin is not activated on any other instances. @@ -641,7 +674,7 @@ If you need a plugin active in one environment but not the other, you can use `e } ``` -#### Custom Port Numbers +### Custom Port Numbers You can tell `wp-env` to use a custom port number so that your instance does not conflict with other `wp-env` instances. @@ -657,7 +690,7 @@ You can tell `wp-env` to use a custom port number so that your instance does not } ``` -#### Specific PHP Version +### Specific PHP Version You can tell `wp-env` to use a specific PHP version for compatibility and testing. This can also be set via the environment variable `WP_ENV_PHP_VERSION`. @@ -668,6 +701,39 @@ You can tell `wp-env` to use a specific PHP version for compatibility and testin } ``` +### Node Lifecycle Script + +This is useful for performing some actions after setting up the environment, such as bootstrapping an E2E test environment. + +```json +{ + "afterSetup": "node tests/e2e/bin/setup-env.js" +} +``` + +### Advanced PHP settings + +You can set PHP settings by mapping an `.htaccess` file. This maps an `.htaccess` file to the WordPress root (`/var/www/html`) from the directory in which you run `wp-env`. + +```json +{ + "mappings": { + ".htaccess": ".htaccess" + } +} +``` + +Then, your .htaccess file can contain various settings like this: + +``` +# Note: the default upload value is 1G. +php_value post_max_size 2G +php_value upload_max_filesize 2G +php_value memory_limit 2G +``` + +This is useful if there are options you'd like to add to `php.ini`, which is difficult to access in this environment. + ## Contributing to this package This is an individual package that's part of the Gutenberg project. The project is organized as a monorepo. It's made up of multiple self-contained software packages, each with a specific purpose. The packages in this monorepo are published to [npm](https://www.npmjs.com/) and used by [WordPress](https://make.wordpress.org/core/) as well as other software projects. diff --git a/packages/env/lib/build-docker-compose-config.js b/packages/env/lib/build-docker-compose-config.js index 7b27f85a7f6cf..5336f8690cca8 100644 --- a/packages/env/lib/build-docker-compose-config.js +++ b/packages/env/lib/build-docker-compose-config.js @@ -10,25 +10,28 @@ const path = require( 'path' ); */ const { hasSameCoreSource } = require( './wordpress' ); const { dbEnv } = require( './config' ); +const getHostUser = require( './get-host-user' ); /** * @typedef {import('./config').WPConfig} WPConfig - * @typedef {import('./config').WPServiceConfig} WPServiceConfig + * @typedef {import('./config').WPEnvironmentConfig} WPEnvironmentConfig */ /** * Gets the volume mounts for an individual service. * - * @param {string} workDirectoryPath The working directory for wp-env. - * @param {WPServiceConfig} config The service config to get the mounts from. - * @param {string} wordpressDefault The default internal path for the WordPress - * source code (such as tests-wordpress). + * @param {string} workDirectoryPath The working directory for wp-env. + * @param {WPEnvironmentConfig} config The service config to get the mounts from. + * @param {string} hostUsername The username of the host running wp-env. + * @param {string} wordpressDefault The default internal path for the WordPress + * source code (such as tests-wordpress). * * @return {string[]} An array of volumes to mount in string format. */ function getMounts( workDirectoryPath, config, + hostUsername, wordpressDefault = 'wordpress' ) { // Top-level WordPress directory mounts (like wp-content/themes) @@ -46,9 +49,10 @@ function getMounts( `${ source.path }:/var/www/html/wp-content/themes/${ source.basename }` ); - const coreMount = `${ - config.coreSource ? config.coreSource.path : wordpressDefault - }:/var/www/html`; + const userHomeMount = + wordpressDefault === 'wordpress' + ? `user-home:/home/${ hostUsername }` + : `tests-user-home:/home/${ hostUsername }`; const corePHPUnitMount = `${ path.join( workDirectoryPath, @@ -59,10 +63,15 @@ function getMounts( 'phpunit' ) }:/wordpress-phpunit`; + const coreMount = `${ + config.coreSource ? config.coreSource.path : wordpressDefault + }:/var/www/html`; + return [ ...new Set( [ - coreMount, + coreMount, // Must be first because of some operations later that expect it to be! corePHPUnitMount, + userHomeMount, ...directoryMounts, ...pluginMounts, ...themeMounts, @@ -79,16 +88,32 @@ function getMounts( * @return {Object} A docker-compose config object, ready to serialize into YAML. */ module.exports = function buildDockerComposeConfig( config ) { + // Since we are mounting files from the host operating system + // we want to create the host user in some of our containers. + // This ensures ownership parity and lets us access files + // and folders between the containers and the host. + const hostUser = getHostUser(); + const developmentMounts = getMounts( config.workDirectoryPath, - config.env.development + config.env.development, + hostUser.name ); const testsMounts = getMounts( config.workDirectoryPath, config.env.tests, + hostUser.name, 'tests-wordpress' ); + // We use a custom Dockerfile in order to make sure that + // the current host user exists inside the container. + const imageBuildArgs = { + HOST_USERNAME: hostUser.name, + HOST_UID: hostUser.uid, + HOST_GID: hostUser.gid, + }; + // When both tests and development reference the same WP source, we need to // ensure that tests pulls from a copy of the files so that it maintains // a separate DB and config. Additionally, if the source type is local we @@ -143,64 +168,6 @@ module.exports = function buildDockerComposeConfig( config ) { const developmentPorts = `\${WP_ENV_PORT:-${ config.env.development.port }}:80`; const testsPorts = `\${WP_ENV_TESTS_PORT:-${ config.env.tests.port }}:80`; - // Set the WordPress, WP-CLI, PHPUnit PHP version if defined. - const developmentPhpVersion = config.env.development.phpVersion - ? config.env.development.phpVersion - : ''; - const testsPhpVersion = config.env.tests.phpVersion - ? config.env.tests.phpVersion - : ''; - - // Set the WordPress images with the PHP version tag. - const developmentWpImage = `wordpress${ - developmentPhpVersion ? ':php' + developmentPhpVersion : '' - }`; - const testsWpImage = `wordpress${ - testsPhpVersion ? ':php' + testsPhpVersion : '' - }`; - // Set the WordPress CLI images with the PHP version tag. - const developmentWpCliImage = `wordpress:cli${ - ! developmentPhpVersion || developmentPhpVersion.length === 0 - ? '' - : '-php' + developmentPhpVersion - }`; - const testsWpCliImage = `wordpress:cli${ - ! testsPhpVersion || testsPhpVersion.length === 0 - ? '' - : '-php' + testsPhpVersion - }`; - - // Defaults are to use the most recent version of PHPUnit that provides - // support for the specified version of PHP. - // PHP Unit is assumed to be for Tests so use the testsPhpVersion. - let phpunitTag = 'latest'; - const phpunitPhpVersion = '-php-' + testsPhpVersion + '-fpm'; - if ( testsPhpVersion === '5.6' ) { - phpunitTag = '5' + phpunitPhpVersion; - } else if ( testsPhpVersion === '7.0' ) { - phpunitTag = '6' + phpunitPhpVersion; - } else if ( testsPhpVersion === '7.1' ) { - phpunitTag = '7' + phpunitPhpVersion; - } else if ( testsPhpVersion === '7.2' ) { - phpunitTag = '8' + phpunitPhpVersion; - } else if ( - [ '7.3', '7.4', '8.0', '8.1', '8.2' ].indexOf( testsPhpVersion ) >= 0 - ) { - phpunitTag = '9' + phpunitPhpVersion; - } - const phpunitImage = `wordpressdevelop/phpunit:${ phpunitTag }`; - - // The www-data user in wordpress:cli has a different UID (82) to the - // www-data user in wordpress (33). Ensure we use the wordpress www-data - // user for CLI commands. - // https://github.com/docker-library/wordpress/issues/256 - const cliUser = '33:33'; - - // If the user mounted their own uploads folder, we should not override it in the phpunit service. - const isMappingTestUploads = testsMounts.some( ( mount ) => - mount.endsWith( ':/var/www/html/wp-content/uploads' ) - ); - return { version: '3.7', services: { @@ -227,69 +194,72 @@ module.exports = function buildDockerComposeConfig( config ) { volumes: [ 'mysql-test:/var/lib/mysql' ], }, wordpress: { - build: '.', depends_on: [ 'mysql' ], - image: developmentWpImage, + build: { + context: '.', + dockerfile: 'WordPress.Dockerfile', + args: imageBuildArgs, + }, ports: [ developmentPorts ], environment: { + APACHE_RUN_USER: '#' + hostUser.uid, + APACHE_RUN_GROUP: '#' + hostUser.gid, ...dbEnv.credentials, ...dbEnv.development, WP_TESTS_DIR: '/wordpress-phpunit', }, volumes: developmentMounts, + extra_hosts: [ 'host.docker.internal:host-gateway' ], }, 'tests-wordpress': { depends_on: [ 'tests-mysql' ], - image: testsWpImage, + build: { + context: '.', + dockerfile: 'Tests-WordPress.Dockerfile', + args: imageBuildArgs, + }, ports: [ testsPorts ], environment: { + APACHE_RUN_USER: '#' + hostUser.uid, + APACHE_RUN_GROUP: '#' + hostUser.gid, ...dbEnv.credentials, ...dbEnv.tests, WP_TESTS_DIR: '/wordpress-phpunit', }, volumes: testsMounts, + extra_hosts: [ 'host.docker.internal:host-gateway' ], }, cli: { depends_on: [ 'wordpress' ], - image: developmentWpCliImage, + build: { + context: '.', + dockerfile: 'CLI.Dockerfile', + args: imageBuildArgs, + }, volumes: developmentMounts, - user: cliUser, + user: hostUser.fullUser, environment: { ...dbEnv.credentials, ...dbEnv.development, WP_TESTS_DIR: '/wordpress-phpunit', }, + extra_hosts: [ 'host.docker.internal:host-gateway' ], }, 'tests-cli': { depends_on: [ 'tests-wordpress' ], - image: testsWpCliImage, + build: { + context: '.', + dockerfile: 'Tests-CLI.Dockerfile', + args: imageBuildArgs, + }, volumes: testsMounts, - user: cliUser, + user: hostUser.fullUser, environment: { ...dbEnv.credentials, ...dbEnv.tests, WP_TESTS_DIR: '/wordpress-phpunit', }, - }, - composer: { - image: 'composer', - volumes: [ `${ config.configDirectoryPath }:/app` ], - }, - phpunit: { - image: phpunitImage, - depends_on: [ 'tests-wordpress' ], - volumes: [ - ...testsMounts, - ...( ! isMappingTestUploads - ? [ 'phpunit-uploads:/var/www/html/wp-content/uploads' ] - : [] ), - ], - environment: { - LOCAL_DIR: 'html', - WP_TESTS_DIR: '/wordpress-phpunit', - ...dbEnv.credentials, - ...dbEnv.tests, - }, + extra_hosts: [ 'host.docker.internal:host-gateway' ], }, }, volumes: { @@ -297,7 +267,8 @@ module.exports = function buildDockerComposeConfig( config ) { ...( ! config.env.tests.coreSource && { 'tests-wordpress': {} } ), mysql: {}, 'mysql-test': {}, - 'phpunit-uploads': {}, + 'user-home': {}, + 'tests-user-home': {}, }, }; }; diff --git a/packages/env/lib/cache.js b/packages/env/lib/cache.js index d6b532c559f49..187a8a24d030a 100644 --- a/packages/env/lib/cache.js +++ b/packages/env/lib/cache.js @@ -1,3 +1,4 @@ +'use strict'; /** * External dependencies */ diff --git a/packages/env/lib/cli.js b/packages/env/lib/cli.js index 68316855c6bed..115018a329c02 100644 --- a/packages/env/lib/cli.js +++ b/packages/env/lib/cli.js @@ -39,8 +39,11 @@ const withSpinner = process.exit( 0 ); }, ( error ) => { - if ( error instanceof env.ValidationError ) { - // Error is a validation error. That means the user did something wrong. + if ( + error instanceof env.ValidationError || + error instanceof env.AfterSetupError + ) { + // Error is a configuration error. That means the user did something wrong. spinner.fail( error.message ); process.exit( 1 ); } else if ( @@ -124,6 +127,11 @@ module.exports = function cli() { coerce: parseXdebugMode, type: 'string', } ); + args.option( 'scripts', { + type: 'boolean', + describe: 'Execute any configured lifecycle scripts.', + default: true, + } ); }, withSpinner( env.start ) ); @@ -145,6 +153,11 @@ module.exports = function cli() { choices: [ 'all', 'development', 'tests' ], default: 'tests', } ); + args.option( 'scripts', { + type: 'boolean', + describe: 'Execute any configured lifecycle scripts.', + default: true, + } ); }, withSpinner( env.clean ) ); @@ -215,7 +228,7 @@ module.exports = function cli() { ); yargs.command( 'install-path', - 'Get the path where environment files are located.', + 'Get the path where all of the environment files are stored. This includes the Docker files, WordPress, PHPUnit files, and any sources that were downloaded.', () => {}, withSpinner( env.installPath ) ); diff --git a/packages/env/lib/commands/clean.js b/packages/env/lib/commands/clean.js index adde9d072d9eb..20b7b1550c704 100644 --- a/packages/env/lib/commands/clean.js +++ b/packages/env/lib/commands/clean.js @@ -1,3 +1,4 @@ +'use strict'; /** * External dependencies */ @@ -8,6 +9,7 @@ const dockerCompose = require( 'docker-compose' ); */ const initConfig = require( '../init-config' ); const { configureWordPress, resetDatabase } = require( '../wordpress' ); +const { executeAfterSetup } = require( '../execute-after-setup' ); /** * @typedef {import('../wordpress').WPEnvironment} WPEnvironment @@ -20,9 +22,15 @@ const { configureWordPress, resetDatabase } = require( '../wordpress' ); * @param {Object} options * @param {WPEnvironmentSelection} options.environment The environment to clean. Either 'development', 'tests', or 'all'. * @param {Object} options.spinner A CLI spinner which indicates progress. + * @param {boolean} options.scripts Indicates whether or not lifecycle scripts should be executed. * @param {boolean} options.debug True if debug mode is enabled. */ -module.exports = async function clean( { environment, spinner, debug } ) { +module.exports = async function clean( { + environment, + spinner, + scripts, + debug, +} ) { const config = await initConfig( { spinner, debug } ); const description = `${ environment } environment${ @@ -57,5 +65,10 @@ module.exports = async function clean( { environment, spinner, debug } ) { await Promise.all( tasks ); + // Execute any configured command that should run after the environment has finished being set up. + if ( scripts ) { + executeAfterSetup( config, spinner ); + } + spinner.text = `Cleaned ${ description }.`; }; diff --git a/packages/env/lib/commands/destroy.js b/packages/env/lib/commands/destroy.js index 5b081392209f9..4d8a7e4ee6d6b 100644 --- a/packages/env/lib/commands/destroy.js +++ b/packages/env/lib/commands/destroy.js @@ -1,3 +1,4 @@ +'use strict'; /** * External dependencies */ @@ -11,12 +12,11 @@ const inquirer = require( 'inquirer' ); * Promisified dependencies */ const rimraf = util.promisify( require( 'rimraf' ) ); -const exec = util.promisify( require( 'child_process' ).exec ); /** * Internal dependencies */ -const { readConfig } = require( '../../lib/config' ); +const { loadConfig } = require( '../config' ); /** * Destroy the development server. @@ -26,9 +26,8 @@ const { readConfig } = require( '../../lib/config' ); * @param {boolean} options.debug True if debug mode is enabled. */ module.exports = async function destroy( { spinner, debug } ) { - const configPath = path.resolve( '.wp-env.json' ); - const { dockerComposeConfigPath, workDirectoryPath } = await readConfig( - configPath + const { dockerComposeConfigPath, workDirectoryPath } = await loadConfig( + path.resolve( '.' ) ); try { @@ -39,7 +38,7 @@ module.exports = async function destroy( { spinner, debug } ) { } spinner.info( - 'WARNING! This will remove Docker containers, volumes, and networks associated with the WordPress instance.' + 'WARNING! This will remove Docker containers, volumes, networks, and images associated with the WordPress instance.' ); const { yesDelete } = await inquirer.prompt( [ @@ -58,44 +57,20 @@ module.exports = async function destroy( { spinner, debug } ) { return; } - spinner.text = 'Removing WordPress docker containers.'; + spinner.text = 'Removing docker images, volumes, and networks.'; - await dockerCompose.rm( { + await dockerCompose.down( { config: dockerComposeConfigPath, - commandOptions: [ '--stop', '-v' ], + commandOptions: [ '--volumes', '--remove-orphans', '--rmi', 'all' ], log: debug, } ); - const directoryHash = path.basename( workDirectoryPath ); - - spinner.text = 'Removing docker volumes.'; - await removeDockerItems( 'volume', directoryHash ); - - spinner.text = 'Removing docker networks.'; - await removeDockerItems( 'network', directoryHash ); - spinner.text = 'Removing local files.'; - + // Note: there is a race condition where docker compose actually hasn't finished + // by this point, which causes rimraf to fail. We need to wait at least 2.5-5s, + // but using 10s in case it's dependant on the machine. + await new Promise( ( resolve ) => setTimeout( resolve, 10000 ) ); await rimraf( workDirectoryPath ); spinner.text = 'Removed WordPress environment.'; }; - -/** - * Removes docker items, like networks or volumes, matching the given name. - * - * @param {string} itemType The item type, like "network" or "volume" - * @param {string} name Remove items whose name match this string. - */ -async function removeDockerItems( itemType, name ) { - const { stdout: items } = await exec( - `docker ${ itemType } ls -q --filter name=${ name }` - ); - if ( items ) { - await exec( - `docker ${ itemType } rm ${ items - .split( '\n' ) // TODO: use os.EOL? - .join( ' ' ) }` - ); - } -} diff --git a/packages/env/lib/commands/index.js b/packages/env/lib/commands/index.js index f040fe174687d..04b0a9a3678d1 100644 --- a/packages/env/lib/commands/index.js +++ b/packages/env/lib/commands/index.js @@ -1,3 +1,4 @@ +'use strict'; /** * Internal dependencies */ diff --git a/packages/env/lib/commands/install-path.js b/packages/env/lib/commands/install-path.js index 4cc5358a65d02..a71a153339c47 100644 --- a/packages/env/lib/commands/install-path.js +++ b/packages/env/lib/commands/install-path.js @@ -1,3 +1,4 @@ +'use strict'; /** * Internal dependencies */ diff --git a/packages/env/lib/commands/logs.js b/packages/env/lib/commands/logs.js index 704b89328d6b5..3a749b20b3dab 100644 --- a/packages/env/lib/commands/logs.js +++ b/packages/env/lib/commands/logs.js @@ -1,3 +1,4 @@ +'use strict'; /** * External dependencies */ diff --git a/packages/env/lib/commands/run.js b/packages/env/lib/commands/run.js index fd0f800041cb9..c85d141cbe67e 100644 --- a/packages/env/lib/commands/run.js +++ b/packages/env/lib/commands/run.js @@ -1,3 +1,4 @@ +'use strict'; /** * External dependencies */ @@ -8,6 +9,8 @@ const path = require( 'path' ); * Internal dependencies */ const initConfig = require( '../init-config' ); +const getHostUser = require( '../get-host-user' ); +const { ValidationError } = require( '../config' ); /** * @typedef {import('../config').WPConfig} WPConfig @@ -30,6 +33,8 @@ module.exports = async function run( { spinner, debug, } ) { + validateContainerExistence( container ); + const config = await initConfig( { spinner, debug } ); command = command.join( ' ' ); @@ -42,6 +47,42 @@ module.exports = async function run( { spinner.text = `Ran \`${ command }\` in '${ container }'.`; }; +/** + * Validates the container option and throws if it is invalid. + * + * @param {string} container The Docker container to run the command on. + */ +function validateContainerExistence( container ) { + // Give better errors for containers that we have removed. + if ( container === 'phpunit' ) { + throw new ValidationError( + "The 'phpunit' container has been removed. Please use 'wp-env run tests-cli --env-cwd=wp-content/path/to/plugin phpunit' instead." + ); + } + if ( container === 'composer' ) { + throw new ValidationError( + "The 'composer' container has been removed. Please use 'wp-env run cli --env-cwd=wp-content/path/to/plugin composer' instead." + ); + } + + // Provide better error output than Docker's "service does not exist" messaging. + const validContainers = [ + 'mysql', + 'tests-mysql', + 'wordpress', + 'tests-wordpress', + 'cli', + 'tests-cli', + ]; + if ( ! validContainers.includes( container ) ) { + throw new ValidationError( + `The '${ container }' container does not exist. Valid selections are: ${ validContainers.join( + ', ' + ) }` + ); + } +} + /** * Runs an arbitrary command on the given Docker container. * @@ -52,16 +93,31 @@ module.exports = async function run( { * @param {Object} spinner A CLI spinner which indicates progress. */ function spawnCommandDirectly( config, container, command, envCwd, spinner ) { + // Both the `wordpress` and `tests-wordpress` containers have the host's + // user so that they can maintain ownership parity with the host OS. + // We should run any commands as that user so that they are able + // to interact with the files mounted from the host. + const hostUser = getHostUser(); + // We need to pass absolute paths to the container. - envCwd = path.resolve( '/var/www/html', envCwd ); + envCwd = path.resolve( + // Not all containers have the same starting working directory. + container === 'mysql' || container === 'tests-mysql' + ? '/' + : '/var/www/html', + envCwd + ); + const isTTY = process.stdout.isTTY; const composeCommand = [ '-f', config.dockerComposeConfigPath, - 'run', + 'exec', + ! isTTY ? '-T' : '', '-w', envCwd, - '--rm', + '--user', + hostUser.fullUser, container, ...command.split( ' ' ), // The command will fail if passed as a complete string. ]; diff --git a/packages/env/lib/commands/start.js b/packages/env/lib/commands/start.js index a217d5492d2b4..474d8fefbdaf0 100644 --- a/packages/env/lib/commands/start.js +++ b/packages/env/lib/commands/start.js @@ -1,3 +1,4 @@ +'use strict'; /** * External dependencies */ @@ -30,6 +31,7 @@ const { } = require( '../wordpress' ); const { didCacheChange, setCache } = require( '../cache' ); const md5 = require( '../md5' ); +const { executeAfterSetup } = require( '../execute-after-setup' ); /** * @typedef {import('../config').WPConfig} WPConfig @@ -41,11 +43,18 @@ const CONFIG_CACHE_KEY = 'config_checksum'; * * @param {Object} options * @param {Object} options.spinner A CLI spinner which indicates progress. - * @param {boolean} options.debug True if debug mode is enabled. * @param {boolean} options.update If true, update sources. * @param {string} options.xdebug The Xdebug mode to set. + * @param {boolean} options.scripts Indicates whether or not lifecycle scripts should be executed. + * @param {boolean} options.debug True if debug mode is enabled. */ -module.exports = async function start( { spinner, debug, update, xdebug } ) { +module.exports = async function start( { + spinner, + update, + xdebug, + scripts, + debug, +} ) { spinner.text = 'Reading configuration.'; await checkForLegacyInstall( spinner ); @@ -152,12 +161,20 @@ module.exports = async function start( { spinner, debug, update, xdebug } ) { spinner.text = 'Starting WordPress.'; - await dockerCompose.upMany( [ 'wordpress', 'tests-wordpress' ], { - ...dockerComposeConfig, - commandOptions: shouldConfigureWp - ? [ '--build', '--force-recreate' ] - : [], - } ); + await dockerCompose.upMany( + [ 'wordpress', 'tests-wordpress', 'cli', 'tests-cli' ], + { + ...dockerComposeConfig, + commandOptions: shouldConfigureWp + ? [ '--build', '--force-recreate' ] + : [], + } + ); + + // Make sure we've consumed the custom CLI dockerfile. + if ( shouldConfigureWp ) { + await dockerCompose.buildOne( [ 'cli' ], { ...dockerComposeConfig } ); + } // Only run WordPress install/configuration when config has changed. if ( shouldConfigureWp ) { @@ -186,6 +203,11 @@ module.exports = async function start( { spinner, debug, update, xdebug } ) { } ), ] ); + // Execute any configured command that should run after the environment has finished being set up. + if ( scripts ) { + executeAfterSetup( config, spinner ); + } + // Set the cache key once everything has been configured. await setCache( CONFIG_CACHE_KEY, configHash, { workDirectoryPath, diff --git a/packages/env/lib/commands/stop.js b/packages/env/lib/commands/stop.js index 4f6688003ebb9..3700c3f2aa581 100644 --- a/packages/env/lib/commands/stop.js +++ b/packages/env/lib/commands/stop.js @@ -1,3 +1,4 @@ +'use strict'; /** * External dependencies */ diff --git a/packages/env/lib/config/add-or-replace-port.js b/packages/env/lib/config/add-or-replace-port.js index 8d891869238a3..d1b7ff149b10d 100644 --- a/packages/env/lib/config/add-or-replace-port.js +++ b/packages/env/lib/config/add-or-replace-port.js @@ -1,3 +1,4 @@ +'use strict'; /** * Internal dependencies */ @@ -6,9 +7,10 @@ const { ValidationError } = require( './validate-config' ); /** * Adds or replaces the port to the given domain or URI. * - * @param {string} input The domain or URI to operate on. - * @param {string} port The port to append. - * @param {boolean} [replace] Indicates whether or not the port should be replaced if one is already present. Defaults to true. + * @param {string} input The domain or URI to operate on. + * @param {number|string} port The port to append. + * @param {boolean} [replace] Indicates whether or not the port should be replaced if one is already present. Defaults to true. + * * @return {string} The string with the port added or replaced. */ module.exports = function addOrReplacePort( input, port, replace = true ) { @@ -27,6 +29,13 @@ module.exports = function addOrReplacePort( input, port, replace = true ) { return input; } + // There's never a reason to add the default ports. + // We use == to catch both string and number ports. + // eslint-disable-next-line eqeqeq + if ( port == 80 || port == 443 ) { + return input; + } + // Place the port in the correct location in the input. return matches[ 1 ] + ':' + port + matches[ 3 ]; }; diff --git a/packages/env/lib/config/config.js b/packages/env/lib/config/config.js deleted file mode 100644 index 4018830f853a0..0000000000000 --- a/packages/env/lib/config/config.js +++ /dev/null @@ -1,353 +0,0 @@ -'use strict'; -/** - * External dependencies - */ -const fs = require( 'fs' ).promises; -const path = require( 'path' ); -const os = require( 'os' ); - -/** - * Internal dependencies - */ -const detectDirectoryType = require( './detect-directory-type' ); -const { validateConfig, ValidationError } = require( './validate-config' ); -const readRawConfigFile = require( './read-raw-config-file' ); -const parseConfig = require( './parse-config' ); -const { includeTestsPath, parseSourceString } = parseConfig; -const addOrReplacePort = require( './add-or-replace-port' ); -const md5 = require( '../md5' ); - -/** - * wp-env configuration. - * - * @typedef WPConfig - * @property {string} name Name of the environment. - * @property {string} configDirectoryPath Path to the .wp-env.json file. - * @property {string} workDirectoryPath Path to the work directory located in ~/.wp-env. - * @property {string} dockerComposeConfigPath Path to the docker-compose.yml file. - * @property {boolean} detectedLocalConfig If true, wp-env detected local config and used it. - * @property {Object.} env Specific config for different environments. - * @property {boolean} debug True if debug mode is enabled. - */ - -/** - * Base-level config for any particular environment. (development/tests/etc) - * - * @typedef WPServiceConfig - * @property {WPSource} coreSource The WordPress installation to load in the environment. - * @property {WPSource[]} pluginSources Plugins to load in the environment. - * @property {WPSource[]} themeSources Themes to load in the environment. - * @property {number} port The port to use. - * @property {Object} config Mapping of wp-config.php constants to their desired values. - * @property {Object.} mappings Mapping of WordPress directories to local directories which should be mounted. - * @property {string} phpVersion Version of PHP to use in the environments, of the format 0.0. - */ - -/** - * A WordPress installation, plugin or theme to be loaded into the environment. - * - * @typedef WPSource - * @property {'local'|'git'|'zip'} type The source type. - * @property {string} path The path to the WordPress installation, plugin or theme. - * @property {?string} url The URL to the source download if the source type is not local. - * @property {?string} ref The git ref for the source if the source type is 'git'. - * @property {string} basename Name that identifies the WordPress installation, plugin or theme. - */ - -/** - * Reads, parses, and validates the given .wp-env.json file into a wp-env config - * object for internal use. - * - * @param {string} configPath Path to the .wp-env.json file. - * - * @return {WPConfig} A parsed and validated wp-env config object. - */ -module.exports = async function readConfig( configPath ) { - const configDirectoryPath = path.dirname( configPath ); - const workDirectoryPath = path.resolve( - await getHomeDirectory(), - md5( configPath ) - ); - - // The specified base configuration from .wp-env.json or from the local - // source type which was automatically detected. - const baseConfig = - ( await readRawConfigFile( '.wp-env.json', configPath ) ) || - ( await getDefaultBaseConfig( configPath ) ); - - // Overriden .wp-env.json on a per-user case. - const overrideConfig = - ( await readRawConfigFile( - '.wp-env.override.json', - configPath.replace( /\.wp-env\.json$/, '.wp-env.override.json' ) - ) ) || {}; - - const detectedLocalConfig = - Object.keys( { ...baseConfig, ...overrideConfig } ).length > 0; - - // Default configuration which is overridden by .wp-env.json files. - const defaultConfiguration = { - core: null, // Indicates that the latest stable version should ultimately be used. - phpVersion: null, - plugins: [], - themes: [], - port: 8888, - mappings: {}, - config: { - WP_DEBUG: true, - SCRIPT_DEBUG: true, - WP_ENVIRONMENT_TYPE: 'local', - WP_PHP_BINARY: 'php', - WP_TESTS_EMAIL: 'admin@example.org', - WP_TESTS_TITLE: 'Test Blog', - WP_TESTS_DOMAIN: 'localhost', - WP_SITEURL: 'http://localhost', - WP_HOME: 'http://localhost', - }, - env: { - development: {}, // No overrides needed, but it should exist. - tests: { - config: { WP_DEBUG: false, SCRIPT_DEBUG: false }, - port: 8889, - }, - }, - }; - - // A quick validation before merging on a service by service level allows us - // to check the root configuration options and provide more helpful errors. - validateConfig( - mergeWpServiceConfigs( [ - defaultConfiguration, - baseConfig, - overrideConfig, - ] ) - ); - - // A unique array of the environments specified in the config options. - // Needed so that we can override settings per-environment, rather than - // overwriting each environment key. - const getEnvKeys = ( config ) => Object.keys( config.env || {} ); - const allEnvs = [ - ...new Set( [ - ...getEnvKeys( defaultConfiguration ), - ...getEnvKeys( baseConfig ), - ...getEnvKeys( overrideConfig ), - ] ), - ]; - - // Returns a pair with the root config options and the specific environment config options. - const getEnvConfig = ( config, envName ) => [ - config, - config.env && config.env[ envName ] ? config.env[ envName ] : {}, - ]; - - // Merge each of the specified environment-level overrides. - const allPorts = new Set(); // Keep track of unique ports for validation. - const env = {}; - for ( const envName of allEnvs ) { - env[ envName ] = await parseConfig( - validateConfig( - mergeWpServiceConfigs( [ - ...getEnvConfig( defaultConfiguration, envName ), - ...getEnvConfig( baseConfig, envName ), - ...getEnvConfig( overrideConfig, envName ), - ] ), - envName - ), - { workDirectoryPath } - ); - allPorts.add( env[ envName ].port ); - } - - if ( allPorts.size !== allEnvs.length ) { - throw new ValidationError( - 'Invalid .wp-env.json: Each port value must be unique.' - ); - } - - return withOverrides( { - name: path.basename( configDirectoryPath ), - dockerComposeConfigPath: path.resolve( - workDirectoryPath, - 'docker-compose.yml' - ), - configDirectoryPath, - workDirectoryPath, - detectedLocalConfig, - env, - } ); -}; - -/** - * Deep-merges the values in the given service environment. This allows us to - * merge the wp-config.php values instead of overwriting them. Note that this - * merges configs before they have been validated, so the passed config shape - * will not match the WPServiceConfig type. - * - * @param {Object[]} configs Array of raw service config objects to merge. - * - * @return {Object} The merged configuration object. - */ -function mergeWpServiceConfigs( configs ) { - // Returns an array of nested values in the config object. For example, - // an array of all the wp-config objects. - const mergeNestedObjs = ( key ) => - Object.assign( - {}, - ...configs.map( ( config ) => { - if ( ! config[ key ] ) { - return {}; - } else if ( typeof config[ key ] === 'object' ) { - return config[ key ]; - } - throw new ValidationError( - `Invalid .wp-env.json: "${ key }" must be an object.` - ); - } ) - ); - - const mergedConfig = { - ...Object.assign( {}, ...configs ), - config: mergeNestedObjs( 'config' ), - mappings: mergeNestedObjs( 'mappings' ), - }; - - delete mergedConfig.env; - return mergedConfig; -} - -/** - * Detects basic config options to use if the .wp-env.json config file does not - * exist. For example, if the local directory contains a plugin, that will be - * added to the default plugin sources. - * - * @param {string} configPath A path to the config file for the source to detect. - * @return {Object} Basic config options for the detected source type. Empty - * object if no config detected. - */ -async function getDefaultBaseConfig( configPath ) { - const configDirectoryPath = path.dirname( configPath ); - const type = await detectDirectoryType( configDirectoryPath ); - - if ( type === 'core' ) { - return { core: '.' }; - } else if ( type === 'plugin' ) { - return { plugins: [ '.' ] }; - } else if ( type === 'theme' ) { - return { themes: [ '.' ] }; - } - - return {}; -} - -/** - * Overrides keys in the config object with set environment variables or options - * which should be merged. - * - * @param {WPConfig} config fully parsed configuration object. - * @return {WPConfig} configuration object with overrides applied. - */ -function withOverrides( config ) { - const workDirectoryPath = config.workDirectoryPath; - // Override port numbers with environment variables. - config.env.development.port = - getNumberFromEnvVariable( 'WP_ENV_PORT' ) || - config.env.development.port; - config.env.tests.port = - getNumberFromEnvVariable( 'WP_ENV_TESTS_PORT' ) || - config.env.tests.port; - - // Override WordPress core with environment variable. - if ( process.env.WP_ENV_CORE ) { - const coreSource = includeTestsPath( - parseSourceString( process.env.WP_ENV_CORE, { workDirectoryPath } ), - { workDirectoryPath } - ); - config.env.development.coreSource = coreSource; - config.env.tests.coreSource = coreSource; - } - - // Override PHP version with environment variable. - config.env.development.phpVersion = - process.env.WP_ENV_PHP_VERSION || config.env.development.phpVersion; - config.env.tests.phpVersion = - process.env.WP_ENV_PHP_VERSION || config.env.tests.phpVersion; - - // Some of our configuration options need to have the port added to them. - const addConfigPort = ( configKey ) => { - // Don't replace the port if one is set in WP_HOME. - const replace = configKey !== 'WP_HOME'; - - config.env.development.config[ configKey ] = addOrReplacePort( - config.env.development.config[ configKey ], - config.env.development.port, - replace - ); - config.env.tests.config[ configKey ] = addOrReplacePort( - config.env.tests.config[ configKey ], - config.env.tests.port, - replace - ); - }; - addConfigPort( 'WP_TESTS_DOMAIN' ); - addConfigPort( 'WP_SITEURL' ); - addConfigPort( 'WP_HOME' ); - - return config; -} - -/** - * Parses an environment variable which should be a number. - * - * Throws an error if the variable cannot be parsed to a number. - * Returns null if the environment variable has not been specified. - * - * @param {string} varName The environment variable to check (e.g. WP_ENV_PORT). - * @return {null|number} The number. Null if it does not exist. - */ -function getNumberFromEnvVariable( varName ) { - // Allow use of the default if it does not exist. - if ( ! process.env[ varName ] ) { - return null; - } - - const maybeNumber = parseInt( process.env[ varName ] ); - - // Throw an error if it is not parseable as a number. - if ( isNaN( maybeNumber ) ) { - throw new ValidationError( - `Invalid environment variable: ${ varName } must be a number.` - ); - } - - return maybeNumber; -} - -/** - * Gets the `wp-env` home directory in which generated files are created. - * - * By default: '~/.wp-env/'. On Linux with snap packages: '~/wp-env/'. Can be - * overridden with the WP_ENV_HOME environment variable. - * - * @return {Promise} The absolute path to the `wp-env` home directory. - */ -async function getHomeDirectory() { - // Allow user to override download location. - if ( process.env.WP_ENV_HOME ) { - return path.resolve( process.env.WP_ENV_HOME ); - } - - /** - * Installing docker with Snap Packages on Linux is common, but does not - * support hidden directories. Therefore we use a public directory when - * snap packages exist. - * - * @see https://github.com/WordPress/gutenberg/issues/20180#issuecomment-587046325 - */ - return path.resolve( - os.homedir(), - !! ( await fs.stat( '/snap' ).catch( () => false ) ) - ? 'wp-env' - : '.wp-env' - ); -} diff --git a/packages/env/lib/config/db-env.js b/packages/env/lib/config/db-env.js index 95c0c23cf93d3..aa73fc70d60ce 100644 --- a/packages/env/lib/config/db-env.js +++ b/packages/env/lib/config/db-env.js @@ -1,3 +1,4 @@ +'use strict'; // Username and password used in all databases. const credentials = { WORDPRESS_DB_USER: 'root', diff --git a/packages/env/lib/config/detect-directory-type.js b/packages/env/lib/config/detect-directory-type.js index b685685c76c0c..91d897d956479 100644 --- a/packages/env/lib/config/detect-directory-type.js +++ b/packages/env/lib/config/detect-directory-type.js @@ -18,7 +18,7 @@ const finished = util.promisify( stream.finished ); * Detects whether the given directory is a WordPress installation, a plugin or a theme. * * @param {string} directoryPath The directory to detect. - * @return {string|null} 'core' if the directory is a WordPress installation, 'plugin' if it is a plugin, 'theme' if it is a theme, or null if we can't tell. + * @return {Promise} 'core' if the directory is a WordPress installation, 'plugin' if it is a plugin, 'theme' if it is a theme, or null if we can't tell. */ module.exports = async function detectDirectoryType( directoryPath ) { // If we have a `wp-includes/version.php` file, then this is a Core install. diff --git a/packages/env/lib/config/get-cache-directory.js b/packages/env/lib/config/get-cache-directory.js new file mode 100644 index 0000000000000..1cb291592df34 --- /dev/null +++ b/packages/env/lib/config/get-cache-directory.js @@ -0,0 +1,39 @@ +'use strict'; +/** + * External dependencies + */ +const path = require( 'path' ); +const fs = require( 'fs' ).promises; +const os = require( 'os' ); + +/** + * Gets the directory in which generated files are created. + * + * By default: '~/.wp-env/'. On Linux with snap packages: '~/wp-env/'. Can be + * overridden with the WP_ENV_HOME environment variable. + * + * @return {Promise} The absolute path to the `wp-env` home directory. + */ +module.exports = async function getCacheDirectory() { + // Allow user to override download location. + if ( process.env.WP_ENV_HOME ) { + return path.resolve( process.env.WP_ENV_HOME ); + } + + /** + * Installing docker with Snap Packages on Linux is common, but does not + * support hidden directories. Therefore we use a public directory when + * snap packages exist. + * + * @see https://github.com/WordPress/gutenberg/issues/20180#issuecomment-587046325 + */ + let usesSnap; + try { + await fs.stat( '/snap' ); + usesSnap = true; + } catch { + usesSnap = false; + } + + return path.resolve( os.homedir(), usesSnap ? 'wp-env' : '.wp-env' ); +}; diff --git a/packages/env/lib/config/get-config-from-environment-vars.js b/packages/env/lib/config/get-config-from-environment-vars.js new file mode 100644 index 0000000000000..1447d1a4de271 --- /dev/null +++ b/packages/env/lib/config/get-config-from-environment-vars.js @@ -0,0 +1,86 @@ +'use strict'; +/** + * Internal dependencies + */ +const { + parseSourceString, + includeTestsPath, +} = require( './parse-source-string' ); +const { checkPort, checkVersion, checkString } = require( './validate-config' ); + +/** + * @typedef {import('./parse-source-string').WPSource} WPSource + */ + +/** + * Environment variable configuration. + * + * @typedef WPEnvironmentVariableConfig + * @property {?number} port An override for the development environment's port. + * @property {?number} testsPort An override for the testing environment's port. + * @property {?WPSource} coreSource An override for all environment's coreSource. + * @property {?string} phpVersion An override for all environment's PHP version. + */ + +/** + * Gets configuration options from environment variables. + * + * @param {string} cacheDirectoryPath Path to the work directory located in ~/.wp-env. + * + * @return {WPEnvironmentVariableConfig} Any configuration options parsed from the environment variables. + */ +module.exports = function getConfigFromEnvironmentVars( cacheDirectoryPath ) { + const environmentConfig = { + port: getPortFromEnvironmentVariable( 'WP_ENV_PORT' ), + testsPort: getPortFromEnvironmentVariable( 'WP_ENV_TESTS_PORT' ), + }; + + if ( process.env.WP_ENV_CORE ) { + environmentConfig.coreSource = includeTestsPath( + parseSourceString( process.env.WP_ENV_CORE, { + cacheDirectoryPath, + } ), + { cacheDirectoryPath } + ); + } + + if ( process.env.WP_ENV_PHP_VERSION ) { + checkVersion( + 'environment variable', + 'WP_ENV_PHP_VERSION', + process.env.WP_ENV_PHP_VERSION + ); + environmentConfig.phpVersion = process.env.WP_ENV_PHP_VERSION; + } + + if ( process.env.WP_ENV_AFTER_SETUP ) { + checkString( + 'environment variable', + 'WP_ENV_AFTER_SETUP', + process.env.WP_ENV_AFTER_SETUP + ); + environmentConfig.afterSetup = process.env.WP_ENV_AFTER_SETUP; + } + + return environmentConfig; +}; + +/** + * Parses an environment variable which should be a port. + * + * @param {string} varName The environment variable to check (e.g. WP_ENV_PORT). + * + * @return {number} The parsed port number + */ +function getPortFromEnvironmentVariable( varName ) { + if ( ! process.env[ varName ] ) { + return undefined; + } + + const port = parseInt( process.env[ varName ] ); + + // Throw an error if it is not parseable as a number. + checkPort( 'environment variable', varName, port ); + + return port; +} diff --git a/packages/env/lib/config/index.js b/packages/env/lib/config/index.js index 1a944f43501d0..0fa88f4422275 100644 --- a/packages/env/lib/config/index.js +++ b/packages/env/lib/config/index.js @@ -1,18 +1,20 @@ +'use strict'; /** * Internal dependencies */ -const readConfig = require( './config' ); +const loadConfig = require( './load-config' ); const { ValidationError } = require( './validate-config' ); const dbEnv = require( './db-env' ); /** - * @typedef {import('./config').WPConfig} WPConfig - * @typedef {import('./config').WPServiceConfig} WPServiceConfig - * @typedef {import('./config').WPSource} WPSource + * @typedef {import('./load-config').WPConfig} WPConfig + * @typedef {import('./parse-config').WPRootConfig} WPRootConfig + * @typedef {import('./parse-config').WPEnvironmentConfig} WPEnvironmentConfig + * @typedef {import('./parse-source-string').WPSource} WPSource */ module.exports = { ValidationError, - readConfig, + loadConfig, dbEnv, }; diff --git a/packages/env/lib/config/load-config.js b/packages/env/lib/config/load-config.js new file mode 100644 index 0000000000000..1459dbe5a4e62 --- /dev/null +++ b/packages/env/lib/config/load-config.js @@ -0,0 +1,92 @@ +'use strict'; +/** + * External dependencies + */ +const path = require( 'path' ); +const fs = require( 'fs' ).promises; + +/** + * Internal dependencies + */ +const getCacheDirectory = require( './get-cache-directory' ); +const md5 = require( '../md5' ); +const { parseConfig, getConfigFilePath } = require( './parse-config' ); +const postProcessConfig = require( './post-process-config' ); + +/** + * @typedef {import('./parse-config').WPRootConfig} WPRootConfig + * @typedef {import('./parse-config').WPEnvironmentConfig} WPEnvironmentConfig + */ + +/** + * wp-env configuration. + * + * @typedef WPConfig + * @property {string} name Name of the environment. + * @property {string} configDirectoryPath Path to the .wp-env.json file. + * @property {string} workDirectoryPath Path to the work directory located in ~/.wp-env. + * @property {string} dockerComposeConfigPath Path to the docker-compose.yml file. + * @property {boolean} detectedLocalConfig If true, wp-env detected local config and used it. + * @property {string} afterSetup The command(s) to run after configuring WordPress on start and clean. + * @property {Object.} env Specific config for different environments. + * @property {boolean} debug True if debug mode is enabled. + */ + +/** + * Loads any configuration from a given directory. + * + * @param {string} configDirectoryPath The directory we want to load the config from. + * + * @return {WPConfig} The config object we've loaded. + */ +module.exports = async function loadConfig( configDirectoryPath ) { + const configFilePath = getConfigFilePath( configDirectoryPath ); + + const cacheDirectoryPath = path.resolve( + await getCacheDirectory(), + md5( configFilePath ) + ); + + // Parse any configuration we found in the given directory. + // This comes merged and prepared for internal consumption. + let config = await parseConfig( configDirectoryPath, cacheDirectoryPath ); + + // Make sure to perform any additional post-processing that + // may be needed before the config object is ready for + // consumption elsewhere in the tool. + config = postProcessConfig( config ); + + return { + name: path.basename( configDirectoryPath ), + dockerComposeConfigPath: path.resolve( + cacheDirectoryPath, + 'docker-compose.yml' + ), + configDirectoryPath, + workDirectoryPath: cacheDirectoryPath, + detectedLocalConfig: await hasLocalConfig( [ + configFilePath, + getConfigFilePath( configDirectoryPath, 'override' ), + ] ), + afterSetup: config.afterSetup, + env: config.env, + }; +}; + +/** + * Checks to see whether or not there is any configuration present in the directory. + * + * @param {string[]} configFilePaths The config files we want to check for existence. + * + * @return {Promise} A promise indicating whether or not a local config is present. + */ +async function hasLocalConfig( configFilePaths ) { + for ( const filePath of configFilePaths ) { + try { + await fs.stat( filePath ); + return true; + } catch {} + } + + return false; +} diff --git a/packages/env/lib/config/merge-configs.js b/packages/env/lib/config/merge-configs.js new file mode 100644 index 0000000000000..4494cea2bfb15 --- /dev/null +++ b/packages/env/lib/config/merge-configs.js @@ -0,0 +1,104 @@ +'use strict'; +/** + * @typedef {import('./parse-config').WPEnvironmentConfig} WPEnvironmentConfig + */ + +/** + * Deep-merges the values in the given service environment. This allows us to + * merge the wp-config.php values instead of overwriting them. + * + * @param {WPEnvironmentConfig} defaultConfig The default config options. + * @param {...string} configs The config options to merge. + * + * @return {WPEnvironmentConfig} The merged config object. + */ +module.exports = function mergeConfigs( defaultConfig, ...configs ) { + // Start with our default config object. This has all + // of the options filled out already and we can use + // that to make performing the merge easier. + let config = defaultConfig; + + // Merge the configs + for ( const merge of configs ) { + config = mergeConfig( config, merge ); + } + + return config; +}; + +/** + * Merges a config object into another. + * + * @param {WPEnvironmentConfig} config The config object to be merged into. + * @param {WPEnvironmentConfig} toMerge The config object to merge. + * + * @return {WPEnvironmentConfig} The merged config object. + */ +function mergeConfig( config, toMerge ) { + // Begin by updating any of the config options that the object already has. + for ( const option in config ) { + // We don't need to do anything if the merge source doesn't have a property to give. + if ( toMerge[ option ] === undefined ) { + continue; + } + + switch ( option ) { + // Some config options are merged together instead of entirely replaced. + case 'config': + case 'mappings': { + config[ option ] = Object.assign( + config[ option ], + toMerge[ option ] + ); + break; + } + + // Environment-specific config options are recursively merged. + case 'env': { + for ( const environment in config.env ) { + // Once again, we don't need to do anything if the merge source has nothing to give. + if ( toMerge.env[ environment ] === undefined ) { + continue; + } + + config.env[ environment ] = mergeConfig( + config.env[ environment ], + toMerge.env[ environment ] + ); + } + break; + } + + default: { + config[ option ] = toMerge[ option ]; + break; + } + } + } + + // Next, add any new options that the config object doesn't already have. + for ( const option in toMerge ) { + // Environment-specific config options should be checked individually. + if ( option === 'env' ) { + for ( const environment in toMerge.env ) { + // The presence of the environment means it would have been merged above. + if ( config.env[ environment ] !== undefined ) { + continue; + } + + config.env[ environment ] = toMerge[ environment ]; + } + + continue; + } + + // As above, the presence of the option means it would have been merged above. + if ( config[ option ] !== undefined ) { + continue; + } + + config[ option ] = toMerge[ option ]; + } + + return config; +} diff --git a/packages/env/lib/config/parse-config.js b/packages/env/lib/config/parse-config.js index 9528ac8b3528c..aa39d94ebfd9f 100644 --- a/packages/env/lib/config/parse-config.js +++ b/packages/env/lib/config/parse-config.js @@ -3,206 +3,467 @@ * External dependencies */ const path = require( 'path' ); -const os = require( 'os' ); /** * Internal dependencies */ -const { ValidationError } = require( './validate-config' ); +const readRawConfigFile = require( './read-raw-config-file' ); +const { + parseSourceString, + includeTestsPath, +} = require( './parse-source-string' ); +const { + ValidationError, + checkString, + checkPort, + checkStringArray, + checkObjectWithValues, + checkVersion, + checkValidURL, +} = require( './validate-config' ); +const getConfigFromEnvironmentVars = require( './get-config-from-environment-vars' ); +const detectDirectoryType = require( './detect-directory-type' ); const { getLatestWordPressVersion } = require( '../wordpress' ); +const mergeConfigs = require( './merge-configs' ); /** - * @typedef {import('./config').WPServiceConfig} WPServiceConfig - * @typedef {import('./config').WPSource} WPSource + * @typedef {import('./parse-source-string').WPSource} WPSource */ /** - * The string at the beginning of a source path that points to a home-relative - * directory. Will be '~/' on unix environments and '~\' on Windows. + * The root configuration options. + * + * @typedef WPRootConfigOptions + * @property {number} port The port to use in the development environment. + * @property {number} testsPort The port to use in the tests environment. + * @property {string|null} afterSetup The command(s) to run after configuring WordPress on start and clean. + */ + +/** + * The environment-specific configuration options. (development/tests/etc) + * + * @typedef WPEnvironmentConfig + * @property {WPSource} coreSource The WordPress installation to load in the environment. + * @property {WPSource[]} pluginSources Plugins to load in the environment. + * @property {WPSource[]} themeSources Themes to load in the environment. + * @property {number} port The port to use. + * @property {Object} config Mapping of wp-config.php constants to their desired values. + * @property {Object.} mappings Mapping of WordPress directories to local directories which should be mounted. + * @property {string|null} phpVersion Version of PHP to use in the environments, of the format 0.0. + */ + +/** + * The root configuration options. + * + * @typedef {WPEnvironmentConfig & WPRootConfigOptions} WPRootConfig + */ + +/** + * A WordPress installation, plugin or theme to be loaded into the environment. + * + * @typedef WPSource + * @property {'local'|'git'|'zip'} type The source type. + * @property {string} path The path to the WordPress installation, plugin or theme. + * @property {?string} url The URL to the source download if the source type is not local. + * @property {?string} ref The git ref for the source if the source type is 'git'. + * @property {string} basename Name that identifies the WordPress installation, plugin or theme. + */ + +/** + * Given a directory, this parses any relevant config files and + * constructs an object in the format used internally. + * + * + * @param {string} configDirectoryPath A path to the directory we are parsing the config for. + * @param {string} cacheDirectoryPath Path to the work directory located in ~/.wp-env. + * + * @return {WPRootConfig} Parsed config. + */ +async function parseConfig( configDirectoryPath, cacheDirectoryPath ) { + // The local config will be used to override any defaults. + const localConfig = await parseConfigFile( + getConfigFilePath( configDirectoryPath ), + { cacheDirectoryPath } + ); + + // Any overrides that can be used in place + // of properties set by the local config. + const overrideConfig = await parseConfigFile( + getConfigFilePath( configDirectoryPath, 'override' ), + { cacheDirectoryPath } + ); + + // It's important to know whether or not the user + // has configured the tool using a JSON file. + const hasUserConfig = localConfig || overrideConfig; + + // The default config will be used when no local config + // file is present in this directory. We should also + // infer the project type when there is no local + // config file present to use. + const defaultConfig = await getDefaultConfig( configDirectoryPath, { + shouldInferType: ! hasUserConfig, + cacheDirectoryPath, + } ); + + // Users can provide overrides in environment + // variables that supercede all other options. + const environmentVarOverrides = + getEnvironmentVarOverrides( cacheDirectoryPath ); + + // Merge all of our configs so that we have a complete object + // containing the desired options in order of precedence. + return mergeConfigs( + defaultConfig, + localConfig ?? {}, + overrideConfig ?? {}, + environmentVarOverrides + ); +} + +/** + * Gets the path to the config file. + * + * @param {string} configDirectoryPath The path to the directory containing config files. + * @param {string} type The type of config file we're interested in: 'local' or 'override'. + * + * @return {string} The path to the config file. */ -const HOME_PATH_PREFIX = `~${ path.sep }`; +function getConfigFilePath( configDirectoryPath, type = 'local' ) { + let fileName; + switch ( type ) { + case 'local': { + fileName = '.wp-env.json'; + break; + } + + case 'override': { + fileName = '.wp-env.override.json'; + break; + } + + default: { + throw new Error( `Invalid config file type "${ type }.` ); + } + } + + return path.resolve( configDirectoryPath, fileName ); +} /** - * Parses a config object. Takes environment-level configuration in the format - * specified in .wp-env.json, validates it, and converts it into the format used - * internally. For example, `plugins: string[]` will be parsed into - * `pluginSources: WPSource[]`. + * Gets the default config that can be overridden. * - * @param {Object} config A config object to validate. + * @param {string} configDirectoryPath A path to the config file's directory. * @param {Object} options - * @param {string} options.workDirectoryPath Path to the work directory located in ~/.wp-env. - * @return {WPServiceConfig} Parsed environment-level configuration. + * @param {string} options.shouldInferType Indicates whether or not we should infer the type of project wp-env is being used in. + * @param {string} options.cacheDirectoryPath Path to the work directory located in ~/.wp-env. + * + * @return {Promise} The default config object. */ -module.exports = async function parseConfig( config, options ) { - return { - port: config.port, - phpVersion: config.phpVersion, - coreSource: includeTestsPath( - await parseCoreSource( config.core, options ), - options - ), - pluginSources: config.plugins.map( ( sourceString ) => - parseSourceString( sourceString, options ) - ), - themeSources: config.themes.map( ( sourceString ) => - parseSourceString( sourceString, options ) - ), - config: config.config, - mappings: Object.entries( config.mappings ).reduce( - ( result, [ wpDir, localDir ] ) => { - const source = parseSourceString( localDir, options ); - result[ wpDir ] = source; - return result; +async function getDefaultConfig( + configDirectoryPath, + { shouldInferType, cacheDirectoryPath } +) { + // Our default config should try to infer what type of project + // this is in order to automatically map the current directory. + const detectedType = shouldInferType + ? await detectDirectoryType( configDirectoryPath ) + : null; + + // The default configuration should contain all possible options and + // environments whether they're empty or not. This makes using the + // config objects easier because once merged we don't need to + // verify that a given option exists before using it. + const rawConfig = { + core: detectedType === 'core' ? '.' : null, + phpVersion: null, + plugins: detectedType === 'plugin' ? [ '.' ] : [], + themes: detectedType === 'theme' ? [ '.' ] : [], + port: 8888, + testsPort: 8889, + mappings: {}, + config: { + WP_DEBUG: true, + SCRIPT_DEBUG: true, + WP_ENVIRONMENT_TYPE: 'local', + WP_PHP_BINARY: 'php', + WP_TESTS_EMAIL: 'admin@example.org', + WP_TESTS_TITLE: 'Test Blog', + WP_TESTS_DOMAIN: 'localhost', + WP_SITEURL: 'http://localhost', + WP_HOME: 'http://localhost', + }, + afterSetup: null, + env: { + development: {}, + tests: { + config: { WP_DEBUG: false, SCRIPT_DEBUG: false }, }, - {} - ), + }, }; -}; -async function parseCoreSource( coreSource, options ) { - // An empty source means we should use the latest version of WordPress. - if ( ! coreSource ) { - const wpVersion = await getLatestWordPressVersion(); - if ( ! wpVersion ) { - throw new ValidationError( - 'Could not find the latest WordPress version. There may be a network issue.' - ); - } + return await parseRootConfig( 'default', rawConfig, { + cacheDirectoryPath, + } ); +} - coreSource = `WordPress/WordPress#${ wpVersion }`; +/** + * Gets a service configuration object containing overrides from our environment variables. + * + * @param {string} cacheDirectoryPath Path to the work directory located in ~/.wp-env. + * + * @return {WPEnvironmentConfig} An object containing the environment variable overrides. + */ +function getEnvironmentVarOverrides( cacheDirectoryPath ) { + const overrides = getConfigFromEnvironmentVars( cacheDirectoryPath ); + + // Create a service config object so we can merge it with the others + // and override anything that the configuration options need to. + const overrideConfig = { + env: { + development: {}, + tests: {}, + }, + }; + + // We're going to take care to set it at both the root-level and the + // environment level. This is not totally necessary, but, it's a + // better representation of how broad the override is. + + if ( overrides.port ) { + overrideConfig.port = overrides.port; + overrideConfig.env.development.port = overrides.port; } - return parseSourceString( coreSource, options ); + + if ( overrides.testsPort ) { + overrideConfig.testsPort = overrides.testsPort; + overrideConfig.env.tests.port = overrides.testsPort; + } + + if ( overrides.coreSource ) { + overrideConfig.coreSource = overrides.coreSource; + overrideConfig.env.development.coreSource = overrides.coreSource; + overrideConfig.env.tests.coreSource = overrides.coreSource; + } + + if ( overrides.phpVersion ) { + overrideConfig.phpVersion = overrides.phpVersion; + overrideConfig.env.development.phpVersion = overrides.phpVersion; + overrideConfig.env.tests.phpVersion = overrides.phpVersion; + } + + if ( overrides.afterSetup ) { + overrideConfig.afterSetup = overrides.afterSetup; + } + + return overrideConfig; } /** - * Parses a source string into a source object. + * Parses a raw config into an unvalidated service config. * - * @param {?string} sourceString The source string. See README.md for documentation on valid source string patterns. - * @param {Object} options - * @param {string} options.workDirectoryPath Path to the work directory located in ~/.wp-env. + * @param {string} configFile The config file that we're parsing. + * @param {Object} options + * @param {string} options.cacheDirectoryPath Path to the work directory located in ~/.wp-env. * - * @return {?WPSource} A source object. + * @return {Promise} The parsed root config object. */ -function parseSourceString( sourceString, { workDirectoryPath } ) { - if ( sourceString === null ) { +async function parseConfigFile( configFile, options ) { + const rawConfig = await readRawConfigFile( configFile ); + if ( ! rawConfig ) { return null; } - if ( - sourceString.startsWith( '.' ) || - sourceString.startsWith( HOME_PATH_PREFIX ) || - path.isAbsolute( sourceString ) - ) { - let sourcePath; - if ( sourceString.startsWith( HOME_PATH_PREFIX ) ) { - sourcePath = path.resolve( - os.homedir(), - sourceString.slice( HOME_PATH_PREFIX.length ) + return await parseRootConfig( configFile, rawConfig, options ); +} + +/** + * Parses the root config object. + * + * @param {string} configFile The config file we're parsing. + * @param {Object} rawConfig The raw config we're parsing. + * @param {Object} options + * @param {string} options.cacheDirectoryPath Path to the work directory located in ~/.wp-env. + * + * @return {Promise} The root config object. + */ +async function parseRootConfig( configFile, rawConfig, options ) { + const parsedConfig = await parseEnvironmentConfig( + configFile, + null, + rawConfig, + options + ); + + // Parse any root-only options. + if ( rawConfig.testsPort !== undefined ) { + checkPort( configFile, `testsPort`, rawConfig.testsPort ); + parsedConfig.testsPort = rawConfig.testsPort; + } + if ( rawConfig.afterSetup !== undefined ) { + // Support null as a valid input. + if ( rawConfig.afterSetup !== null ) { + checkString( configFile, 'afterSetup', rawConfig.afterSetup ); + } + parsedConfig.afterSetup = rawConfig.afterSetup; + } + + // Parse the environment-specific configs so they're accessible to the root. + parsedConfig.env = {}; + if ( rawConfig.env ) { + checkObjectWithValues( configFile, 'env', rawConfig.env, [ 'object' ] ); + for ( const env in rawConfig.env ) { + parsedConfig.env[ env ] = await parseEnvironmentConfig( + configFile, + env, + rawConfig.env[ env ], + options ); - } else { - sourcePath = path.resolve( sourceString ); } - const basename = path.basename( sourcePath ); - return { - type: 'local', - path: sourcePath, - basename, - }; } - const zipFields = sourceString.match( - /^https?:\/\/([^\s$.?#].[^\s]*)\.zip(\?.+)?$/ - ); + return parsedConfig; +} - if ( zipFields ) { - const wpOrgFields = sourceString.match( - /^https?:\/\/downloads\.wordpress\.org\/(?:plugin|theme)\/([^\s\.]*)([^\s]*)?\.zip$/ - ); - const basename = wpOrgFields - ? encodeURIComponent( wpOrgFields[ 1 ] ) - : encodeURIComponent( path.basename( zipFields[ 1 ] ) ); - - return { - type: 'zip', - url: sourceString, - path: path.resolve( workDirectoryPath, basename ), - basename, - }; - } - - // SSH URLs (git) - const supportedProtocols = [ 'ssh:', 'git+ssh:' ]; - try { - const sshUrl = new URL( sourceString ); - if ( supportedProtocols.includes( sshUrl.protocol ) ) { - const pathElements = sshUrl.pathname - .split( '/' ) - .filter( ( e ) => !! e ); - const basename = pathElements - .slice( -1 )[ 0 ] - .replace( /\.git/, '' ); - const workingPath = path.resolve( - workDirectoryPath, - ...pathElements.slice( 0, -1 ), - basename +/** + * Parses and validates a raw config object and returns a validated service config to use internally. + * + * @param {string} configFile The config file that we're parsing. + * @param {string|null} environment If set, the environment that we're parsing the config for. + * @param {Object} config A config object to parse. + * @param {Object} options + * @param {string} options.cacheDirectoryPath Path to the work directory located in ~/.wp-env. + * + * @return {Promise} The environment config object. + */ +async function parseEnvironmentConfig( + configFile, + environment, + config, + options +) { + if ( ! config ) { + return {}; + } + + const environmentPrefix = environment ? environment + '.' : ''; + + const parsedConfig = {}; + + if ( config.port !== undefined ) { + checkPort( configFile, `${ environmentPrefix }port`, config.port ); + parsedConfig.port = config.port; + } + + if ( config.phpVersion !== undefined ) { + // Support null as a valid input. + if ( config.phpVersion !== null ) { + checkVersion( + configFile, + `${ environmentPrefix }phpVersion`, + config.phpVersion ); - return { - type: 'git', - url: sshUrl.href.split( '#' )[ 0 ], - ref: sshUrl.hash.slice( 1 ) || undefined, - path: workingPath, - clonePath: workingPath, - basename, - }; } - } catch ( err ) {} + parsedConfig.phpVersion = config.phpVersion; + } - const gitHubFields = sourceString.match( - /^([^\/]+)\/([^#\/]+)(\/([^#]+))?(?:#(.+))?$/ - ); + if ( config.core !== undefined ) { + parsedConfig.coreSource = includeTestsPath( + await parseCoreSource( config.core, options ), + options + ); + } - if ( gitHubFields ) { - return { - type: 'git', - url: `https://github.com/${ gitHubFields[ 1 ] }/${ gitHubFields[ 2 ] }.git`, - ref: gitHubFields[ 5 ], - path: path.resolve( - workDirectoryPath, - gitHubFields[ 2 ], - gitHubFields[ 4 ] || '.' - ), - clonePath: path.resolve( workDirectoryPath, gitHubFields[ 2 ] ), - basename: gitHubFields[ 4 ] || gitHubFields[ 2 ], - }; - } - - throw new ValidationError( - `Invalid or unrecognized source: "${ sourceString }".` - ); + if ( config.plugins !== undefined ) { + checkStringArray( + configFile, + `${ environmentPrefix }plugins`, + config.plugins + ); + parsedConfig.pluginSources = config.plugins.map( ( sourceString ) => + parseSourceString( sourceString, options ) + ); + } + + if ( config.themes !== undefined ) { + checkStringArray( + configFile, + `${ environmentPrefix }themes`, + config.themes + ); + parsedConfig.themeSources = config.themes.map( ( sourceString ) => + parseSourceString( sourceString, options ) + ); + } + + if ( config.config !== undefined ) { + checkObjectWithValues( + configFile, + `${ environmentPrefix }config`, + config.config, + [ 'string', 'number', 'boolean', 'empty' ] + ); + parsedConfig.config = config.config; + + // There are some configuration options that have a special purpose and need to be validated too. + for ( const key in parsedConfig.config ) { + switch ( key ) { + case 'WP_HOME': + case 'WP_SITEURL': { + checkValidURL( + configFile, + `${ environmentPrefix }config.${ key }`, + parsedConfig.config[ key ] + ); + break; + } + } + } + } + + if ( config.mappings !== undefined ) { + checkObjectWithValues( + configFile, + `${ environmentPrefix }mappings`, + config.mappings, + [ 'string' ] + ); + parsedConfig.mappings = Object.entries( config.mappings ).reduce( + ( result, [ wpDir, localDir ] ) => { + const source = parseSourceString( localDir, options ); + result[ wpDir ] = source; + return result; + }, + {} + ); + } + + return parsedConfig; } -module.exports.parseSourceString = parseSourceString; /** - * Given a source object, returns a new source object with the testsPath - * property set correctly. Only the 'core' source requires a testsPath. - * - * @param {?WPSource} source A source object. - * @param {Object} options - * @param {string} options.workDirectoryPath Path to the work directory located in ~/.wp-env. + * Parses a WordPress Core source string or defaults to the latest version. * - * @return {?WPSource} A source object. + * @param {string|null} coreSource The WordPress course source string to parse. + * @param {Object} options Options to use while parsing. + * @return {Promise} The parsed source object. */ -function includeTestsPath( source, { workDirectoryPath } ) { - if ( source === null ) { - return null; - } +async function parseCoreSource( coreSource, options ) { + // An empty source means we should use the latest version of WordPress. + if ( ! coreSource ) { + const wpVersion = await getLatestWordPressVersion(); + if ( ! wpVersion ) { + throw new ValidationError( + 'Could not find the latest WordPress version. There may be a network issue.' + ); + } - return { - ...source, - testsPath: path.resolve( - workDirectoryPath, - 'tests-' + path.basename( source.path ) - ), - }; + coreSource = `WordPress/WordPress#${ wpVersion }`; + } + return parseSourceString( coreSource, options ); } -module.exports.includeTestsPath = includeTestsPath; + +module.exports = { + parseConfig, + getConfigFilePath, +}; diff --git a/packages/env/lib/config/parse-source-string.js b/packages/env/lib/config/parse-source-string.js new file mode 100644 index 0000000000000..27dcfa4ef5b59 --- /dev/null +++ b/packages/env/lib/config/parse-source-string.js @@ -0,0 +1,164 @@ +'use strict'; +/** + * External dependencies + */ +const path = require( 'path' ); +const os = require( 'os' ); + +/** + * Internal dependencies + */ +const { ValidationError } = require( './validate-config' ); + +/** + * A WordPress installation, plugin or theme to be loaded into the environment. + * + * @typedef WPSource + * @property {'local'|'git'|'zip'} type The source type. + * @property {string} path The path to the WordPress installation, plugin or theme. + * @property {?string} url The URL to the source download if the source type is not local. + * @property {?string} ref The git ref for the source if the source type is 'git'. + * @property {string} basename Name that identifies the WordPress installation, plugin or theme. + */ + +/** + * The string at the beginning of a source path that points to a home-relative + * directory. Will be '~/' on unix environments and '~\' on Windows. + */ +const HOME_PATH_PREFIX = `~${ path.sep }`; + +/** + * Parses a source string into a source object. + * + * @param {?string} sourceString The source string. See README.md for documentation on valid source string patterns. + * @param {Object} options + * @param {string} options.cacheDirectoryPath Path to the work directory located in ~/.wp-env. + * + * @return {?WPSource} A source object. + */ +function parseSourceString( sourceString, { cacheDirectoryPath } ) { + if ( sourceString === null ) { + return null; + } + + if ( + sourceString.startsWith( '.' ) || + sourceString.startsWith( HOME_PATH_PREFIX ) || + path.isAbsolute( sourceString ) + ) { + let sourcePath; + if ( sourceString.startsWith( HOME_PATH_PREFIX ) ) { + sourcePath = path.resolve( + os.homedir(), + sourceString.slice( HOME_PATH_PREFIX.length ) + ); + } else { + sourcePath = path.resolve( sourceString ); + } + const basename = path.basename( sourcePath ); + return { + type: 'local', + path: sourcePath, + basename, + }; + } + + const zipFields = sourceString.match( + /^https?:\/\/([^\s$.?#].[^\s]*)\.zip(\?.+)?$/ + ); + + if ( zipFields ) { + const wpOrgFields = sourceString.match( + /^https?:\/\/downloads\.wordpress\.org\/(?:plugin|theme)\/([^\s\.]*)([^\s]*)?\.zip$/ + ); + const basename = wpOrgFields + ? encodeURIComponent( wpOrgFields[ 1 ] ) + : encodeURIComponent( path.basename( zipFields[ 1 ] ) ); + + return { + type: 'zip', + url: sourceString, + path: path.resolve( cacheDirectoryPath, basename ), + basename, + }; + } + + // SSH URLs (git) + const supportedProtocols = [ 'ssh:', 'git+ssh:' ]; + try { + const sshUrl = new URL( sourceString ); + if ( supportedProtocols.includes( sshUrl.protocol ) ) { + const pathElements = sshUrl.pathname + .split( '/' ) + .filter( ( e ) => !! e ); + const basename = pathElements + .slice( -1 )[ 0 ] + .replace( /\.git/, '' ); + const workingPath = path.resolve( + cacheDirectoryPath, + ...pathElements.slice( 0, -1 ), + basename + ); + return { + type: 'git', + url: sshUrl.href.split( '#' )[ 0 ], + ref: sshUrl.hash.slice( 1 ) || undefined, + path: workingPath, + clonePath: workingPath, + basename, + }; + } + } catch ( err ) {} + + const gitHubFields = sourceString.match( + /^([^\/]+)\/([^#\/]+)(\/([^#]+))?(?:#(.+))?$/ + ); + + if ( gitHubFields ) { + return { + type: 'git', + url: `https://github.com/${ gitHubFields[ 1 ] }/${ gitHubFields[ 2 ] }.git`, + ref: gitHubFields[ 5 ], + path: path.resolve( + cacheDirectoryPath, + gitHubFields[ 2 ], + gitHubFields[ 4 ] || '.' + ), + clonePath: path.resolve( cacheDirectoryPath, gitHubFields[ 2 ] ), + basename: gitHubFields[ 4 ] || gitHubFields[ 2 ], + }; + } + + throw new ValidationError( + `Invalid or unrecognized source: "${ sourceString }".` + ); +} + +/** + * Given a source object, returns a new source object with the testsPath + * property set correctly. Only the 'core' source requires a testsPath. + * + * @param {?WPSource} source A source object. + * @param {Object} options + * @param {string} options.cacheDirectoryPath Path to the work directory located in ~/.wp-env. + * + * @return {?WPSource} A source object. + */ +function includeTestsPath( source, { cacheDirectoryPath } ) { + if ( source === null ) { + return null; + } + + return { + ...source, + testsPath: path.resolve( + cacheDirectoryPath, + 'tests-' + path.basename( source.path ) + ), + }; +} + +module.exports = { + parseSourceString, + includeTestsPath, +}; diff --git a/packages/env/lib/config/post-process-config.js b/packages/env/lib/config/post-process-config.js new file mode 100644 index 0000000000000..46723b9f3d8c3 --- /dev/null +++ b/packages/env/lib/config/post-process-config.js @@ -0,0 +1,202 @@ +'use strict'; +/** + * Internal dependencies + */ +const mergeConfigs = require( './merge-configs' ); +const addOrReplacePort = require( './add-or-replace-port' ); +const { ValidationError } = require( './validate-config' ); + +/** + * @typedef {import('./parse-config').WPRootConfig} WPRootConfig + * @typedef {import('./parse-config').WPEnvironmentConfig} WPEnvironmentConfig + */ + +/** + * Performs any additional post-processing on the config object. + * + * @param {WPEnvironmentConfig} config The config to process. + * + * @return {WPEnvironmentConfig} The config after post-processing. + */ +module.exports = function postProcessConfig( config ) { + // Make sure that we're operating on a config object that has + // complete environment configs for convenience. + config = mergeRootToEnvironments( config ); + + config = appendPortToWPConfigs( config ); + + validate( config ); + return config; +}; + +/** + * Merges the root config and each environment together in order to make sure each environment has + * a full config object to work with internally. This makes it easier than having to try and + * resolve the full config options every time we want to use them for something. + * + * @param {WPEnvironmentConfig} config The config to process. + * + * @return {WPRootConfig} The config object with the root options merged together with the environment-specific options. + */ +function mergeRootToEnvironments( config ) { + // Some root-level options need to be handled early because they have a special + // cascade behavior that would break the normal merge. After merging we then + // delete them to avoid that breakage and add them back before we return. + const removedRootOptions = {}; + if ( + config.port !== undefined && + config.env.development.port === undefined + ) { + removedRootOptions.port = config.port; + config.env.development.port = config.port; + delete config.port; + } + if ( + config.testsPort !== undefined && + config.env.tests.port === undefined + ) { + removedRootOptions.testsPort = config.testsPort; + config.env.tests.port = config.testsPort; + delete config.testsPort; + } + if ( config.afterSetup !== undefined ) { + removedRootOptions.afterSetup = config.afterSetup; + delete config.afterSetup; + } + + // Merge the root config and the environment configs together so that + // we can ignore the root config and have full environment configs. + for ( const env in config.env ) { + config.env[ env ] = mergeConfigs( + deepCopyRootOptions( config ), + config.env[ env ] + ); + } + + // Set any root-level options we reset back. + for ( const option in removedRootOptions ) { + config[ option ] = removedRootOptions[ option ]; + } + + return config; +} + +/** + * Appends the configured port to certain wp-config options. + * + * @param {WPRootConfig} config The config to process. + * + * @return {WPRootConfig} The config after post-processing. + */ +function appendPortToWPConfigs( config ) { + const options = [ 'WP_TESTS_DOMAIN', 'WP_SITEURL', 'WP_HOME' ]; + + // We are only interested in editing the config options for environment-specific configs. + // If we made this change to the root config it would cause problems since they would + // be mapped to all environments even though the ports will be different. + for ( const env in config.env ) { + // There's nothing to do without any wp-config options set. + if ( config.env[ env ].config === undefined ) { + continue; + } + + if ( config.env[ env ].port === undefined ) { + continue; + } + + // Make sure that the port is on the option if it's present. + for ( const option of options ) { + if ( config.env[ env ].config[ option ] === undefined ) { + continue; + } + + config.env[ env ].config[ option ] = addOrReplacePort( + config.env[ env ].config[ option ], + config.env[ env ].port, + // Don't replace the port if one is already set on WP_HOME. + option !== 'WP_HOME' + ); + } + } + + return config; +} + +/** + * Examines the config to make sure that none of the environments share the same port. + * + * @param {WPRootConfig} config The config to process. + */ +function validatePortUniqueness( config ) { + // We're going to build a map of the environments and their port + // so we can accomodate root-level config options more easily. + const environmentPorts = {}; + + // Add all of the environments to the map. This will + // overwrite any root-level options if necessary. + for ( const env in config.env ) { + if ( config.env[ env ].port === undefined ) { + throw new ValidationError( + `The "${ env }" environment has an invalid port.` + ); + } + + environmentPorts[ env ] = config.env[ env ].port; + } + + // This search isn't very performant, but, we won't ever be + // checking more than a few entries so it doesn't matter. + for ( const env in environmentPorts ) { + for ( const check in environmentPorts ) { + if ( env === check ) { + continue; + } + + if ( environmentPorts[ env ] === environmentPorts[ check ] ) { + throw new ValidationError( + `The "${ env }" and "${ check }" environments may not have the same port.` + ); + } + } + } +} + +/** + * Perform any validation that can only happen after post-processing has occurred. + * + * @param {WPRootConfig} config The config to validate. + */ +function validate( config ) { + validatePortUniqueness( config ); +} + +/** + * Creates a deep copy of the root options in the config object that we can use to avoid + * accidentally sharing object state between different environments. + * + * @param {WPRootConfig} config The root config object to copy. + * + * @return {WPRootConfig} A deep copy of the root config object. + */ +function deepCopyRootOptions( config ) { + // Create a shallow clone of the object first so we can operate on it safetly. + const rootConfig = Object.assign( {}, config ); + + // Since we're only dealing with the root options we don't want the environments. + delete rootConfig.env; + + if ( rootConfig.config !== undefined ) { + rootConfig.config = Object.assign( {}, rootConfig.config ); + } + if ( rootConfig.mappings !== undefined ) { + rootConfig.mappings = Object.assign( {}, rootConfig.mappings ); + } + if ( rootConfig.pluginSources !== undefined ) { + rootConfig.pluginSources = [ ...rootConfig.pluginSources ]; + } + if ( rootConfig.themeSources !== undefined ) { + rootConfig.themeSources = [ ...rootConfig.themeSources ]; + } + + return rootConfig; +} diff --git a/packages/env/lib/config/read-raw-config-file.js b/packages/env/lib/config/read-raw-config-file.js index 80f3dc9f1ee36..38c6ddfc84875 100644 --- a/packages/env/lib/config/read-raw-config-file.js +++ b/packages/env/lib/config/read-raw-config-file.js @@ -4,6 +4,7 @@ */ const fs = require( 'fs' ).promises; +const path = require( 'path' ); /** * Internal dependencies */ @@ -13,53 +14,26 @@ const { ValidationError } = require( './validate-config' ); * Reads the config JSON from the filesystem and returns it as a JS object * compatible with the env package. * - * @param {string} name The name of the file for error messages. * @param {string} configPath The path to the JSON file to read. * * @return {Object} the raw config data. */ -module.exports = async function readRawConfigFile( name, configPath ) { +module.exports = async function readRawConfigFile( configPath ) { try { - return withBackCompat( - JSON.parse( await fs.readFile( configPath, 'utf8' ) ) - ); + return JSON.parse( await fs.readFile( configPath, 'utf8' ) ); } catch ( error ) { + const fileName = path.basename( configPath ); + if ( error.code === 'ENOENT' ) { return null; } else if ( error instanceof SyntaxError ) { throw new ValidationError( - `Invalid ${ name }: ${ error.message }` + `Invalid ${ fileName }: ${ error.message }` ); } else { throw new ValidationError( - `Could not read ${ name }: ${ error.message }` + `Could not read ${ fileName }: ${ error.message }` ); } } }; - -/** - * Used to maintain back compatibility with older versions of the .wp-env.json - * file. Returns an object in the shape of the currently expected .wp-env.json - * version. - * - * @param {Object} rawConfig config right after being read from a file. - * @return {Object} Same config with any old-format values converted into the - * shape of the currently expected format. - */ -function withBackCompat( rawConfig ) { - // Convert testsPort into new env.tests format. - if ( rawConfig.testsPort !== undefined ) { - rawConfig.env = { - ...( rawConfig.env || {} ), - tests: { - port: rawConfig.testsPort, - ...( rawConfig.env && rawConfig.env.tests - ? rawConfig.env.tests - : {} ), - }, - }; - } - delete rawConfig.testsPort; - return rawConfig; -} diff --git a/packages/env/lib/config/test/__snapshots__/config-integration.js.snap b/packages/env/lib/config/test/__snapshots__/config-integration.js.snap new file mode 100644 index 0000000000000..914b0ec7cc0a4 --- /dev/null +++ b/packages/env/lib/config/test/__snapshots__/config-integration.js.snap @@ -0,0 +1,271 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Config Integration should load local and override configuration files 1`] = ` +{ + "afterSetup": null, + "configDirectoryPath": "/test/gutenberg", + "detectedLocalConfig": true, + "dockerComposeConfigPath": "/cache/5fea4c5689ef6cc4a4e6eaaa39323338/docker-compose.yml", + "env": { + "development": { + "config": { + "SCRIPT_DEBUG": true, + "WP_DEBUG": true, + "WP_ENVIRONMENT_TYPE": "local", + "WP_HOME": "http://localhost:999", + "WP_PHP_BINARY": "php", + "WP_SITEURL": "http://localhost:999", + "WP_TESTS_DOMAIN": "localhost:999", + "WP_TESTS_EMAIL": "admin@example.org", + "WP_TESTS_TITLE": "Test Blog", + }, + "coreSource": { + "basename": "WordPress", + "clonePath": "/cache/5fea4c5689ef6cc4a4e6eaaa39323338/WordPress", + "path": "/cache/5fea4c5689ef6cc4a4e6eaaa39323338/WordPress", + "ref": "trunk", + "testsPath": "/cache/5fea4c5689ef6cc4a4e6eaaa39323338/tests-WordPress", + "type": "git", + "url": "https://github.com/WordPress/WordPress.git", + }, + "mappings": {}, + "phpVersion": null, + "pluginSources": [], + "port": 999, + "themeSources": [], + }, + "tests": { + "config": { + "SCRIPT_DEBUG": false, + "WP_DEBUG": false, + "WP_ENVIRONMENT_TYPE": "local", + "WP_HOME": "http://localhost:456", + "WP_PHP_BINARY": "php", + "WP_SITEURL": "http://localhost:456", + "WP_TESTS_DOMAIN": "localhost:456", + "WP_TESTS_EMAIL": "admin@example.org", + "WP_TESTS_TITLE": "Test Blog", + }, + "coreSource": { + "basename": "WordPress", + "clonePath": "/cache/5fea4c5689ef6cc4a4e6eaaa39323338/WordPress", + "path": "/cache/5fea4c5689ef6cc4a4e6eaaa39323338/WordPress", + "ref": "trunk", + "testsPath": "/cache/5fea4c5689ef6cc4a4e6eaaa39323338/tests-WordPress", + "type": "git", + "url": "https://github.com/WordPress/WordPress.git", + }, + "mappings": {}, + "phpVersion": null, + "pluginSources": [], + "port": 456, + "themeSources": [], + }, + }, + "name": "gutenberg", + "workDirectoryPath": "/cache/5fea4c5689ef6cc4a4e6eaaa39323338", +} +`; + +exports[`Config Integration should load local configuration file 1`] = ` +{ + "afterSetup": "test", + "configDirectoryPath": "/test/gutenberg", + "detectedLocalConfig": true, + "dockerComposeConfigPath": "/cache/5fea4c5689ef6cc4a4e6eaaa39323338/docker-compose.yml", + "env": { + "development": { + "config": { + "SCRIPT_DEBUG": true, + "WP_DEBUG": true, + "WP_ENVIRONMENT_TYPE": "local", + "WP_HOME": "http://localhost:123", + "WP_PHP_BINARY": "php", + "WP_SITEURL": "http://localhost:123", + "WP_TESTS_DOMAIN": "localhost:123", + "WP_TESTS_EMAIL": "admin@example.org", + "WP_TESTS_TITLE": "Test Blog", + }, + "coreSource": { + "basename": "WordPress", + "clonePath": "/cache/5fea4c5689ef6cc4a4e6eaaa39323338/WordPress", + "path": "/cache/5fea4c5689ef6cc4a4e6eaaa39323338/WordPress", + "ref": "trunk", + "testsPath": "/cache/5fea4c5689ef6cc4a4e6eaaa39323338/tests-WordPress", + "type": "git", + "url": "https://github.com/WordPress/WordPress.git", + }, + "mappings": {}, + "phpVersion": null, + "pluginSources": [], + "port": 123, + "themeSources": [], + }, + "tests": { + "config": { + "SCRIPT_DEBUG": false, + "WP_DEBUG": false, + "WP_ENVIRONMENT_TYPE": "local", + "WP_HOME": "http://localhost:8889", + "WP_PHP_BINARY": "php", + "WP_SITEURL": "http://localhost:8889", + "WP_TESTS_DOMAIN": "localhost:8889", + "WP_TESTS_EMAIL": "admin@example.org", + "WP_TESTS_TITLE": "Test Blog", + }, + "coreSource": { + "basename": "WordPress", + "clonePath": "/cache/5fea4c5689ef6cc4a4e6eaaa39323338/WordPress", + "path": "/cache/5fea4c5689ef6cc4a4e6eaaa39323338/WordPress", + "ref": "trunk", + "testsPath": "/cache/5fea4c5689ef6cc4a4e6eaaa39323338/tests-WordPress", + "type": "git", + "url": "https://github.com/WordPress/WordPress.git", + }, + "mappings": {}, + "phpVersion": null, + "pluginSources": [], + "port": 8889, + "themeSources": [], + }, + }, + "name": "gutenberg", + "workDirectoryPath": "/cache/5fea4c5689ef6cc4a4e6eaaa39323338", +} +`; + +exports[`Config Integration should use default configuration 1`] = ` +{ + "afterSetup": null, + "configDirectoryPath": "/test/gutenberg", + "detectedLocalConfig": true, + "dockerComposeConfigPath": "/cache/5fea4c5689ef6cc4a4e6eaaa39323338/docker-compose.yml", + "env": { + "development": { + "config": { + "SCRIPT_DEBUG": true, + "WP_DEBUG": true, + "WP_ENVIRONMENT_TYPE": "local", + "WP_HOME": "http://localhost:8888", + "WP_PHP_BINARY": "php", + "WP_SITEURL": "http://localhost:8888", + "WP_TESTS_DOMAIN": "localhost:8888", + "WP_TESTS_EMAIL": "admin@example.org", + "WP_TESTS_TITLE": "Test Blog", + }, + "coreSource": { + "basename": "WordPress", + "clonePath": "/cache/5fea4c5689ef6cc4a4e6eaaa39323338/WordPress", + "path": "/cache/5fea4c5689ef6cc4a4e6eaaa39323338/WordPress", + "ref": "100.0.0", + "testsPath": "/cache/5fea4c5689ef6cc4a4e6eaaa39323338/tests-WordPress", + "type": "git", + "url": "https://github.com/WordPress/WordPress.git", + }, + "mappings": {}, + "phpVersion": null, + "pluginSources": [], + "port": 8888, + "themeSources": [], + }, + "tests": { + "config": { + "SCRIPT_DEBUG": false, + "WP_DEBUG": false, + "WP_ENVIRONMENT_TYPE": "local", + "WP_HOME": "http://localhost:8889", + "WP_PHP_BINARY": "php", + "WP_SITEURL": "http://localhost:8889", + "WP_TESTS_DOMAIN": "localhost:8889", + "WP_TESTS_EMAIL": "admin@example.org", + "WP_TESTS_TITLE": "Test Blog", + }, + "coreSource": { + "basename": "WordPress", + "clonePath": "/cache/5fea4c5689ef6cc4a4e6eaaa39323338/WordPress", + "path": "/cache/5fea4c5689ef6cc4a4e6eaaa39323338/WordPress", + "ref": "100.0.0", + "testsPath": "/cache/5fea4c5689ef6cc4a4e6eaaa39323338/tests-WordPress", + "type": "git", + "url": "https://github.com/WordPress/WordPress.git", + }, + "mappings": {}, + "phpVersion": null, + "pluginSources": [], + "port": 8889, + "themeSources": [], + }, + }, + "name": "gutenberg", + "workDirectoryPath": "/cache/5fea4c5689ef6cc4a4e6eaaa39323338", +} +`; + +exports[`Config Integration should use environment variables over local and override configuration files 1`] = ` +{ + "afterSetup": "test", + "configDirectoryPath": "/test/gutenberg", + "detectedLocalConfig": true, + "dockerComposeConfigPath": "/cache/5fea4c5689ef6cc4a4e6eaaa39323338/docker-compose.yml", + "env": { + "development": { + "config": { + "SCRIPT_DEBUG": true, + "WP_DEBUG": true, + "WP_ENVIRONMENT_TYPE": "local", + "WP_HOME": "http://localhost:12345", + "WP_PHP_BINARY": "php", + "WP_SITEURL": "http://localhost:12345", + "WP_TESTS_DOMAIN": "localhost:12345", + "WP_TESTS_EMAIL": "admin@example.org", + "WP_TESTS_TITLE": "Test Blog", + }, + "coreSource": { + "basename": "WordPress", + "clonePath": "/cache/5fea4c5689ef6cc4a4e6eaaa39323338/WordPress", + "path": "/cache/5fea4c5689ef6cc4a4e6eaaa39323338/WordPress", + "ref": "trunk", + "testsPath": "/cache/5fea4c5689ef6cc4a4e6eaaa39323338/tests-WordPress", + "type": "git", + "url": "https://github.com/WordPress/WordPress.git", + }, + "mappings": {}, + "phpVersion": null, + "pluginSources": [], + "port": 12345, + "testsPort": 61234, + "themeSources": [], + }, + "tests": { + "config": { + "SCRIPT_DEBUG": false, + "WP_DEBUG": false, + "WP_ENVIRONMENT_TYPE": "local", + "WP_HOME": "http://localhost:61234", + "WP_PHP_BINARY": "php", + "WP_SITEURL": "http://localhost:61234", + "WP_TESTS_DOMAIN": "localhost:61234", + "WP_TESTS_EMAIL": "admin@example.org", + "WP_TESTS_TITLE": "Test Blog", + }, + "coreSource": { + "basename": "WordPress", + "clonePath": "/cache/5fea4c5689ef6cc4a4e6eaaa39323338/WordPress", + "path": "/cache/5fea4c5689ef6cc4a4e6eaaa39323338/WordPress", + "ref": "trunk", + "testsPath": "/cache/5fea4c5689ef6cc4a4e6eaaa39323338/tests-WordPress", + "type": "git", + "url": "https://github.com/WordPress/WordPress.git", + }, + "mappings": {}, + "phpVersion": null, + "pluginSources": [], + "port": 61234, + "testsPort": 61234, + "themeSources": [], + }, + }, + "name": "gutenberg", + "workDirectoryPath": "/cache/5fea4c5689ef6cc4a4e6eaaa39323338", +} +`; diff --git a/packages/env/lib/config/test/__snapshots__/config.js.snap b/packages/env/lib/config/test/__snapshots__/config.js.snap deleted file mode 100644 index c7bcdbd94d7ff..0000000000000 --- a/packages/env/lib/config/test/__snapshots__/config.js.snap +++ /dev/null @@ -1,83 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`readConfig config file should match snapshot 1`] = ` -{ - "configDirectoryPath": ".", - "detectedLocalConfig": true, - "env": { - "development": { - "config": { - "SCRIPT_DEBUG": true, - "TEST": 100, - "TEST_VAL1": 1, - "TEST_VAL2": "hello", - "TEST_VAL3": false, - "WP_DEBUG": true, - "WP_ENVIRONMENT_TYPE": "local", - "WP_HOME": "http://localhost:2000", - "WP_PHP_BINARY": "php", - "WP_SITEURL": "http://localhost:2000", - "WP_TESTS_DOMAIN": "localhost:2000", - "WP_TESTS_EMAIL": "admin@example.org", - "WP_TESTS_TITLE": "Test Blog", - }, - "mappings": {}, - "phpVersion": null, - "pluginSources": [], - "port": 2000, - "themeSources": [], - }, - "tests": { - "config": { - "SCRIPT_DEBUG": false, - "TEST": 200, - "TEST_VAL1": 1, - "TEST_VAL2": "hello", - "TEST_VAL3": false, - "WP_DEBUG": false, - "WP_ENVIRONMENT_TYPE": "local", - "WP_HOME": "http://localhost:1000", - "WP_PHP_BINARY": "php", - "WP_SITEURL": "http://localhost:1000", - "WP_TESTS_DOMAIN": "localhost:1000", - "WP_TESTS_EMAIL": "admin@example.org", - "WP_TESTS_TITLE": "Test Blog", - }, - "mappings": {}, - "phpVersion": null, - "pluginSources": [], - "port": 1000, - "themeSources": [], - }, - }, - "name": ".", -} -`; - -exports[`readConfig wp config values should use default config values 1`] = ` -{ - "SCRIPT_DEBUG": false, - "WP_DEBUG": false, - "WP_ENVIRONMENT_TYPE": "local", - "WP_HOME": "http://localhost:8889", - "WP_PHP_BINARY": "php", - "WP_SITEURL": "http://localhost:8889", - "WP_TESTS_DOMAIN": "localhost:8889", - "WP_TESTS_EMAIL": "admin@example.org", - "WP_TESTS_TITLE": "Test Blog", -} -`; - -exports[`readConfig wp config values should use default config values 2`] = ` -{ - "SCRIPT_DEBUG": true, - "WP_DEBUG": true, - "WP_ENVIRONMENT_TYPE": "local", - "WP_HOME": "http://localhost:8888", - "WP_PHP_BINARY": "php", - "WP_SITEURL": "http://localhost:8888", - "WP_TESTS_DOMAIN": "localhost:8888", - "WP_TESTS_EMAIL": "admin@example.org", - "WP_TESTS_TITLE": "Test Blog", -} -`; diff --git a/packages/env/lib/config/test/add-or-replace-port.js b/packages/env/lib/config/test/add-or-replace-port.js index f5186e1db9373..e04722e22435a 100644 --- a/packages/env/lib/config/test/add-or-replace-port.js +++ b/packages/env/lib/config/test/add-or-replace-port.js @@ -1,10 +1,11 @@ +'use strict'; /** * Internal dependencies */ const addOrReplacePort = require( '../add-or-replace-port.js' ); describe( 'addOrReplacePort', () => { - beforeEach( () => { + afterEach( () => { jest.clearAllMocks(); } ); @@ -39,6 +40,33 @@ describe( 'addOrReplacePort', () => { } } ); + it( 'should support number ports', () => { + const testMap = [ { in: 'test', expect: 'test:104' } ]; + + for ( const test of testMap ) { + const result = addOrReplacePort( test.in, 104, false ); + expect( result ).toEqual( test.expect ); + } + } ); + + it( 'should not add default HTTP port', () => { + const testMap = [ { in: 'test', expect: 'test' } ]; + + for ( const test of testMap ) { + const result = addOrReplacePort( test.in, 80, false ); + expect( result ).toEqual( test.expect ); + } + } ); + + it( 'should not add default HTTPS port', () => { + const testMap = [ { in: 'test', expect: 'test' } ]; + + for ( const test of testMap ) { + const result = addOrReplacePort( test.in, 443, false ); + expect( result ).toEqual( test.expect ); + } + } ); + it( 'should do nothing if port is present but replacement is not requested', () => { const testMap = [ { in: 'test', expect: 'test:103' }, diff --git a/packages/env/lib/config/test/config-integration.js b/packages/env/lib/config/test/config-integration.js new file mode 100644 index 0000000000000..08c3277f06a45 --- /dev/null +++ b/packages/env/lib/config/test/config-integration.js @@ -0,0 +1,143 @@ +'use strict'; +/* eslint-disable jest/no-conditional-expect */ +/** + * External dependencies + */ +const path = require( 'path' ); +const { readFile } = require( 'fs' ).promises; + +/** + * Internal dependencies + */ +const loadConfig = require( '../load-config' ); +const detectDirectoryType = require( '../detect-directory-type' ); + +jest.mock( 'fs', () => ( { + promises: { + readFile: jest.fn(), + stat: jest.fn().mockResolvedValue( true ), + }, +} ) ); + +// This mocks a small response with a format matching the stable-check API. +// It makes getLatestWordPressVersion resolve to "100.0.0". +jest.mock( 'got', () => + jest.fn( ( url ) => ( { + json: () => { + if ( url === 'https://api.wordpress.org/core/stable-check/1.0/' ) { + return Promise.resolve( { + '1.0': 'insecure', + '99.1.1': 'outdated', + '100.0.0': 'latest', + '100.0.1': 'fancy', + } ); + } + }, + } ) ) +); + +jest.mock( '../detect-directory-type', () => jest.fn() ); + +describe( 'Config Integration', () => { + beforeEach( () => { + process.env.WP_ENV_HOME = '/cache'; + detectDirectoryType.mockResolvedValue( null ); + } ); + + afterEach( () => { + delete process.env.WP_ENV_HOME; + delete process.env.WP_ENV_PORT; + delete process.env.WP_ENV_TESTS_PORT; + delete process.env.WP_ENV_AFTER_SETUP; + } ); + + it( 'should use default configuration', async () => { + readFile.mockImplementation( async () => { + throw { code: 'ENOENT' }; + } ); + + const config = await loadConfig( '/test/gutenberg' ); + + expect( config.env.development.port ).toEqual( 8888 ); + expect( config.env.tests.port ).toEqual( 8889 ); + expect( config ).toMatchSnapshot(); + } ); + + it( 'should load local configuration file', async () => { + readFile.mockImplementation( async ( fileName ) => { + if ( fileName === '/test/gutenberg/.wp-env.json' ) { + return JSON.stringify( { + core: 'WordPress/WordPress#trunk', + port: 123, + afterSetup: 'test', + } ); + } + + throw { code: 'ENOENT' }; + } ); + + const config = await loadConfig( path.resolve( '/test/gutenberg' ) ); + + expect( config.env.development.port ).toEqual( 123 ); + expect( config.env.tests.port ).toEqual( 8889 ); + expect( config ).toMatchSnapshot(); + } ); + + it( 'should load local and override configuration files', async () => { + readFile.mockImplementation( async ( fileName ) => { + if ( fileName === '/test/gutenberg/.wp-env.json' ) { + return JSON.stringify( { + core: 'WordPress/WordPress#trunk', + port: 123, + testsPort: 456, + } ); + } + + if ( fileName === '/test/gutenberg/.wp-env.override.json' ) { + return JSON.stringify( { + port: 999, + } ); + } + + throw { code: 'ENOENT' }; + } ); + + const config = await loadConfig( path.resolve( '/test/gutenberg' ) ); + + expect( config.env.development.port ).toEqual( 999 ); + expect( config.env.tests.port ).toEqual( 456 ); + expect( config ).toMatchSnapshot(); + } ); + + it( 'should use environment variables over local and override configuration files', async () => { + process.env.WP_ENV_PORT = 12345; + process.env.WP_ENV_TESTS_PORT = 61234; + process.env.WP_ENV_AFTER_SETUP = 'test'; + + readFile.mockImplementation( async ( fileName ) => { + if ( fileName === '/test/gutenberg/.wp-env.json' ) { + return JSON.stringify( { + core: 'WordPress/WordPress#trunk', + port: 123, + testsPort: 456, + afterSetup: 'local', + } ); + } + + if ( fileName === '/test/gutenberg/.wp-env.override.json' ) { + return JSON.stringify( { + port: 999, + } ); + } + + throw { code: 'ENOENT' }; + } ); + + const config = await loadConfig( path.resolve( '/test/gutenberg' ) ); + + expect( config.env.development.port ).toEqual( 12345 ); + expect( config.env.tests.port ).toEqual( 61234 ); + expect( config ).toMatchSnapshot(); + } ); +} ); +/* eslint-enable jest/no-conditional-expect */ diff --git a/packages/env/lib/config/test/config.js b/packages/env/lib/config/test/config.js deleted file mode 100644 index 7552af2239a78..0000000000000 --- a/packages/env/lib/config/test/config.js +++ /dev/null @@ -1,1241 +0,0 @@ -/* eslint-disable jest/no-conditional-expect */ -/** - * External dependencies - */ -const { readFile, stat } = require( 'fs' ).promises; -const os = require( 'os' ); -const { join, resolve } = require( 'path' ); - -/** - * Internal dependencies - */ -const { readConfig, ValidationError } = require( '..' ); -const detectDirectoryType = require( '../detect-directory-type' ); - -jest.mock( 'fs', () => ( { - promises: { - readFile: jest.fn(), - stat: jest.fn().mockReturnValue( Promise.resolve( false ) ), - }, -} ) ); - -// This mocks a small response with a format matching the stable-check API. -// It makes getLatestWordPressVersion resolve to "100.0.0". -jest.mock( 'got', () => - jest.fn( ( url ) => ( { - json: () => { - if ( url === 'https://api.wordpress.org/core/stable-check/1.0/' ) { - return Promise.resolve( { - '1.0': 'insecure', - '99.1.1': 'outdated', - '100.0.0': 'latest', - '100.0.1': 'fancy', - } ); - } - }, - } ) ) -); - -jest.mock( '../detect-directory-type', () => jest.fn() ); - -describe( 'readConfig', () => { - beforeEach( () => { - jest.clearAllMocks(); - } ); - - describe( 'config file', () => { - it( 'should throw a validation error if config is invalid JSON', async () => { - readFile.mockImplementation( () => Promise.resolve( '{' ) ); - expect.assertions( 2 ); - try { - await readConfig( '.wp-env.json' ); - } catch ( error ) { - expect( error ).toBeInstanceOf( ValidationError ); - expect( error.message ).toContain( 'Invalid .wp-env.json' ); - } - } ); - - it( 'should throw a validation error if config cannot be read', async () => { - readFile.mockImplementation( () => - Promise.reject( { message: 'Uh oh!' } ) - ); - expect.assertions( 2 ); - try { - await readConfig( '.wp-env.json' ); - } catch ( error ) { - expect( error ).toBeInstanceOf( ValidationError ); - expect( error.message ).toContain( - 'Could not read .wp-env.json' - ); - } - } ); - - it( 'should throw a validation error if WP_SITEURL is not a valid URL', async () => { - readFile.mockImplementation( () => - Promise.resolve( - JSON.stringify( { - config: { - WP_SITEURL: 'test', - }, - } ) - ) - ); - expect.assertions( 2 ); - try { - await readConfig( '.wp-env.json' ); - } catch ( error ) { - expect( error ).toBeInstanceOf( ValidationError ); - expect( error.message ).toContain( 'must be a valid URL' ); - } - } ); - - it( 'should throw a validation error if WP_HOME is not a valid URL', async () => { - readFile.mockImplementation( () => - Promise.resolve( - JSON.stringify( { - config: { - WP_SITEURL: 'test', - }, - } ) - ) - ); - expect.assertions( 2 ); - try { - await readConfig( '.wp-env.json' ); - } catch ( error ) { - expect( error ).toBeInstanceOf( ValidationError ); - expect( error.message ).toContain( 'must be a valid URL' ); - } - } ); - - it( 'should infer a core config when ran from a core directory', async () => { - readFile.mockImplementation( () => - Promise.reject( { code: 'ENOENT' } ) - ); - detectDirectoryType.mockImplementation( () => 'core' ); - const config = await readConfig( '.wp-env.json' ); - expect( config.env.development.coreSource.type ).toBe( 'local' ); - expect( config.env.tests.coreSource ).not.toBeNull(); - expect( config.env.development.pluginSources ).toHaveLength( 0 ); - expect( config.env.development.themeSources ).toHaveLength( 0 ); - } ); - - it( 'should use the most recent stable WordPress version for the default core source', async () => { - readFile.mockImplementation( () => - Promise.resolve( JSON.stringify( {} ) ) - ); - const config = await readConfig( '.wp-env.json' ); - - const expected = { - url: 'https://github.com/WordPress/WordPress.git', - type: 'git', - basename: 'WordPress', - ref: '100.0.0', // From the mock of https at the top of the file. - }; - - expect( config.env.development.coreSource ).toMatchObject( - expected - ); - expect( config.env.tests.coreSource ).toMatchObject( expected ); - } ); - - it( 'should infer a plugin config when ran from a plugin directory', async () => { - readFile.mockImplementation( () => - Promise.reject( { code: 'ENOENT' } ) - ); - detectDirectoryType.mockImplementation( () => 'plugin' ); - const config = await readConfig( '.wp-env.json' ); - expect( config.env.development.coreSource.type ).toBe( 'git' ); - expect( config.env.development.pluginSources ).toHaveLength( 1 ); - expect( config.env.tests.pluginSources ).toHaveLength( 1 ); - expect( config.env.development.themeSources ).toHaveLength( 0 ); - } ); - - it( 'should infer a theme config when ran from a theme directory', async () => { - readFile.mockImplementation( () => - Promise.reject( { code: 'ENOENT' } ) - ); - detectDirectoryType.mockImplementation( () => 'theme' ); - const config = await readConfig( '.wp-env.json' ); - expect( config.env.development.coreSource.type ).toBe( 'git' ); - expect( config.env.tests.coreSource.type ).toBe( 'git' ); - expect( config.env.development.themeSources ).toHaveLength( 1 ); - expect( config.env.tests.themeSources ).toHaveLength( 1 ); - expect( config.env.development.pluginSources ).toHaveLength( 0 ); - expect( config.env.tests.pluginSources ).toHaveLength( 0 ); - } ); - - it( 'should use the WP_ENV_HOME environment variable only if specified', async () => { - readFile.mockImplementation( () => - Promise.resolve( JSON.stringify( {} ) ) - ); - const oldEnvHome = process.env.WP_ENV_HOME; - - expect.assertions( 2 ); - - process.env.WP_ENV_HOME = 'here/is/a/path'; - const configWith = await readConfig( '.wp-env.json' ); - expect( - configWith.workDirectoryPath.includes( - join( 'here', 'is', 'a', 'path' ) - ) - ).toBe( true ); - - process.env.WP_ENV_HOME = undefined; - const configWithout = await readConfig( '.wp-env.json' ); - expect( - configWithout.workDirectoryPath.includes( - join( 'here', 'is', 'a', 'path' ) - ) - ).toBe( false ); - - process.env.WP_ENV_HOME = oldEnvHome; - } ); - - it( 'should use the WP_ENV_HOME environment variable on Linux', async () => { - readFile.mockImplementation( () => - Promise.resolve( JSON.stringify( {} ) ) - ); - const oldEnvHome = process.env.WP_ENV_HOME; - const oldOsPlatform = os.platform; - os.platform = () => 'linux'; - - expect.assertions( 2 ); - - process.env.WP_ENV_HOME = 'here/is/a/path'; - const configWith = await readConfig( '.wp-env.json' ); - expect( - configWith.workDirectoryPath.includes( - join( 'here', 'is', 'a', 'path' ) - ) - ).toBe( true ); - - process.env.WP_ENV_HOME = undefined; - const configWithout = await readConfig( '.wp-env.json' ); - expect( - configWithout.workDirectoryPath.includes( - join( 'here', 'is', 'a', 'path' ) - ) - ).toBe( false ); - - process.env.WP_ENV_HOME = oldEnvHome; - os.platform = oldOsPlatform; - } ); - - it( 'should use a non-private folder with Snap-installed Docker', async () => { - readFile.mockImplementation( () => - Promise.resolve( JSON.stringify( {} ) ) - ); - stat.mockReturnValue( Promise.resolve( true ) ); - - expect.assertions( 2 ); - - const config = await readConfig( '.wp-env.json' ); - expect( config.workDirectoryPath.includes( '.wp-env' ) ).toBe( - false - ); - expect( config.workDirectoryPath.includes( 'wp-env' ) ).toBe( - true - ); - } ); - - it( 'should match snapshot', async () => { - // Note: did not add sources to this config because they include absolute - // paths which would be different elsewhere. - readFile.mockImplementation( () => - Promise.resolve( - JSON.stringify( { - port: 2000, - config: { - TEST_VAL1: 1, - TEST_VAL2: 'hello', - TEST_VAL3: false, - }, - env: { - development: { - config: { - TEST: 100, - }, - }, - tests: { - port: 1000, - config: { - TEST: 200, - }, - }, - }, - } ) - ) - ); - const config = await readConfig( '.wp-env.json' ); - // Remove generated values which are different on other machines. - delete config.dockerComposeConfigPath; - delete config.workDirectoryPath; - - // This encodes both the version of WordPress (which can change frequently) - // as well as the wp-env directory which is unique on every machine. - delete config.env.development.coreSource; - delete config.env.tests.coreSource; - expect( config ).toMatchSnapshot(); - } ); - } ); - - describe( 'source parsing', () => { - it( "should throw a validation error if 'core' is not a string", async () => { - readFile.mockImplementation( () => - Promise.resolve( JSON.stringify( { core: 123 } ) ) - ); - expect.assertions( 2 ); - try { - await readConfig( '.wp-env.json' ); - } catch ( error ) { - expect( error ).toBeInstanceOf( ValidationError ); - expect( error.message ).toContain( 'must be null or a string' ); - } - } ); - - it( "should throw a validation error if 'plugins' is not an array of strings", async () => { - readFile.mockImplementation( () => - Promise.resolve( - JSON.stringify( { plugins: [ 'test', 123 ] } ) - ) - ); - expect.assertions( 2 ); - try { - await readConfig( '.wp-env.json' ); - } catch ( error ) { - expect( error ).toBeInstanceOf( ValidationError ); - expect( error.message ).toContain( - 'must be an array of strings' - ); - } - } ); - - it( "should throw a validation error if 'themes' is not an array of strings", async () => { - readFile.mockImplementation( () => - Promise.resolve( JSON.stringify( { themes: [ 'test', 123 ] } ) ) - ); - expect.assertions( 2 ); - try { - await readConfig( '.wp-env.json' ); - } catch ( error ) { - expect( error ).toBeInstanceOf( ValidationError ); - expect( error.message ).toContain( - 'must be an array of strings' - ); - } - } ); - - it( 'should parse local sources', async () => { - readFile.mockImplementation( () => - Promise.resolve( - JSON.stringify( { - plugins: [ - './relative', - '../parent', - `${ os.homedir() }/home`, - ], - } ) - ) - ); - const config = await readConfig( '.wp-env.json' ); - - expect( config.env.development ).toMatchObject( { - pluginSources: [ - { - type: 'local', - path: expect.stringMatching( /^(\/|\\).*relative$/ ), - basename: 'relative', - }, - { - type: 'local', - path: expect.stringMatching( /^(\/|\\).*parent$/ ), - basename: 'parent', - }, - { - type: 'local', - path: expect.stringMatching( /^(\/|\\).*home$/ ), - basename: 'home', - }, - ], - } ); - expect( config.env.tests ).toMatchObject( { - pluginSources: [ - { - type: 'local', - path: expect.stringMatching( /^(\/|\\).*relative$/ ), - basename: 'relative', - }, - { - type: 'local', - path: expect.stringMatching( /^(\/|\\).*parent$/ ), - basename: 'parent', - }, - { - type: 'local', - path: expect.stringMatching( /^(\/|\\).*home$/ ), - basename: 'home', - }, - ], - } ); - } ); - - it( 'should override plugins/themes on an environment level', async () => { - readFile.mockImplementation( () => - Promise.resolve( - JSON.stringify( { - plugins: [ './test1', './foo2' ], - themes: [ './test2', './foo' ], - env: { - development: { - plugins: [ './test1a' ], - themes: [ './test2a' ], - }, - tests: { - plugins: [ './test1b' ], - themes: [ './test2b' ], - }, - }, - } ) - ) - ); - const config = await readConfig( '.wp-env.json' ); - expect( config.env.development.pluginSources ).toEqual( [ - { - type: 'local', - path: expect.stringMatching( /^(\/|\\).*test1a$/ ), - basename: 'test1a', - }, - ] ); - expect( config.env.development.themeSources ).toEqual( [ - { - type: 'local', - path: expect.stringMatching( /^(\/|\\).*test2a$/ ), - basename: 'test2a', - }, - ] ); - expect( config.env.tests.pluginSources ).toEqual( [ - { - type: 'local', - path: expect.stringMatching( /^(\/|\\).*test1b$/ ), - basename: 'test1b', - }, - ] ); - expect( config.env.tests.themeSources ).toEqual( [ - { - type: 'local', - path: expect.stringMatching( /^(\/|\\).*test2b$/ ), - basename: 'test2b', - }, - ] ); - } ); - - it( "should set testsPath on the 'core' source", async () => { - readFile.mockImplementation( () => - Promise.resolve( JSON.stringify( { core: './relative' } ) ) - ); - const config = await readConfig( '.wp-env.json' ); - expect( config.env.development ).toMatchObject( { - coreSource: { - type: 'local', - path: expect.stringMatching( /^(\/|\\).*relative$/ ), - testsPath: expect.stringMatching( - /^(\/|\\).*tests-relative$/ - ), - }, - } ); - expect( config.env.tests ).toMatchObject( { - coreSource: { - type: 'local', - path: expect.stringMatching( /^(\/|\\).*relative$/ ), - testsPath: expect.stringMatching( - /^(\/|\\).*tests-relative$/ - ), - }, - } ); - } ); - - it( 'should parse GitHub sources', async () => { - readFile.mockImplementation( () => - Promise.resolve( - JSON.stringify( { - plugins: [ - 'WordPress/gutenberg', - 'WordPress/gutenberg#trunk', - 'WordPress/gutenberg#5.0', - 'WordPress/theme-experiments/tt1-blocks#tt1-blocks@0.4.3', - ], - } ) - ) - ); - const config = await readConfig( '.wp-env.json' ); - const matchObj = { - pluginSources: [ - { - type: 'git', - url: 'https://github.com/WordPress/gutenberg.git', - ref: undefined, - path: expect.stringMatching( /^(\/|\\).*gutenberg$/ ), - basename: 'gutenberg', - }, - { - type: 'git', - url: 'https://github.com/WordPress/gutenberg.git', - ref: 'trunk', - path: expect.stringMatching( /^(\/|\\).*gutenberg$/ ), - basename: 'gutenberg', - }, - { - type: 'git', - url: 'https://github.com/WordPress/gutenberg.git', - ref: '5.0', - path: expect.stringMatching( /^(\/|\\).*gutenberg$/ ), - basename: 'gutenberg', - }, - { - type: 'git', - url: 'https://github.com/WordPress/theme-experiments.git', - ref: 'tt1-blocks@0.4.3', - path: expect.stringMatching( - /^(\/|\\).*theme-experiments(\/|\\)tt1-blocks$/ - ), - basename: 'tt1-blocks', - }, - ], - }; - expect( config.env.tests ).toMatchObject( matchObj ); - expect( config.env.development ).toMatchObject( matchObj ); - } ); - - it( 'should parse wordpress.org sources', async () => { - readFile.mockImplementation( () => - Promise.resolve( - JSON.stringify( { - plugins: [ - 'https://downloads.wordpress.org/plugin/gutenberg.zip', - 'https://downloads.wordpress.org/plugin/gutenberg.8.1.0.zip', - 'https://downloads.wordpress.org/theme/twentytwenty.zip', - 'https://downloads.wordpress.org/theme/twentytwenty.1.3.zip', - ], - } ) - ) - ); - const config = await readConfig( '.wp-env.json' ); - const matchObj = { - pluginSources: [ - { - type: 'zip', - url: 'https://downloads.wordpress.org/plugin/gutenberg.zip', - path: expect.stringMatching( /^(\/|\\).*gutenberg$/ ), - basename: 'gutenberg', - }, - { - type: 'zip', - url: 'https://downloads.wordpress.org/plugin/gutenberg.8.1.0.zip', - path: expect.stringMatching( /^(\/|\\).*gutenberg$/ ), - basename: 'gutenberg', - }, - { - type: 'zip', - url: 'https://downloads.wordpress.org/theme/twentytwenty.zip', - path: expect.stringMatching( - /^(\/|\\).*twentytwenty$/ - ), - basename: 'twentytwenty', - }, - { - type: 'zip', - url: 'https://downloads.wordpress.org/theme/twentytwenty.1.3.zip', - path: expect.stringMatching( - /^(\/|\\).*twentytwenty$/ - ), - basename: 'twentytwenty', - }, - ], - }; - expect( config.env.development ).toMatchObject( matchObj ); - expect( config.env.tests ).toMatchObject( matchObj ); - } ); - - it( 'should parse zip sources', async () => { - readFile.mockImplementation( () => - Promise.resolve( - JSON.stringify( { - plugins: [ - 'https://www.example.com/test/path/to/gutenberg.zip', - 'https://www.example.com/test/path/to/gutenberg.8.1.0.zip', - 'https://www.example.com/test/path/to/gutenberg.8.1.0.zip?auth=thisIsAString&token=secondString', - 'https://www.example.com/test/path/to/twentytwenty.zip', - 'https://www.example.com/test/path/to/twentytwenty.1.3.zip', - 'https://example.com/twentytwenty.1.3.zip', - ], - } ) - ) - ); - const config = await readConfig( '.wp-env.json' ); - const matchObj = { - pluginSources: [ - { - type: 'zip', - url: 'https://www.example.com/test/path/to/gutenberg.zip', - path: expect.stringMatching( /^(\/|\\).*gutenberg$/ ), - basename: 'gutenberg', - }, - { - type: 'zip', - url: 'https://www.example.com/test/path/to/gutenberg.8.1.0.zip', - path: expect.stringMatching( - /^(\/|\\).*gutenberg.8.1.0$/ - ), - basename: 'gutenberg.8.1.0', - }, - { - type: 'zip', - url: 'https://www.example.com/test/path/to/gutenberg.8.1.0.zip?auth=thisIsAString&token=secondString', - path: expect.stringMatching( - /^(\/|\\).*gutenberg.8.1.0$/ - ), - basename: 'gutenberg.8.1.0', - }, - { - type: 'zip', - url: 'https://www.example.com/test/path/to/twentytwenty.zip', - path: expect.stringMatching( - /^(\/|\\).*twentytwenty$/ - ), - basename: 'twentytwenty', - }, - { - type: 'zip', - url: 'https://www.example.com/test/path/to/twentytwenty.1.3.zip', - path: expect.stringMatching( - /^(\/|\\).*twentytwenty.1.3$/ - ), - basename: 'twentytwenty.1.3', - }, - { - type: 'zip', - url: 'https://example.com/twentytwenty.1.3.zip', - path: expect.stringMatching( - /^(\/|\\).*twentytwenty.1.3$/ - ), - basename: 'twentytwenty.1.3', - }, - ], - }; - expect( config.env.development ).toMatchObject( matchObj ); - expect( config.env.tests ).toMatchObject( matchObj ); - } ); - - it( 'should throw a validaton error if there is an unknown source', async () => { - readFile.mockImplementation( () => - Promise.resolve( JSON.stringify( { plugins: [ 'invalid' ] } ) ) - ); - expect.assertions( 2 ); - try { - await readConfig( '.wp-env.json' ); - } catch ( error ) { - expect( error ).toBeInstanceOf( ValidationError ); - expect( error.message ).toContain( - 'Invalid or unrecognized source' - ); - } - } ); - } ); - - describe( 'mappings parsing', () => { - it( 'should parse mappings into sources', async () => { - readFile.mockImplementation( () => - Promise.resolve( - JSON.stringify( { - mappings: { - test: './relative', - test2: 'WordPress/gutenberg#trunk', - }, - } ) - ) - ); - const config = await readConfig( '.wp-env.json' ); - const matchObj = { - test: { - type: 'local', - path: expect.stringMatching( /^(\/|\\).*relative$/ ), - basename: 'relative', - }, - test2: { - type: 'git', - path: expect.stringMatching( /^(\/|\\).*gutenberg$/ ), - basename: 'gutenberg', - }, - }; - expect( config.env.development.mappings ).toMatchObject( matchObj ); - expect( config.env.development.mappings ).toMatchObject( matchObj ); - } ); - - it( 'should throw a validaton error if there is an invalid mapping', async () => { - readFile.mockImplementation( () => - Promise.resolve( - JSON.stringify( { mappings: { test: 'false' } } ) - ) - ); - expect.assertions( 2 ); - try { - await readConfig( '.wp-env.json' ); - } catch ( error ) { - expect( error ).toBeInstanceOf( ValidationError ); - expect( error.message ).toContain( - 'Invalid or unrecognized source' - ); - } - } ); - - it( 'throws an error if a mapping is badly formatted', async () => { - readFile.mockImplementation( () => - Promise.resolve( - JSON.stringify( { - mappings: { test: null }, - } ) - ) - ); - expect.assertions( 2 ); - try { - await readConfig( '.wp-env.json' ); - } catch ( error ) { - expect( error ).toBeInstanceOf( ValidationError ); - expect( error.message ).toContain( - 'Invalid .wp-env.json: "mappings.test" should be a string.' - ); - } - } ); - - it( 'throws an error if mappings is not an object', async () => { - readFile.mockImplementation( () => - Promise.resolve( - JSON.stringify( { - mappings: 'not object', - } ) - ) - ); - expect.assertions( 2 ); - try { - await readConfig( '.wp-env.json' ); - } catch ( error ) { - expect( error ).toBeInstanceOf( ValidationError ); - expect( error.message ).toContain( - 'Invalid .wp-env.json: "mappings" must be an object.' - ); - } - } ); - - it( 'should return an empty mappings object if none are passed', async () => { - readFile.mockImplementation( () => - Promise.resolve( JSON.stringify( { mappings: {} } ) ) - ); - const config = await readConfig( '.wp-env.json' ); - expect( config.env.development.mappings ).toEqual( {} ); - expect( config.env.tests.mappings ).toEqual( {} ); - } ); - - it( 'should merge mappings from different environments', async () => { - readFile.mockImplementation( () => - Promise.resolve( - JSON.stringify( { - mappings: { - test1: '/test1', - }, - env: { - tests: { - mappings: { - test2: '/test2', - }, - }, - development: { - mappings: { - test3: '/test3', - }, - }, - }, - } ) - ) - ); - const config = await readConfig( '.wp-env.json' ); - expect( config.env.development.mappings ).toEqual( { - test1: { - basename: 'test1', - // resolve is required to remove drive letters on Windows. - path: resolve( '/test1' ), - type: 'local', - }, - test3: { - basename: 'test3', - path: resolve( '/test3' ), - type: 'local', - }, - } ); - expect( config.env.tests.mappings ).toEqual( { - test1: { - basename: 'test1', - path: resolve( '/test1' ), - type: 'local', - }, - test2: { - basename: 'test2', - path: resolve( '/test2' ), - type: 'local', - }, - } ); - } ); - - it( 'should override less specific mappings with more specific mappings', async () => { - readFile.mockImplementation( () => - Promise.resolve( - JSON.stringify( { - mappings: { - test: '/test1', - }, - env: { - tests: { - mappings: { - test: '/test2', - }, - }, - development: { - mappings: { - test: '/test3', - }, - }, - }, - } ) - ) - ); - const config = await readConfig( '.wp-env.json' ); - expect( config.env.development.mappings ).toEqual( { - test: { - basename: 'test3', - path: resolve( '/test3' ), - type: 'local', - }, - } ); - expect( config.env.tests.mappings ).toEqual( { - test: { - basename: 'test2', - path: resolve( '/test2' ), - type: 'local', - }, - } ); - } ); - } ); - - describe( 'port number parsing', () => { - it( 'should throw a validaton error if the ports are not numbers', async () => { - expect.assertions( 10 ); - await testPortNumberValidation( 'port', 'string' ); - await testPortNumberValidation( 'testsPort', [], 'env.tests.' ); - await testPortNumberValidation( 'port', {} ); - await testPortNumberValidation( 'testsPort', false, 'env.tests.' ); - await testPortNumberValidation( 'port', null ); - } ); - - it( 'should throw a validaton error if the ports are the same', async () => { - expect.assertions( 2 ); - readFile.mockImplementation( () => - Promise.resolve( - JSON.stringify( { port: 8888, testsPort: 8888 } ) - ) - ); - try { - await readConfig( '.wp-env.json' ); - } catch ( error ) { - expect( error ).toBeInstanceOf( ValidationError ); - expect( error.message ).toContain( - 'Invalid .wp-env.json: Each port value must be unique.' - ); - } - } ); - - it( 'should parse custom ports', async () => { - readFile.mockImplementation( () => - Promise.resolve( - JSON.stringify( { - port: 1000, - testsPort: 2000, - } ) - ) - ); - const config = await readConfig( '.wp-env.json' ); - // Custom port is overridden while testsPort gets the deault value. - expect( config ).toMatchObject( { - env: { - development: { - port: 1000, - }, - tests: { - port: 2000, - }, - }, - } ); - } ); - - it( 'certain wp-config values should include the port number', async () => { - readFile.mockImplementation( () => - Promise.resolve( - JSON.stringify( { - port: 1000, - testsPort: 2000, - } ) - ) - ); - const config = await readConfig( '.wp-env.json' ); - // Custom port is overridden while testsPort gets the deault value. - expect( config ).toMatchObject( { - env: { - development: { - port: 1000, - config: { - WP_TESTS_DOMAIN: 'localhost:1000', - WP_SITEURL: 'http://localhost:1000', - WP_HOME: 'http://localhost:1000', - }, - }, - tests: { - port: 2000, - config: { - WP_TESTS_DOMAIN: 'localhost:2000', - WP_SITEURL: 'http://localhost:2000', - WP_HOME: 'http://localhost:2000', - }, - }, - }, - } ); - } ); - - it( 'should not overwrite port number for WP_HOME if set', async () => { - readFile.mockImplementation( () => - Promise.resolve( - JSON.stringify( { - port: 1000, - testsPort: 2000, - config: { - WP_HOME: 'http://localhost:3000', - }, - } ) - ) - ); - const config = await readConfig( '.wp-env.json' ); - // Custom port is overridden while testsPort gets the deault value. - expect( config ).toMatchObject( { - env: { - development: { - port: 1000, - config: { - WP_TESTS_DOMAIN: 'localhost:1000', - WP_SITEURL: 'http://localhost:1000', - WP_HOME: 'http://localhost:3000', - }, - }, - tests: { - port: 2000, - config: { - WP_TESTS_DOMAIN: 'localhost:2000', - WP_SITEURL: 'http://localhost:2000', - WP_HOME: 'http://localhost:3000', - }, - }, - }, - } ); - } ); - - it( 'should throw an error if the port number environment variable is invalid', async () => { - readFile.mockImplementation( () => - Promise.resolve( JSON.stringify( {} ) ) - ); - const oldPort = process.env.WP_ENV_PORT; - process.env.WP_ENV_PORT = 'hello'; - try { - await readConfig( '.wp-env.json' ); - } catch ( error ) { - expect( error ).toBeInstanceOf( ValidationError ); - expect( error.message ).toContain( - 'Invalid environment variable: WP_ENV_PORT must be a number.' - ); - } - process.env.WP_ENV_PORT = oldPort; - } ); - - it( 'should throw an error if the tests port number environment variable is invalid', async () => { - readFile.mockImplementation( () => - Promise.resolve( JSON.stringify( {} ) ) - ); - const oldPort = process.env.WP_ENV_TESTS_PORT; - process.env.WP_ENV_TESTS_PORT = 'hello'; - try { - await readConfig( '.wp-env.json' ); - } catch ( error ) { - expect( error ).toBeInstanceOf( ValidationError ); - expect( error.message ).toContain( - 'Invalid environment variable: WP_ENV_TESTS_PORT must be a number.' - ); - } - process.env.WP_ENV_TESTS_PORT = oldPort; - } ); - - it( 'should use port environment values rather than config values if both are defined', async () => { - readFile.mockImplementation( () => - Promise.resolve( - JSON.stringify( { - port: 1000, - testsPort: 2000, - } ) - ) - ); - const oldPort = process.env.WP_ENV_PORT; - const oldTestsPort = process.env.WP_ENV_TESTS_PORT; - process.env.WP_ENV_PORT = 4000; - process.env.WP_ENV_TESTS_PORT = 3000; - - const config = await readConfig( '.wp-env.json' ); - expect( config ).toMatchObject( { - env: { - development: { - port: 4000, - }, - tests: { - port: 3000, - }, - }, - } ); - - process.env.WP_ENV_PORT = oldPort; - process.env.WP_ENV_TESTS_PORT = oldTestsPort; - } ); - - it( 'should use 8888 and 8889 as the default port and testsPort values if nothing else is specified', async () => { - readFile.mockImplementation( () => - Promise.resolve( JSON.stringify( {} ) ) - ); - - const config = await readConfig( '.wp-env.json' ); - expect( config ).toMatchObject( { - env: { - development: { - port: 8888, - }, - tests: { - port: 8889, - }, - }, - } ); - } ); - } ); - - describe( 'wp config values', () => { - it( 'should use default config values', async () => { - const config = await readConfig( '.wp-env.json' ); - - expect( config.env.tests.config ).toMatchSnapshot(); - expect( config.env.development.config ).toMatchSnapshot(); - } ); - - it( 'should override default config values when some are specified', async () => { - const testValue = 'new value'; - readFile.mockImplementation( () => - Promise.resolve( - JSON.stringify( { - config: { - SCRIPT_DEBUG: testValue, - }, - } ) - ) - ); - - const config = await readConfig( '.wp-env.json' ); - expect( config.env.tests.config.SCRIPT_DEBUG ).toBe( testValue ); - expect( config.env.development.config.SCRIPT_DEBUG ).toBe( - testValue - ); - } ); - - it( 'should override config values using the .override file first', async () => { - readFile.mockImplementation( ( filename ) => { - let result; - if ( filename === '.wp-env.json' ) { - result = { - config: { - SCRIPT_DEBUG: '1', - }, - }; - } else if ( filename === '.wp-env.override.json' ) { - result = { - config: { - SCRIPT_DEBUG: '2', - }, - }; - } - return Promise.resolve( JSON.stringify( result ) ); - } ); - - const config = await readConfig( '.wp-env.json' ); - expect( config.env.tests.config.SCRIPT_DEBUG ).toBe( '2' ); - expect( config.env.development.config.SCRIPT_DEBUG ).toBe( '2' ); - } ); - - it( 'should override config values using the environment option from the .override file after the general option', async () => { - readFile.mockImplementation( ( filename ) => { - let result; - if ( filename === '.wp-env.json' ) { - result = { - config: { - SCRIPT_DEBUG: '1', - }, - env: { - tests: { - config: { - SCRIPT_DEBUG: '0', - }, - }, - development: { - config: { - SCRIPT_DEBUG: '0', - }, - }, - }, - }; - } else if ( filename === '.wp-env.override.json' ) { - result = { - config: { - SCRIPT_DEBUG: '2', - }, - env: { - tests: { - config: { - SCRIPT_DEBUG: '3', - }, - }, - development: { - config: { - SCRIPT_DEBUG: '4', - }, - }, - }, - }; - } - return Promise.resolve( JSON.stringify( result ) ); - } ); - - const config = await readConfig( '.wp-env.json' ); - expect( config.env.tests.config.SCRIPT_DEBUG ).toBe( '3' ); - expect( config.env.development.config.SCRIPT_DEBUG ).toBe( '4' ); - } ); - - it( 'should override config values using the environment option after the general option', async () => { - readFile.mockImplementation( () => - Promise.resolve( - JSON.stringify( { - config: { - SCRIPT_DEBUG: '1', - }, - env: { - tests: { - config: { - SCRIPT_DEBUG: '2', - }, - }, - development: { - config: { - SCRIPT_DEBUG: '4', - }, - }, - }, - } ) - ) - ); - - const config = await readConfig( '.wp-env.json' ); - expect( config.env.tests.config.SCRIPT_DEBUG ).toBe( '2' ); - expect( config.env.development.config.SCRIPT_DEBUG ).toBe( '4' ); - } ); - - it( 'should merge config values without overwriting an entire section', async () => { - readFile.mockImplementation( () => - Promise.resolve( - JSON.stringify( { - config: { - SCRIPT_DEBUG: '1', - TEST: '2', - }, - env: { - tests: { - config: { - SCRIPT_DEBUG: '2', - TEST3: 'foo', - }, - }, - development: { - config: { - SCRIPT_DEBUG: '4', - TEST5: 5, - }, - }, - }, - } ) - ) - ); - - const config = await readConfig( '.wp-env.json' ); - expect( config.env.tests.config ).toEqual( { - SCRIPT_DEBUG: '2', - TEST3: 'foo', - TEST: '2', - WP_DEBUG: false, - WP_ENVIRONMENT_TYPE: 'local', - WP_PHP_BINARY: 'php', - WP_TESTS_EMAIL: 'admin@example.org', - WP_TESTS_TITLE: 'Test Blog', - WP_TESTS_DOMAIN: 'localhost:8889', - WP_SITEURL: 'http://localhost:8889', - WP_HOME: 'http://localhost:8889', - } ); - - expect( config.env.development.config ).toEqual( { - SCRIPT_DEBUG: '4', - TEST5: 5, - TEST: '2', - WP_DEBUG: true, - WP_ENVIRONMENT_TYPE: 'local', - WP_PHP_BINARY: 'php', - WP_TESTS_EMAIL: 'admin@example.org', - WP_TESTS_TITLE: 'Test Blog', - WP_TESTS_DOMAIN: 'localhost:8888', - WP_SITEURL: 'http://localhost:8888', - WP_HOME: 'http://localhost:8888', - } ); - } ); - } ); -} ); - -/** - * Tests that readConfig will throw errors when invalid port numbers are passed. - * - * @param {string} portName The name of the port to test ('port' or 'testsPort') - * @param {any} value A value which should throw an error. - * @param {string} envText Env text which prefixes the error. - */ -async function testPortNumberValidation( portName, value, envText = '' ) { - readFile.mockImplementation( () => - Promise.resolve( JSON.stringify( { [ portName ]: value } ) ) - ); - try { - await readConfig( '.wp-env.json' ); - } catch ( error ) { - // Useful for debugging: - if ( ! ( error instanceof ValidationError ) ) { - throw error; - } - expect( error ).toBeInstanceOf( ValidationError ); - expect( error.message ).toContain( - `Invalid .wp-env.json: "${ envText }port" must be an integer.` - ); - } - jest.clearAllMocks(); -} -/* eslint-enable jest/no-conditional-expect */ diff --git a/packages/env/lib/config/test/get-cache-directory.js b/packages/env/lib/config/test/get-cache-directory.js new file mode 100644 index 0000000000000..c93589a4c3150 --- /dev/null +++ b/packages/env/lib/config/test/get-cache-directory.js @@ -0,0 +1,57 @@ +'use strict'; +/* eslint-disable jest/no-conditional-expect */ +/** + * External dependencies + */ +const { stat } = require( 'fs' ).promises; +const { homedir } = require( 'os' ); + +/** + * Internal dependencies + */ +const getCacheDirectory = require( '../get-cache-directory' ); + +jest.mock( 'fs', () => ( { + promises: { + stat: jest.fn(), + }, +} ) ); +jest.mock( 'os', () => ( { + homedir: jest.fn(), +} ) ); + +describe( 'getCacheDirectory', () => { + afterEach( () => { + delete process.env.WP_ENV_HOME; + } ); + + it( 'uses WP_ENV_HOME for cache directory when set', async () => { + process.env.WP_ENV_HOME = '/test'; + + const parsed = await getCacheDirectory(); + + expect( homedir ).not.toHaveBeenCalled(); + expect( parsed ).toEqual( '/test' ); + } ); + + it( 'uses hidden home directory for cache', async () => { + stat.mockRejectedValue( false ); + homedir.mockReturnValue( '/home/test' ); + + const parsed = await getCacheDirectory(); + + expect( homedir ).toHaveBeenCalled(); + expect( parsed ).toEqual( '/home/test/.wp-env' ); + } ); + + it( 'uses non-hidden cache directory when using Snap-installed Docker', async () => { + stat.mockResolvedValue( true ); + homedir.mockReturnValue( '/home/test' ); + + const parsed = await getCacheDirectory(); + + expect( homedir ).toHaveBeenCalled(); + expect( parsed ).toEqual( '/home/test/wp-env' ); + } ); +} ); +/* eslint-enable jest/no-conditional-expect */ diff --git a/packages/env/lib/config/test/merge-configs.js b/packages/env/lib/config/test/merge-configs.js new file mode 100644 index 0000000000000..d3769b9d8ff2b --- /dev/null +++ b/packages/env/lib/config/test/merge-configs.js @@ -0,0 +1,111 @@ +'use strict'; +/** + * Internal dependencies + */ +const mergeConfigs = require( '../merge-configs' ); + +describe( 'mergeConfigs', () => { + it( 'should merge configs without environments', () => { + const merged = mergeConfigs( + { + port: 8888, + coreSource: { + type: 'local', + path: '/home/test', + }, + config: { + WP_TEST: 'test', + }, + }, + { + port: 8889, + config: { + WP_TEST_2: 'test-2', + }, + } + ); + + expect( merged ).toEqual( { + port: 8889, + coreSource: { + type: 'local', + path: '/home/test', + }, + config: { + WP_TEST: 'test', + WP_TEST_2: 'test-2', + }, + } ); + } ); + + it( 'should merge configs with environments', () => { + const merged = mergeConfigs( + { + port: 8888, + coreSource: { + type: 'local', + path: '/home/test', + }, + config: { + WP_TEST: 'test', + }, + env: { + development: { + config: { + WP_TEST_3: 'test-3', + }, + }, + tests: { + config: { + WP_TEST_4: 'test-4', + }, + }, + }, + }, + { + port: 8889, + config: { + WP_TEST_2: 'test-2', + }, + env: { + development: { + config: { + WP_TEST_5: 'test-5', + }, + }, + tests: { + config: { + WP_TEST_6: 'test-6', + }, + }, + }, + } + ); + + expect( merged ).toEqual( { + port: 8889, + coreSource: { + type: 'local', + path: '/home/test', + }, + config: { + WP_TEST: 'test', + WP_TEST_2: 'test-2', + }, + env: { + development: { + config: { + WP_TEST_3: 'test-3', + WP_TEST_5: 'test-5', + }, + }, + tests: { + config: { + WP_TEST_4: 'test-4', + WP_TEST_6: 'test-6', + }, + }, + }, + } ); + } ); +} ); diff --git a/packages/env/lib/config/test/parse-config.js b/packages/env/lib/config/test/parse-config.js new file mode 100644 index 0000000000000..ce5e28809ecd2 --- /dev/null +++ b/packages/env/lib/config/test/parse-config.js @@ -0,0 +1,342 @@ +'use strict'; +/* eslint-disable jest/no-conditional-expect */ +/** + * External dependencies + */ +const path = require( 'path' ); + +/** + * Internal dependencies + */ +const { parseConfig } = require( '../parse-config' ); +const readRawConfigFile = require( '../read-raw-config-file' ); +const { getLatestWordPressVersion } = require( '../../wordpress' ); +const { ValidationError } = require( '../validate-config' ); +const detectDirectoryType = require( '../detect-directory-type' ); + +jest.mock( 'got', () => jest.fn() ); +jest.mock( '../read-raw-config-file', () => jest.fn() ); +jest.mock( '../detect-directory-type', () => jest.fn() ); +jest.mock( '../../wordpress', () => ( { + getLatestWordPressVersion: jest.fn(), +} ) ); + +/** + * Since our configurations are merged, we will want to refer to the parsed default config frequently. + */ +const DEFAULT_CONFIG = { + port: 8888, + testsPort: 8889, + phpVersion: null, + coreSource: { + type: 'git', + url: 'https://github.com/WordPress/WordPress.git', + ref: '100.0.0', + path: '/cache/WordPress', + clonePath: '/cache/WordPress', + basename: 'WordPress', + testsPath: '/cache/tests-WordPress', + }, + pluginSources: [], + themeSources: [], + config: { + WP_DEBUG: true, + SCRIPT_DEBUG: true, + WP_ENVIRONMENT_TYPE: 'local', + WP_PHP_BINARY: 'php', + WP_TESTS_EMAIL: 'admin@example.org', + WP_TESTS_TITLE: 'Test Blog', + WP_TESTS_DOMAIN: 'localhost', + WP_SITEURL: 'http://localhost', + WP_HOME: 'http://localhost', + }, + mappings: {}, + afterSetup: null, + env: { + development: {}, + tests: { + config: { + WP_DEBUG: false, + SCRIPT_DEBUG: false, + }, + }, + }, +}; + +describe( 'parseConfig', () => { + beforeEach( () => { + readRawConfigFile.mockResolvedValue( null ); + detectDirectoryType.mockResolvedValue( null ); + getLatestWordPressVersion.mockResolvedValue( '100.0.0' ); + } ); + + afterEach( () => { + jest.clearAllMocks(); + delete process.env.WP_ENV_PORT; + delete process.env.WP_ENV_TESTS_PORT; + delete process.env.WP_ENV_CORE; + delete process.env.WP_ENV_PHP_VERSION; + delete process.env.WP_ENV_AFTER_SETUP; + } ); + + it( 'should return default config', async () => { + const parsed = await parseConfig( './', '/cache' ); + + expect( parsed ).toEqual( DEFAULT_CONFIG ); + } ); + + it( 'should infer a core mounting default when ran from a WordPress directory', async () => { + detectDirectoryType.mockResolvedValue( 'core' ); + + const parsed = await parseConfig( './', '/cache' ); + + expect( parsed ).toEqual( { + ...DEFAULT_CONFIG, + coreSource: { + basename: 'gutenberg', + path: path.resolve( '.' ), + testsPath: '/cache/tests-gutenberg', + type: 'local', + }, + } ); + } ); + + it( 'should infer a plugin mounting default when ran from a plugin directory', async () => { + detectDirectoryType.mockResolvedValue( 'plugin' ); + + const parsed = await parseConfig( './', '/cache' ); + + expect( parsed ).toEqual( { + ...DEFAULT_CONFIG, + pluginSources: [ + { + basename: 'gutenberg', + path: path.resolve( '.' ), + type: 'local', + }, + ], + } ); + } ); + + it( 'should infer a theme mounting default when ran from a theme directory', async () => { + detectDirectoryType.mockResolvedValue( 'theme' ); + + const parsed = await parseConfig( './', '/cache' ); + + expect( parsed ).toEqual( { + ...DEFAULT_CONFIG, + themeSources: [ + { + basename: 'gutenberg', + path: path.resolve( '.' ), + type: 'local', + }, + ], + } ); + } ); + + it( 'should merge configs with precedence', async () => { + readRawConfigFile.mockImplementation( async ( configFile ) => { + if ( configFile === path.resolve( './.wp-env.json' ) ) { + return { + core: 'WordPress/WordPress#Test', + phpVersion: '1.0', + afterSetup: 'test', + env: { + development: { + port: 1234, + }, + tests: { + port: 5678, + }, + }, + }; + } + + if ( configFile === path.resolve( './.wp-env.override.json' ) ) { + return { + phpVersion: '2.0', + env: { + tests: { + port: 1011, + }, + }, + }; + } + + throw new Error( 'Invalid File: ' + configFile ); + } ); + + const parsed = await parseConfig( './', '/cache' ); + + const expected = { + ...DEFAULT_CONFIG, + coreSource: { + basename: 'WordPress', + path: '/cache/WordPress', + clonePath: '/cache/WordPress', + ref: 'Test', + testsPath: '/cache/tests-WordPress', + url: 'https://github.com/WordPress/WordPress.git', + type: 'git', + }, + phpVersion: '2.0', + afterSetup: 'test', + env: { + development: { + ...DEFAULT_CONFIG.env.development, + port: 1234, + }, + tests: { + ...DEFAULT_CONFIG.env.tests, + port: 1011, + }, + }, + }; + expect( parsed ).toEqual( expected ); + } ); + + it( 'should parse core, plugin, theme, and mapping sources', async () => { + readRawConfigFile.mockImplementation( async ( configFile ) => { + if ( configFile === path.resolve( '.', './.wp-env.json' ) ) { + return { + core: 'WordPress/WordPress#Test', + plugins: [ 'WordPress/TestPlugin#Test' ], + themes: [ 'WordPress/TestTheme#Test' ], + mappings: { + '/var/www/html/wp-content/plugins/test-mapping': + 'WordPress/TestMapping#Test', + }, + }; + } + + if ( + configFile === path.resolve( '.', './.wp-env.override.json' ) + ) { + return {}; + } + + throw new Error( 'Invalid File: ' + configFile ); + } ); + + const parsed = await parseConfig( './', '/cache' ); + + expect( parsed ).toEqual( { + ...DEFAULT_CONFIG, + coreSource: { + basename: 'WordPress', + path: '/cache/WordPress', + clonePath: '/cache/WordPress', + ref: 'Test', + testsPath: '/cache/tests-WordPress', + url: 'https://github.com/WordPress/WordPress.git', + type: 'git', + }, + pluginSources: [ + { + basename: 'TestPlugin', + path: '/cache/TestPlugin', + clonePath: '/cache/TestPlugin', + ref: 'Test', + url: 'https://github.com/WordPress/TestPlugin.git', + type: 'git', + }, + ], + themeSources: [ + { + basename: 'TestTheme', + path: '/cache/TestTheme', + clonePath: '/cache/TestTheme', + ref: 'Test', + url: 'https://github.com/WordPress/TestTheme.git', + type: 'git', + }, + ], + mappings: { + '/var/www/html/wp-content/plugins/test-mapping': { + basename: 'TestMapping', + path: '/cache/TestMapping', + clonePath: '/cache/TestMapping', + ref: 'Test', + url: 'https://github.com/WordPress/TestMapping.git', + type: 'git', + }, + }, + } ); + } ); + + it( 'should override with environment variables', async () => { + process.env.WP_ENV_PORT = 123; + process.env.WP_ENV_TESTS_PORT = 456; + process.env.WP_ENV_CORE = 'WordPress/WordPress#test'; + process.env.WP_ENV_PHP_VERSION = '3.0'; + process.env.WP_ENV_AFTER_SETUP = 'test after'; + + const parsed = await parseConfig( './', '/cache' ); + + expect( parsed ).toEqual( { + ...DEFAULT_CONFIG, + port: 123, + testsPort: 456, + coreSource: { + basename: 'WordPress', + path: '/cache/WordPress', + clonePath: '/cache/WordPress', + ref: 'test', + testsPath: '/cache/tests-WordPress', + url: 'https://github.com/WordPress/WordPress.git', + type: 'git', + }, + phpVersion: '3.0', + afterSetup: 'test after', + env: { + development: { + port: 123, + phpVersion: '3.0', + coreSource: { + basename: 'WordPress', + path: '/cache/WordPress', + clonePath: '/cache/WordPress', + ref: 'test', + testsPath: '/cache/tests-WordPress', + url: 'https://github.com/WordPress/WordPress.git', + type: 'git', + }, + }, + tests: { + port: 456, + phpVersion: '3.0', + coreSource: { + basename: 'WordPress', + path: '/cache/WordPress', + clonePath: '/cache/WordPress', + ref: 'test', + testsPath: '/cache/tests-WordPress', + url: 'https://github.com/WordPress/WordPress.git', + type: 'git', + }, + config: { + WP_DEBUG: false, + SCRIPT_DEBUG: false, + }, + }, + }, + } ); + } ); + + it( 'throws when latest WordPress version needed but not found', async () => { + getLatestWordPressVersion.mockResolvedValue( null ); + + expect.assertions( 1 ); + try { + await parseConfig( './', '/cache' ); + } catch ( error ) { + expect( error ).toEqual( + new ValidationError( + 'Could not find the latest WordPress version. There may be a network issue.' + ) + ); + } + } ); +} ); +/* eslint-enable jest/no-conditional-expect */ diff --git a/packages/env/lib/config/test/parse-source-string.js b/packages/env/lib/config/test/parse-source-string.js new file mode 100644 index 0000000000000..a0ccc6eb6d3f7 --- /dev/null +++ b/packages/env/lib/config/test/parse-source-string.js @@ -0,0 +1,154 @@ +'use strict'; +/* eslint-disable jest/no-conditional-expect */ +/** + * External dependencies + */ +const path = require( 'path' ); +const { homedir } = require( 'os' ); + +/** + * Internal dependencies + */ +const { ValidationError } = require( '../validate-config' ); +const { parseSourceString } = require( '../parse-source-string' ); + +jest.mock( 'os', () => ( { + homedir: jest.fn(), +} ) ); + +describe( 'parseSourceString', () => { + const options = { + cacheDirectoryPath: '/test/cache', + }; + + beforeEach( () => { + homedir.mockReturnValue( '/home/test' ); + } ); + + it( 'should do nothing when given an empty source', () => { + expect( parseSourceString( null, options ) ).toEqual( null ); + } ); + + it( 'should throw when source not parseable', () => { + expect.assertions( 1 ); + try { + parseSourceString( 'test://test', options ); + } catch ( error ) { + expect( error ).toEqual( + new ValidationError( + 'Invalid or unrecognized source: "test://test".' + ) + ); + } + } ); + + describe( 'local sources', () => { + it( 'should parse relative directories', () => { + expect( parseSourceString( '.', options ) ).toEqual( { + basename: 'gutenberg', + path: path.resolve( '.' ), + type: 'local', + } ); + } ); + + it( 'should parse home directories', () => { + expect( parseSourceString( '~/test', options ) ).toEqual( { + basename: 'test', + path: '/home/test/test', + type: 'local', + } ); + } ); + + it( 'should parse absolute directories', () => { + expect( parseSourceString( '/absolute/test', options ) ).toEqual( { + basename: 'test', + path: '/absolute/test', + type: 'local', + } ); + } ); + } ); + + describe( 'zip sources', () => { + it( 'should parse WordPress.org sources', () => { + expect( + parseSourceString( + 'http://downloads.wordpress.org/plugin/gutenberg.zip', + options + ) + ).toEqual( { + basename: 'gutenberg', + path: '/test/cache/gutenberg', + type: 'zip', + url: 'http://downloads.wordpress.org/plugin/gutenberg.zip', + } ); + } ); + + it( 'should parse other sources', () => { + expect( + parseSourceString( 'http://test.com/testing.zip', options ) + ).toEqual( { + basename: 'testing', + path: '/test/cache/testing', + type: 'zip', + url: 'http://test.com/testing.zip', + } ); + } ); + } ); + + describe( 'Git SSH sources', () => { + it( 'should parse ssh protocol', () => { + expect( + parseSourceString( 'ssh://test/test.git#trunk', options ) + ).toEqual( { + basename: 'test', + path: '/test/cache/test', + clonePath: '/test/cache/test', + ref: 'trunk', + type: 'git', + url: 'ssh://test/test.git', + } ); + } ); + + it( 'should parse git+ssh protocol', () => { + expect( + parseSourceString( 'git+ssh://test/test.git#trunk', options ) + ).toEqual( { + basename: 'test', + path: '/test/cache/test', + clonePath: '/test/cache/test', + ref: 'trunk', + type: 'git', + url: 'git+ssh://test/test.git', + } ); + } ); + + it( 'should work without ref', () => { + expect( + parseSourceString( 'ssh://test/test.git', options ) + ).toEqual( { + basename: 'test', + path: '/test/cache/test', + clonePath: '/test/cache/test', + ref: undefined, + type: 'git', + url: 'ssh://test/test.git', + } ); + } ); + } ); + + describe( 'GitHub sources', () => { + it( 'should parse', () => { + expect( + parseSourceString( 'WordPress/gutenberg#trunk', options ) + ).toEqual( { + basename: 'gutenberg', + path: '/test/cache/gutenberg', + clonePath: '/test/cache/gutenberg', + ref: 'trunk', + type: 'git', + url: 'https://github.com/WordPress/gutenberg.git', + } ); + } ); + } ); +} ); +/* eslint-enable jest/no-conditional-expect */ diff --git a/packages/env/lib/config/test/post-process-config.js b/packages/env/lib/config/test/post-process-config.js new file mode 100644 index 0000000000000..8559728b969d1 --- /dev/null +++ b/packages/env/lib/config/test/post-process-config.js @@ -0,0 +1,295 @@ +'use strict'; +/** + * Internal dependencies + */ +const { ValidationError } = require( '..' ); +const postProcessConfig = require( '../post-process-config' ); + +describe( 'postProcessConfig', () => { + afterEach( () => { + jest.clearAllMocks(); + } ); + + it( 'should merge relevant root options into environment options', () => { + const processed = postProcessConfig( { + port: 123, + testsPort: 456, + coreSource: { + type: 'test', + }, + config: { + TESTS_ROOT: 'root', + }, + pluginSources: [ + { + type: 'root-plugin', + }, + ], + themeSources: [ + { + type: 'root-theme', + }, + ], + mappings: { + 'root-mapping': { + type: 'root-mapping', + }, + }, + env: { + development: { + coreSource: { + type: 'test', + }, + config: { + TEST_ENV: 'development', + }, + pluginSources: [ + { + type: 'development-plugin', + }, + ], + themeSources: [ + { + type: 'development-theme', + }, + ], + mappings: { + 'development-mapping': { + type: 'development-mapping', + }, + }, + }, + tests: { + coreSource: { + type: 'test', + }, + config: { + TEST_ENV: 'tests', + }, + }, + }, + } ); + + expect( processed ).toEqual( { + port: 123, + testsPort: 456, + coreSource: { + type: 'test', + }, + config: { + TESTS_ROOT: 'root', + }, + pluginSources: [ + { + type: 'root-plugin', + }, + ], + themeSources: [ + { + type: 'root-theme', + }, + ], + mappings: { + 'root-mapping': { + type: 'root-mapping', + }, + }, + env: { + development: { + port: 123, + coreSource: { + type: 'test', + }, + config: { + TESTS_ROOT: 'root', + TEST_ENV: 'development', + }, + pluginSources: [ + { + type: 'development-plugin', + }, + ], + themeSources: [ + { + type: 'development-theme', + }, + ], + mappings: { + 'root-mapping': { + type: 'root-mapping', + }, + 'development-mapping': { + type: 'development-mapping', + }, + }, + }, + tests: { + port: 456, + coreSource: { + type: 'test', + }, + config: { + TESTS_ROOT: 'root', + TEST_ENV: 'tests', + }, + pluginSources: [ + { + type: 'root-plugin', + }, + ], + themeSources: [ + { + type: 'root-theme', + }, + ], + mappings: { + 'root-mapping': { + type: 'root-mapping', + }, + }, + }, + }, + } ); + } ); + + it( 'should not merge some root options into environment options', () => { + const processed = postProcessConfig( { + port: 8888, + testsPort: 8889, + afterSetup: 'test', + env: { + development: {}, + tests: {}, + }, + } ); + + expect( processed ).toEqual( { + port: 8888, + testsPort: 8889, + afterSetup: 'test', + env: { + development: { + port: 8888, + }, + tests: { + port: 8889, + }, + }, + } ); + } ); + + describe( 'appendPortToWPConfigs', () => { + it( 'should add port to certain environment config options', () => { + const processed = postProcessConfig( { + port: 123, + config: { + WP_TESTS_DOMAIN: 'localhost', + WP_SITEURL: 'localhost', + WP_HOME: 'localhost', + }, + env: { + development: { + port: 123, + }, + tests: { + port: 456, + }, + }, + } ); + + expect( processed ).toEqual( { + // Since the root-level config shouldn't apply to an environment, + // we shouldn't add the port to the config options for it. + port: 123, + config: { + WP_TESTS_DOMAIN: 'localhost', + WP_SITEURL: 'localhost', + WP_HOME: 'localhost', + }, + env: { + development: { + port: 123, + config: { + WP_TESTS_DOMAIN: 'localhost:123', + WP_SITEURL: 'localhost:123', + WP_HOME: 'localhost:123', + }, + }, + tests: { + port: 456, + config: { + WP_TESTS_DOMAIN: 'localhost:456', + WP_SITEURL: 'localhost:456', + WP_HOME: 'localhost:456', + }, + }, + }, + } ); + } ); + + it( 'should not overwrite port in WP_HOME', () => { + const processed = postProcessConfig( { + env: { + development: { + port: 123, + config: { + WP_TESTS_DOMAIN: 'localhost:777', + WP_SITEURL: 'localhost:777', + WP_HOME: 'localhost:777', + }, + }, + tests: { + port: 456, + config: { + WP_TESTS_DOMAIN: 'localhost:777', + WP_SITEURL: 'localhost:777', + WP_HOME: 'localhost:777', + }, + }, + }, + } ); + + expect( processed ).toEqual( { + env: { + development: { + port: 123, + config: { + WP_TESTS_DOMAIN: 'localhost:123', + WP_SITEURL: 'localhost:123', + WP_HOME: 'localhost:777', + }, + }, + tests: { + port: 456, + config: { + WP_TESTS_DOMAIN: 'localhost:456', + WP_SITEURL: 'localhost:456', + WP_HOME: 'localhost:777', + }, + }, + }, + } ); + } ); + } ); + + describe( 'validatePortUniqueness', () => { + it( 'should fail when two environments have the same port', () => { + expect( () => { + postProcessConfig( { + env: { + development: { + port: 123, + }, + tests: { + port: 123, + }, + }, + } ); + } ).toThrow( + new ValidationError( + 'The "development" and "tests" environments may not have the same port.' + ) + ); + } ); + } ); +} ); diff --git a/packages/env/lib/config/test/read-raw-config-file.js b/packages/env/lib/config/test/read-raw-config-file.js index bb1b282208ee4..135bc4cd4d9c4 100644 --- a/packages/env/lib/config/test/read-raw-config-file.js +++ b/packages/env/lib/config/test/read-raw-config-file.js @@ -1,3 +1,5 @@ +'use strict'; +/* eslint-disable jest/no-conditional-expect */ /** * External dependencies */ @@ -6,7 +8,8 @@ const { readFile } = require( 'fs' ).promises; /** * Internal dependencies */ -const readConfigFile = require( '../read-raw-config-file' ); +const readRawConfigFile = require( '../read-raw-config-file' ); +const { ValidationError } = require( '../validate-config' ); jest.mock( 'fs', () => ( { promises: { @@ -15,76 +18,29 @@ jest.mock( 'fs', () => ( { } ) ); describe( 'readRawConfigFile', () => { - beforeEach( () => { + afterEach( () => { jest.clearAllMocks(); } ); it( 'returns null if it cannot find a file', async () => { - readFile.mockImplementation( () => - Promise.reject( { code: 'ENOENT' } ) - ); - const result = await readConfigFile( 'wp-env', '/.wp-env.json' ); + readFile.mockRejectedValue( { code: 'ENOENT' } ); + + const result = await readRawConfigFile( '/.wp-env.json' ); expect( result ).toBe( null ); } ); - it( 'converts testPort into tests.port', async () => { - readFile.mockImplementation( () => - Promise.resolve( JSON.stringify( { testsPort: 100 } ) ) - ); - const result = await readConfigFile( 'wp-env', '/.wp-env.json' ); - expect( result ).toEqual( { - env: { - tests: { - port: 100, - }, - }, - } ); - } ); + it( 'rejects when read file fails', async () => { + readFile.mockRejectedValue( { message: 'Test' } ); - it( 'does not overwrite other test config values', async () => { - readFile.mockImplementation( () => - Promise.resolve( - JSON.stringify( { - testsPort: 100, - env: { - tests: { - something: 'test', - }, - }, - } ) - ) - ); - const result = await readConfigFile( 'wp-env', '/.wp-env.json' ); - expect( result ).toEqual( { - env: { - tests: { - port: 100, - something: 'test', - }, - }, - } ); - } ); + expect.assertions( 1 ); - it( 'uses tests.port if both tests.port and testsPort exist', async () => { - readFile.mockImplementation( () => - Promise.resolve( - JSON.stringify( { - testsPort: 100, - env: { - tests: { - port: 200, - }, - }, - } ) - ) - ); - const result = await readConfigFile( 'wp-env', '/.wp-env.json' ); - expect( result ).toEqual( { - env: { - tests: { - port: 200, - }, - }, - } ); + try { + await readRawConfigFile( '/.wp-env.json' ); + } catch ( error ) { + expect( error ).toEqual( + new ValidationError( 'Could not read .wp-env.json: Test' ) + ); + } } ); } ); +/* eslint-enable jest/no-conditional-expect */ diff --git a/packages/env/lib/config/test/validate-config.js b/packages/env/lib/config/test/validate-config.js new file mode 100644 index 0000000000000..adcae2b90e241 --- /dev/null +++ b/packages/env/lib/config/test/validate-config.js @@ -0,0 +1,305 @@ +'use strict'; +/** + * Internal dependencies + */ +const { + ValidationError, + checkString, + checkPort, + checkStringArray, + checkObjectWithValues, + checkVersion, + checkValidURL, +} = require( '../validate-config' ); + +describe( 'validate-config', () => { + describe( 'checkString', () => { + it( 'throws when not a string', () => { + expect( () => checkString( 'test.json', 'test', 1234 ) ).toThrow( + new ValidationError( + 'Invalid test.json: "test" must be a string.' + ) + ); + } ); + + it( 'passes for string', () => { + expect( () => + checkString( 'test.json', 'test', 'test' ) + ).not.toThrow(); + } ); + } ); + + describe( 'checkPort', () => { + it( 'throws when not a number', () => { + expect( () => checkPort( 'test.json', 'test', 'test' ) ).toThrow( + new ValidationError( + 'Invalid test.json: "test" must be an integer.' + ) + ); + + expect( () => + checkPort( 'test.json', 'test', { test: 'test' } ) + ).toThrow( + new ValidationError( + 'Invalid test.json: "test" must be an integer.' + ) + ); + } ); + + it( 'throws when port out of range', () => { + expect( () => checkPort( 'test.json', 'test', -1 ) ).toThrow( + new ValidationError( + 'Invalid test.json: "test" must be a valid port.' + ) + ); + + expect( () => checkPort( 'test.json', 'test', 99999999 ) ).toThrow( + new ValidationError( + 'Invalid test.json: "test" must be a valid port.' + ) + ); + } ); + + it( 'passes for valid port', () => { + expect( () => + checkPort( 'test.json', 'test', 8888 ) + ).not.toThrow(); + } ); + } ); + + describe( 'checkStringArray', () => { + it( 'throws when not an array', () => { + expect( () => + checkStringArray( 'test.json', 'test', 'test' ) + ).toThrow( + new ValidationError( + 'Invalid test.json: "test" must be an array.' + ) + ); + + expect( () => + checkStringArray( 'test.json', 'test', { test: 'test' } ) + ).toThrow( + new ValidationError( + 'Invalid test.json: "test" must be an array.' + ) + ); + } ); + + it( 'throws when array contains non-strings', () => { + expect( () => + checkStringArray( 'test.json', 'test', [ 12 ] ) + ).toThrow( + new ValidationError( + 'Invalid test.json: "test" must be an array of strings.' + ) + ); + + expect( () => + checkStringArray( 'test.json', 'test', [ 'test', 12 ] ) + ).toThrow( + new ValidationError( + 'Invalid test.json: "test" must be an array of strings.' + ) + ); + } ); + + it( 'passes for string arrays', () => { + expect( () => + checkStringArray( 'test.json', 'test', [] ) + ).not.toThrow(); + expect( () => + checkStringArray( 'test.json', 'test', [ 'test' ] ) + ).not.toThrow(); + } ); + } ); + + describe( 'checkObjectWithValues', () => { + it( 'throws when not an object', () => { + expect( () => + checkObjectWithValues( 'test.json', 'test', 'test', [] ) + ).toThrow( + new ValidationError( + 'Invalid test.json: "test" must be an object.' + ) + ); + + expect( () => + checkObjectWithValues( 'test.json', 'test', [ 'test' ], [] ) + ).toThrow( + new ValidationError( + 'Invalid test.json: "test" must be an object.' + ) + ); + } ); + + it( 'throws when no allowed types are given', () => { + expect( () => + checkObjectWithValues( + 'test.json', + 'test', + { test: 'test' }, + [] + ) + ).toThrow( + new ValidationError( + 'Invalid test.json: "test.test" must be a .' + ) + ); + } ); + + it( 'throws when type is not allowed', () => { + expect( () => + checkObjectWithValues( 'test.json', 'test', { test: 'test' }, [ + 'number', + ] ) + ).toThrow( + new ValidationError( + 'Invalid test.json: "test.test" must be a number.' + ) + ); + + expect( () => + checkObjectWithValues( 'test.json', 'test', { test: 1 }, [ + 'string', + ] ) + ).toThrow( + new ValidationError( + 'Invalid test.json: "test.test" must be a string.' + ) + ); + + expect( () => + checkObjectWithValues( + 'test.json', + 'test', + { test: [ 'test' ] }, + [ 'object', 'string' ] + ) + ).toThrow( + new ValidationError( + 'Invalid test.json: "test.test" must be a object or string.' + ) + ); + } ); + + it( 'passes when type is allowed', () => { + expect( () => + checkObjectWithValues( 'test.json', 'test', { test: 'test' }, [ + 'string', + ] ) + ).not.toThrow(); + expect( () => + checkObjectWithValues( 'test.json', 'test', { test: 1 }, [ + 'number', + ] ) + ).not.toThrow(); + expect( () => + checkObjectWithValues( + 'test.json', + 'test', + { test: { nested: 'test' } }, + [ 'object' ] + ) + ).not.toThrow(); + expect( () => + checkObjectWithValues( + 'test.json', + 'test', + { test: [ 'test' ] }, + [ 'array' ] + ) + ).not.toThrow(); + } ); + } ); + + describe( 'checkVersion', () => { + it( 'throws for invalid input', () => { + expect( () => checkVersion( 'test.json', 'test', 'test' ) ).toThrow( + new ValidationError( + 'Invalid test.json: "test" must be a string of the format "X", "X.X", or "X.X.X".' + ) + ); + + expect( () => checkVersion( 'test.json', 'test', 123 ) ).toThrow( + new ValidationError( + 'Invalid test.json: "test" must be a string.' + ) + ); + } ); + + it( 'passes for different version formats', () => { + expect( () => + checkVersion( 'test.json', 'test', '1' ) + ).not.toThrow(); + expect( () => + checkVersion( 'test.json', 'test', '1.1' ) + ).not.toThrow(); + expect( () => + checkVersion( 'test.json', 'test', '1.1.1' ) + ).not.toThrow(); + expect( () => + checkVersion( 'test.json', 'test', '15.7.2' ) + ).not.toThrow(); + expect( () => + checkVersion( 'test.json', 'test', '26634543' ) + ).not.toThrow(); + } ); + } ); + + describe( 'checkValidURL', () => { + it( 'throws for invaid URLs', () => { + expect( () => + checkValidURL( 'test.json', 'test', 'localhost' ) + ).toThrow( + new ValidationError( + 'Invalid test.json: "test" must be a valid URL.' + ) + ); + + expect( () => checkValidURL( 'test.json', 'test', '' ) ).toThrow( + new ValidationError( + 'Invalid test.json: "test" must be a valid URL.' + ) + ); + + expect( () => checkValidURL( 'test.json', 'test', 123 ) ).toThrow( + new ValidationError( + 'Invalid test.json: "test" must be a valid URL.' + ) + ); + } ); + + it( 'passes for valid URLs', () => { + expect( () => + checkValidURL( 'test.json', 'test', 'http://test.com' ) + ).not.toThrow(); + expect( () => + checkValidURL( 'test.json', 'test', 'https://test.com' ) + ).not.toThrow(); + expect( () => + checkValidURL( 'test.json', 'test', 'http://test' ) + ).not.toThrow(); + expect( () => + checkValidURL( + 'test.json', + 'test', + 'http://test/test?test=test' + ) + ).not.toThrow(); + expect( () => + checkValidURL( 'test.json', 'test', 'http://test.co.uk' ) + ).not.toThrow(); + expect( () => + checkValidURL( 'test.json', 'test', 'https://test.co.uk:8888' ) + ).not.toThrow(); + expect( () => + checkValidURL( + 'test.json', + 'test', + 'http://test.co.uk:8888/test?test=test#test' + ) + ).not.toThrow(); + } ); + } ); +} ); diff --git a/packages/env/lib/config/validate-config.js b/packages/env/lib/config/validate-config.js index 74a5fb79b5deb..f5c5d61f297c6 100644 --- a/packages/env/lib/config/validate-config.js +++ b/packages/env/lib/config/validate-config.js @@ -1,8 +1,7 @@ 'use strict'; /** - * @typedef {import('./config').WPServiceConfig} WPServiceConfig - * @typedef {import('./config').WPSource} WPSource + * @typedef {import('./parse-source-string').WPSource} WPSource */ /** @@ -12,105 +11,145 @@ class ValidationError extends Error {} /** - * Validates a config object by throwing a ValidationError if any of its properties - * do not match the required format. + * Validates that the value is a string. * - * @param {Object} config A config object to validate. - * @param {?string} envLocation Identifies if the error occurred in a specific environment property. - * @return {Object} The passed config object with no modifications. + * @param {string} configFile The configuration file we're validating. + * @param {string} configKey The configuration key we're validating. + * @param {number} value The value to check. */ -function validateConfig( config, envLocation ) { - const envPrefix = envLocation ? `env.${ envLocation }.` : ''; - if ( config.core !== null && typeof config.core !== 'string' ) { +function checkString( configFile, configKey, value ) { + if ( typeof value !== 'string' ) { throw new ValidationError( - `Invalid .wp-env.json: "${ envPrefix }core" must be null or a string.` + `Invalid ${ configFile }: "${ configKey }" must be a string.` ); } +} - if ( - ! Array.isArray( config.plugins ) || - config.plugins.some( ( plugin ) => typeof plugin !== 'string' ) - ) { +/** + * Validates the port and throws if it isn't valid. + * + * @param {string} configFile The configuration file we're validating. + * @param {string} configKey The configuration key we're validating. + * @param {number} port The port to check. + */ +function checkPort( configFile, configKey, port ) { + if ( ! Number.isInteger( port ) ) { throw new ValidationError( - `Invalid .wp-env.json: "${ envPrefix }plugins" must be an array of strings.` + `Invalid ${ configFile }: "${ configKey }" must be an integer.` ); } - if ( - ! Array.isArray( config.themes ) || - config.themes.some( ( theme ) => typeof theme !== 'string' ) - ) { + if ( port < 0 || port > 65535 ) { throw new ValidationError( - `Invalid .wp-env.json: "${ envPrefix }themes" must be an array of strings.` + `Invalid ${ configFile }: "${ configKey }" must be a valid port.` ); } +} - if ( ! Number.isInteger( config.port ) ) { +/** + * Validates the array and throws if it isn't valid. + * + * @param {string} configFile The config file we're validating. + * @param {string} configKey The configuration key we're validating. + * @param {string[]} array The array that we're checking. + */ +function checkStringArray( configFile, configKey, array ) { + if ( ! Array.isArray( array ) ) { throw new ValidationError( - `Invalid .wp-env.json: "${ envPrefix }port" must be an integer.` + `Invalid ${ configFile }: "${ configKey }" must be an array.` ); } - if ( typeof config.config !== 'object' ) { + if ( array.some( ( value ) => typeof value !== 'string' ) ) { throw new ValidationError( - `Invalid .wp-env.json: "${ envPrefix }config" must be an object.` + `Invalid ${ configFile }: "${ configKey }" must be an array of strings.` ); } +} - if ( typeof config.mappings !== 'object' ) { +/** + * Validates the object and throws if it isn't valid. + * + * @param {string} configFile The config file we're validating. + * @param {string} configKey The configuration key we're validating. + * @param {string[]} obj The object that we're checking. + * @param {string[]} allowTypes The types that are allowed. + */ +function checkObjectWithValues( configFile, configKey, obj, allowTypes ) { + if ( allowTypes === undefined ) { + allowTypes = []; + } + + if ( typeof obj !== 'object' || Array.isArray( obj ) ) { throw new ValidationError( - `Invalid .wp-env.json: "${ envPrefix }mappings" must be an object.` + `Invalid ${ configFile }: "${ configKey }" must be an object.` ); } - for ( const [ wpDir, localDir ] of Object.entries( config.mappings ) ) { - if ( ! localDir || typeof localDir !== 'string' ) { + for ( const key in obj ) { + if ( ! obj[ key ] && ! allowTypes.includes( 'empty' ) ) { throw new ValidationError( - `Invalid .wp-env.json: "${ envPrefix }mappings.${ wpDir }" should be a string.` + `Invalid ${ configFile }: "${ configKey }.${ key }" must not be empty.` + ); + } + + // Identify arrays uniquely. + const type = Array.isArray( obj[ key ] ) ? 'array' : typeof obj[ key ]; + + if ( ! allowTypes.includes( type ) ) { + throw new ValidationError( + `Invalid ${ configFile }: "${ configKey }.${ key }" must be a ${ allowTypes.join( + ' or ' + ) }.` ); } } +} - if ( - config.phpVersion && - ! ( - typeof config.phpVersion === 'string' && - config.phpVersion.length === 3 - ) - ) { +/** + * Validates the version and throws if it isn't valid. + * + * @param {string} configFile The config file we're validating. + * @param {string} configKey The configuration key we're validating. + * @param {string} version The version that we're checking. + */ +function checkVersion( configFile, configKey, version ) { + if ( typeof version !== 'string' ) { throw new ValidationError( - `Invalid .wp-env.json: "${ envPrefix }phpVersion" must be a string of the format "0.0".` + `Invalid ${ configFile }: "${ configKey }" must be a string.` ); } - checkValidURL( envPrefix, config.config, 'WP_SITEURL' ); - checkValidURL( envPrefix, config.config, 'WP_HOME' ); - - return config; + if ( ! version.match( /[0-9]+(?:\.[0-9]+)*/ ) ) { + throw new ValidationError( + `Invalid ${ configFile }: "${ configKey }" must be a string of the format "X", "X.X", or "X.X.X".` + ); + } } /** - * Validates the input and throws if it isn't a valid URL. + * Validates the url and throws if it isn't valid. * - * @param {string} envPrefix The environment we're validating. - * @param {Object} config The configuration object we're looking at. - * @param {string} configKey The configuration key we're validating. + * @param {string} configFile The config file we're validating. + * @param {string} configKey The configuration key we're validating. + * @param {string} url The URL that we're checking. */ -function checkValidURL( envPrefix, config, configKey ) { - if ( config[ configKey ] === undefined ) { - return; - } - +function checkValidURL( configFile, configKey, url ) { try { - new URL( config[ configKey ] ); + new URL( url ); } catch { throw new ValidationError( - `Invalid .wp-env.json: "${ envPrefix }config.${ configKey }" must be a valid URL.` + `Invalid ${ configFile }: "${ configKey }" must be a valid URL.` ); } } module.exports = { - validateConfig, ValidationError, + checkString, + checkPort, + checkStringArray, + checkObjectWithValues, + checkVersion, + checkValidURL, }; diff --git a/packages/env/lib/env.js b/packages/env/lib/env.js index 3942a30710937..be093afe5a106 100644 --- a/packages/env/lib/env.js +++ b/packages/env/lib/env.js @@ -3,9 +3,11 @@ * Internal dependencies */ const { ValidationError } = require( './config' ); +const { AfterSetupError } = require( './execute-after-setup' ); const commands = require( './commands' ); module.exports = { ...commands, ValidationError, + AfterSetupError, }; diff --git a/packages/env/lib/execute-after-setup.js b/packages/env/lib/execute-after-setup.js new file mode 100644 index 0000000000000..09b642d2fef56 --- /dev/null +++ b/packages/env/lib/execute-after-setup.js @@ -0,0 +1,51 @@ +'use strict'; +/** + * External dependencies + */ +const { execSync } = require( 'child_process' ); + +/** + * @typedef {import('./config').WPConfig} WPConfig + */ + +/** + * Error subtype which indicates that the afterSetup command failed. + */ +class AfterSetupError extends Error {} + +/** + * Executes any defined afterSetup command. + * + * @param {WPConfig} config The config object to use. + * @param {Object} spinner A CLI spinner which indciates progress. + */ +function executeAfterSetup( config, spinner ) { + if ( ! config.afterSetup ) { + return; + } + + spinner.text = 'Executing Script: afterSetup'; + + try { + let output = execSync( config.afterSetup, { + encoding: 'utf-8', + stdio: 'pipe', + env: process.env, + } ); + + // Remove any trailing whitespace for nicer output. + output = output.trimRight(); + + // We don't need to bother with any output if there isn't any. + if ( output ) { + spinner.info( `After Setup:\n${ output }` ); + } + } catch ( error ) { + throw new AfterSetupError( `After Setup:\n${ error.stderr }` ); + } +} + +module.exports = { + AfterSetupError, + executeAfterSetup, +}; diff --git a/packages/env/lib/get-host-user.js b/packages/env/lib/get-host-user.js new file mode 100644 index 0000000000000..7c7f095de1715 --- /dev/null +++ b/packages/env/lib/get-host-user.js @@ -0,0 +1,31 @@ +'use strict'; +/** + * External dependencies + */ +const os = require( 'os' ); + +/** + * Gets information about the host user. + * + * @return {Object} The host user's name, uid, and gid. + */ +module.exports = function getHostUser() { + const hostUser = os.userInfo(); + + // On Windows the uid and gid will be -1. Since there isn't a great way to handle this, + // we're just going to say that the host user is root. On Windows you'll likely be + // using WSL to run commands inside the container, which has a uid and gid. If + // you aren't, you'll be mounting directories from Windows, to a Linux + // VM (Docker Desktop uses one), to the guest OS. I assume that + // when dealing with this configuration that file ownership + // has the same kind of magic handling that macOS uses. + const uid = ( hostUser.uid === -1 ? 0 : hostUser.uid ).toString(); + const gid = ( hostUser.gid === -1 ? 0 : hostUser.gid ).toString(); + + return { + name: hostUser.username, + uid, + gid, + fullUser: uid + ':' + gid, + }; +}; diff --git a/packages/env/lib/init-config.js b/packages/env/lib/init-config.js index 8bcb27e207b7a..4a573c97aae7e 100644 --- a/packages/env/lib/init-config.js +++ b/packages/env/lib/init-config.js @@ -1,3 +1,4 @@ +'use strict'; /** * External dependencies */ @@ -5,12 +6,11 @@ const path = require( 'path' ); const { writeFile, mkdir } = require( 'fs' ).promises; const { existsSync } = require( 'fs' ); const yaml = require( 'js-yaml' ); -const os = require( 'os' ); /** * Internal dependencies */ -const { readConfig } = require( './config' ); +const { loadConfig, ValidationError } = require( './config' ); const buildDockerComposeConfig = require( './build-docker-compose-config' ); /** @@ -39,8 +39,7 @@ module.exports = async function initConfig( { xdebug = 'off', writeChanges = false, } ) { - const configPath = path.resolve( '.wp-env.json' ); - const config = await readConfig( configPath ); + const config = await loadConfig( path.resolve( '.' ) ); config.debug = debug; // Adding this to the config allows the start command to understand that the @@ -81,13 +80,23 @@ module.exports = async function initConfig( { yaml.dump( dockerComposeConfig ) ); - await writeFile( - path.resolve( config.workDirectoryPath, 'Dockerfile' ), - dockerFileContents( - dockerComposeConfig.services.wordpress.image, - config - ) - ); + // Write four Dockerfiles for each service we provided. + // (WordPress and CLI services, then a development and test environment for each.) + for ( const imageType of [ 'WordPress', 'CLI' ] ) { + for ( const envType of [ 'development', 'tests' ] ) { + await writeFile( + path.resolve( + config.workDirectoryPath, + `${ + envType === 'tests' ? 'Tests-' : '' + }${ imageType }.Dockerfile` + ), + imageType === 'WordPress' + ? wordpressDockerFileContents( envType, config ) + : cliDockerFileContents( envType, config ) + ); + } + } } else if ( ! existsSync( config.workDirectoryPath ) ) { spinner.fail( 'wp-env has not yet been initialized. Please run `wp-env start` to install the WordPress instance before using any other commands. This is only necessary to set up the environment for the first time; it is typically not necessary for the instance to be running after that in order to use other commands.' @@ -99,80 +108,189 @@ module.exports = async function initConfig( { }; /** - * Checks the configured PHP version - * against the minimum version supported by Xdebug + * Generates the Dockerfile used by wp-env's `wordpress` and `tests-wordpress` instances. * - * @param {WPConfig} config - * @return {boolean} Whether the PHP version is supported by Xdebug + * @param {string} env The environment we're installing -- development or tests. + * @param {WPConfig} config The configuration object. + * @return {string} The dockerfile contents. */ -function checkXdebugPhpCompatibility( config ) { - // By default, an undefined phpVersion uses the version on the docker image, - // which is supported by Xdebug 3. - const phpCompatibility = true; - - // If PHP version is defined - // ensure it meets the Xdebug minimum compatibility requirment. - if ( config.env.development.phpVersion ) { - const versionTokens = config.env.development.phpVersion.split( '.' ); - const majorVer = parseInt( versionTokens[ 0 ] ); - const minorVer = parseInt( versionTokens[ 1 ] ); +function wordpressDockerFileContents( env, config ) { + const phpVersion = config.env[ env ].phpVersion + ? ':php' + config.env[ env ].phpVersion + : ''; - if ( isNaN( majorVer ) || isNaN( minorVer ) ) { - throw new Error( - 'Something went wrong when parsing the PHP version.' - ); - } + return `FROM wordpress${ phpVersion } - // Xdebug 3 supports 7.2 and higher - // Ensure user has specified a compatible PHP version. - if ( majorVer < 7 || ( majorVer === 7 && minorVer < 2 ) ) { - throw new Error( 'Cannot use XDebug 3 on PHP < 7.2.' ); - } - } +# Update apt sources for archived versions of Debian. + +# stretch (https://lists.debian.org/debian-devel-announce/2023/03/msg00006.html) +RUN sed -i 's|deb.debian.org/debian stretch|archive.debian.org/debian stretch|g' /etc/apt/sources.list +RUN sed -i 's|security.debian.org/debian-security stretch|archive.debian.org/debian-security stretch|g' /etc/apt/sources.list +RUN sed -i '/stretch-updates/d' /etc/apt/sources.list - return phpCompatibility; +# Create the host's user so that we can match ownership in the container. +ARG HOST_USERNAME +ARG HOST_UID +ARG HOST_GID +# When the IDs are already in use we can still safely move on. +RUN groupadd -g $HOST_GID $HOST_USERNAME || true +RUN useradd -m -u $HOST_UID -g $HOST_GID $HOST_USERNAME || true + +# Install any dependencies we need in the container. +${ installDependencies( 'wordpress', env, config ) }`; } /** - * Generates the Dockerfile used by wp-env's development instance. + * Generates the Dockerfile used by wp-env's `cli` and `tests-cli` instances. * - * @param {string} image The base docker image to use. + * @param {string} env The environment we're installing -- development or tests. * @param {WPConfig} config The configuration object. - * * @return {string} The dockerfile contents. */ -function dockerFileContents( image, config ) { - // Don't install XDebug unless it is explicitly required. - let shouldInstallXdebug = false; +function cliDockerFileContents( env, config ) { + const phpVersion = config.env[ env ].phpVersion + ? '-php' + config.env[ env ].phpVersion + : ''; + + return `FROM wordpress:cli${ phpVersion } + +# Switch to root so we can create users. +USER root + +# Create the host's user so that we can match ownership in the container. +ARG HOST_USERNAME +ARG HOST_UID +ARG HOST_GID +# When the IDs are already in use we can still safely move on. +RUN addgroup -g $HOST_GID $HOST_USERNAME || true +RUN adduser -h /home/$HOST_USERNAME -G $( getent group $HOST_GID | cut -d: -f1 ) -u $HOST_UID $HOST_USERNAME || true + +# Install any dependencies we need in the container. +${ installDependencies( 'cli', env, config ) } + +# Switch back to the original user now that we're done. +USER www-data + +# Have the container sleep infinitely to keep it alive for us to run commands on it. +CMD [ "/bin/sh", "-c", "while true; do sleep 2073600; done" ] +`; +} + +/** + * Generates content for the Dockerfile to install dependencies. + * + * @param {string} service The kind of service that we're installing dependencies on ('wordpress' or 'cli'). + * @param {string} env The environment we're installing dependencies for ('development' or 'tests'). + * @param {WPConfig} config The configuration object. + * @return {string} The Dockerfile content for installing dependencies. + */ +function installDependencies( service, env, config ) { + let dockerFileContent = ''; + + // At times we may need to evaluate the environment. This is because the + // WordPress image uses Ubuntu while the CLI image uses Alpine. - if ( config.xdebug !== 'off' ) { - const usingCompatiblePhp = checkXdebugPhpCompatibility( config ); + // Start with some environment-specific dependency installations. + switch ( service ) { + case 'wordpress': { + dockerFileContent += ` +# Make sure we're working with the latest packages. +RUN apt-get -qy update - if ( usingCompatiblePhp ) { - shouldInstallXdebug = true; +# Install some basic PHP dependencies. +RUN apt-get -qy install $PHPIZE_DEPS && touch /usr/local/etc/php/php.ini + +# Set up sudo so they can have root access. +RUN apt-get -qy install sudo +RUN echo "$HOST_USERNAME ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers`; + break; + } + case 'cli': { + dockerFileContent += ` +RUN apk update +RUN apk --no-cache add $PHPIZE_DEPS && touch /usr/local/etc/php/php.ini +RUN apk --no-cache add sudo linux-headers +RUN echo "$HOST_USERNAME ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers`; + break; + } + default: { + throw new Error( `Invalid service "${ service }" given` ); } } - return `FROM ${ image } + dockerFileContent += getXdebugConfig( + config.xdebug, + config.env[ env ].phpVersion + ); -RUN apt-get -qy install $PHPIZE_DEPS && touch /usr/local/etc/php/php.ini -${ shouldInstallXdebug ? installXdebug( config.xdebug ) : '' } -`; + // Add better PHP settings. + dockerFileContent += ` +RUN echo 'upload_max_filesize = 1G' >> /usr/local/etc/php/php.ini +RUN echo 'post_max_size = 1G' >> /usr/local/etc/php/php.ini`; + + // Make sure Composer is available for use in all services. + dockerFileContent += ` +RUN curl -sS https://getcomposer.org/installer -o /tmp/composer-setup.php +RUN export COMPOSER_HASH=\`curl -sS https://composer.github.io/installer.sig\` && php -r "if (hash_file('SHA384', '/tmp/composer-setup.php') === '$COMPOSER_HASH') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('/tmp/composer-setup.php'); } echo PHP_EOL;" +RUN php /tmp/composer-setup.php --install-dir=/usr/local/bin --filename=composer +RUN rm /tmp/composer-setup.php`; + + // Install any Composer packages we might need globally. + // Make sure to do this as the user and ensure the binaries are available in the $PATH. + dockerFileContent += ` +USER $HOST_USERNAME +ENV PATH="\${PATH}:/home/$HOST_USERNAME/.composer/vendor/bin" +RUN composer global require --dev yoast/phpunit-polyfills:"^1.0" +USER root`; + + return dockerFileContent; } -function installXdebug( enableXdebug ) { - const isLinux = os.type() === 'Linux'; - // Discover client host does not appear to work on macOS with Docker. - const clientDetectSettings = isLinux - ? 'xdebug.discover_client_host=true' - : 'xdebug.client_host="host.docker.internal"'; +/** + * Gets the Xdebug config based on the options in the config object. + * + * @param {string} xdebugMode The Xdebug mode set in the config. + * @param {string} phpVersion The php version set in the environment. + * @return {string} The Xdebug config -- can be an empty string when it's not used. + */ +function getXdebugConfig( xdebugMode = 'off', phpVersion ) { + if ( xdebugMode === 'off' ) { + return ''; + } + + let xdebugVersion = 'xdebug'; + + if ( phpVersion ) { + const versionTokens = phpVersion.split( '.' ); + const majorVer = parseInt( versionTokens[ 0 ] ); + const minorVer = parseInt( versionTokens[ 1 ] ); + + if ( isNaN( majorVer ) || isNaN( minorVer ) ) { + throw new ValidationError( + 'Something went wrong when parsing the PHP version.' + ); + } + + // Throw an error if someone tries to use Xdebug with an unsupported PHP version. + // Xdebug 3 only supports 7.2 and higher. + if ( majorVer < 7 || ( majorVer === 7 && minorVer < 2 ) ) { + throw new ValidationError( + `Cannot use XDebug 3 with PHP < 7.2. Your PHP version is ${ phpVersion }.` + ); + } + + // For now, we support PHP 7 by installing the final version of Xdebug to + // support PHP 7 when the environment uses that version. By default, use the + // latest version. + if ( majorVer === 7 ) { + xdebugVersion = 'xdebug-3.1.6'; + } + } return ` -# Install Xdebug: -RUN if [ -z "$(pecl list | grep xdebug)" ] ; then pecl install xdebug ; fi +RUN if [ -z "$(pecl list | grep ${ xdebugVersion })" ] ; then pecl install ${ xdebugVersion } ; fi RUN docker-php-ext-enable xdebug RUN echo 'xdebug.start_with_request=yes' >> /usr/local/etc/php/php.ini -RUN echo 'xdebug.mode=${ enableXdebug }' >> /usr/local/etc/php/php.ini -RUN echo '${ clientDetectSettings }' >> /usr/local/etc/php/php.ini - `; +RUN echo 'xdebug.mode=${ xdebugMode }' >> /usr/local/etc/php/php.ini +RUN echo 'xdebug.client_host="host.docker.internal"' >> /usr/local/etc/php/php.ini`; } diff --git a/packages/env/lib/md5.js b/packages/env/lib/md5.js index 2fd6b7c6a6bf2..9f37b5a4ede62 100644 --- a/packages/env/lib/md5.js +++ b/packages/env/lib/md5.js @@ -1,3 +1,4 @@ +'use strict'; /** * External dependencies */ diff --git a/packages/env/lib/parse-xdebug-mode.js b/packages/env/lib/parse-xdebug-mode.js index 927b4f6e3f233..0fb781d786fba 100644 --- a/packages/env/lib/parse-xdebug-mode.js +++ b/packages/env/lib/parse-xdebug-mode.js @@ -1,3 +1,4 @@ +'use strict'; // See https://xdebug.org/docs/all_settings#mode const XDEBUG_MODES = [ 'develop', diff --git a/packages/env/lib/retry.js b/packages/env/lib/retry.js index 014e5b7bffe81..753a8123ead6a 100644 --- a/packages/env/lib/retry.js +++ b/packages/env/lib/retry.js @@ -1,3 +1,4 @@ +'use strict'; /** * External dependencies */ diff --git a/packages/env/test/__snapshots__/md5.js.snap b/packages/env/lib/test/__snapshots__/md5.js.snap similarity index 100% rename from packages/env/test/__snapshots__/md5.js.snap rename to packages/env/lib/test/__snapshots__/md5.js.snap diff --git a/packages/env/test/build-docker-compose-config.js b/packages/env/lib/test/build-docker-compose-config.js similarity index 72% rename from packages/env/test/build-docker-compose-config.js rename to packages/env/lib/test/build-docker-compose-config.js index 5927e188c9df0..95cf6419d5db4 100644 --- a/packages/env/test/build-docker-compose-config.js +++ b/packages/env/lib/test/build-docker-compose-config.js @@ -1,7 +1,9 @@ +'use strict'; /** * Internal dependencies */ -const buildDockerComposeConfig = require( '../lib/build-docker-compose-config' ); +const buildDockerComposeConfig = require( '../build-docker-compose-config' ); +const getHostUser = require( '../get-host-user' ); // The basic config keys which build docker compose config requires. const CONFIG = { @@ -12,6 +14,16 @@ const CONFIG = { configDirectoryPath: '/path/to/config', }; +jest.mock( '../get-host-user', () => jest.fn() ); +getHostUser.mockImplementation( () => { + return { + name: 'test', + uid: 1, + gid: 2, + fullUser: '1:2', + }; +} ); + describe( 'buildDockerComposeConfig', () => { it( 'should map directories before individual sources', () => { const envConfig = { @@ -33,6 +45,7 @@ describe( 'buildDockerComposeConfig', () => { expect( volumes ).toEqual( [ 'wordpress:/var/www/html', // WordPress root. '/path/WordPress-PHPUnit/tests/phpunit:/wordpress-phpunit', // WordPress test library, + 'user-home:/home/test', '/path/to/wp-plugins:/var/www/html/wp-content/plugins', // Mapped plugins root. '/path/to/local/plugin:/var/www/html/wp-content/plugins/test-name', // Mapped plugin. ] ); @@ -68,6 +81,7 @@ describe( 'buildDockerComposeConfig', () => { let localSources = [ '/path/to/wp-plugins:/var/www/html/wp-content/plugins', '/path/WordPress-PHPUnit/tests/phpunit:/wordpress-phpunit', + 'user-home:/home/test', '/path/to/local/plugin:/var/www/html/wp-content/plugins/test-name', '/path/to/local/theme:/var/www/html/wp-content/themes/test-theme', ]; @@ -76,6 +90,7 @@ describe( 'buildDockerComposeConfig', () => { localSources = [ '/path/to/wp-plugins:/var/www/html/wp-content/plugins', '/path/tests-WordPress-PHPUnit/tests/phpunit:/wordpress-phpunit', + 'tests-user-home:/home/test', '/path/to/local/plugin:/var/www/html/wp-content/plugins/test-name', '/path/to/local/theme:/var/www/html/wp-content/themes/test-theme', ]; @@ -84,52 +99,6 @@ describe( 'buildDockerComposeConfig', () => { ); } ); - it( 'should not map the default phpunit uploads directory if the user has specified their own directory', () => { - const envConfig = { - ...CONFIG, - mappings: { - 'wp-content/uploads': { - path: '/path/to/wp-uploads', - }, - }, - }; - const dockerConfig = buildDockerComposeConfig( { - workDirectoryPath: '/path', - env: { development: envConfig, tests: envConfig }, - } ); - const expectedVolumes = [ - 'tests-wordpress:/var/www/html', - '/path/tests-WordPress-PHPUnit/tests/phpunit:/wordpress-phpunit', - '/path/to/wp-uploads:/var/www/html/wp-content/uploads', - ]; - expect( dockerConfig.services.phpunit.volumes ).toEqual( - expectedVolumes - ); - } ); - - it( 'should map the default phpunit uploads directory even if the user has specified their own directory only for the development instance', () => { - const envConfig = { - ...CONFIG, - mappings: { - 'wp-content/uploads': { - path: '/path/to/wp-uploads', - }, - }, - }; - const dockerConfig = buildDockerComposeConfig( { - workDirectoryPath: '/path', - env: { development: envConfig, tests: CONFIG }, - } ); - const expectedVolumes = [ - 'tests-wordpress:/var/www/html', - '/path/tests-WordPress-PHPUnit/tests/phpunit:/wordpress-phpunit', - 'phpunit-uploads:/var/www/html/wp-content/uploads', - ]; - expect( dockerConfig.services.phpunit.volumes ).toEqual( - expectedVolumes - ); - } ); - it( 'should create "wordpress" and "tests-wordpress" volumes if they are needed by containers', () => { // CONFIG has no coreSource entry, so there are no core sources on the // local filesystem, so a volume should be created to contain core diff --git a/packages/env/test/cache.js b/packages/env/lib/test/cache.js similarity index 99% rename from packages/env/test/cache.js rename to packages/env/lib/test/cache.js index 3b7f17d11e1f5..63da7ec47d70b 100644 --- a/packages/env/test/cache.js +++ b/packages/env/lib/test/cache.js @@ -1,3 +1,4 @@ +'use strict'; /** * External dependencies */ @@ -11,7 +12,7 @@ const { setCache, getCache, getCacheFile, -} = require( '../lib/cache' ); +} = require( '../cache' ); jest.mock( 'fs', () => ( { promises: { diff --git a/packages/env/test/cli.js b/packages/env/lib/test/cli.js similarity index 91% rename from packages/env/test/cli.js rename to packages/env/lib/test/cli.js index 9319750518095..336d254493682 100644 --- a/packages/env/test/cli.js +++ b/packages/env/lib/test/cli.js @@ -2,8 +2,8 @@ /** * Internal dependencies */ -const cli = require( '../lib/cli' ); -const env = require( '../lib/env' ); +const cli = require( '../cli' ); +const env = require( '../env' ); /** * Mocked dependencies @@ -14,13 +14,17 @@ jest.mock( 'ora', () => () => ( { return { text: '', succeed: jest.fn(), fail: jest.fn() }; }, } ) ); -jest.mock( '../lib/env', () => ( { - start: jest.fn( Promise.resolve.bind( Promise ) ), - stop: jest.fn( Promise.resolve.bind( Promise ) ), - clean: jest.fn( Promise.resolve.bind( Promise ) ), - run: jest.fn( Promise.resolve.bind( Promise ) ), - ValidationError: jest.requireActual( '../lib/env' ).ValidationError, -} ) ); +jest.mock( '../env', () => { + const actual = jest.requireActual( '../env' ); + return { + start: jest.fn( Promise.resolve.bind( Promise ) ), + stop: jest.fn( Promise.resolve.bind( Promise ) ), + clean: jest.fn( Promise.resolve.bind( Promise ) ), + run: jest.fn( Promise.resolve.bind( Promise ) ), + ValidationError: actual.ValidationError, + AfterSetupError: actual.AfterSetupError, + }; +} ); describe( 'env cli', () => { beforeEach( jest.clearAllMocks ); diff --git a/packages/env/lib/test/execute-after-setup.js b/packages/env/lib/test/execute-after-setup.js new file mode 100644 index 0000000000000..4ea0e8fd9bfdd --- /dev/null +++ b/packages/env/lib/test/execute-after-setup.js @@ -0,0 +1,66 @@ +'use strict'; +/** + * External dependencies + */ +const { execSync } = require( 'child_process' ); + +/** + * Internal dependencies + */ +const { + AfterSetupError, + executeAfterSetup, +} = require( '../execute-after-setup' ); + +jest.mock( 'child_process', () => ( { + execSync: jest.fn(), +} ) ); + +describe( 'executeAfterSetup', () => { + const spinner = { + info: jest.fn(), + }; + + afterEach( () => { + jest.clearAllMocks(); + } ); + + it( 'should do nothing without afterSetup option', () => { + executeAfterSetup( { afterSetup: null }, spinner ); + + expect( spinner.info ).not.toHaveBeenCalled(); + } ); + + it( 'should run afterSetup option and print output without extra whitespace', () => { + execSync.mockReturnValue( 'Test \n' ); + + executeAfterSetup( { afterSetup: 'Test Setup' }, spinner ); + + expect( execSync ).toHaveBeenCalled(); + expect( execSync.mock.calls[ 0 ][ 0 ] ).toEqual( 'Test Setup' ); + expect( spinner.info ).toHaveBeenCalledWith( 'After Setup:\nTest' ); + } ); + + it( 'should print nothing if afterSetup returns no output', () => { + execSync.mockReturnValue( '' ); + + executeAfterSetup( { afterSetup: 'Test Setup' }, spinner ); + + expect( execSync ).toHaveBeenCalled(); + expect( execSync.mock.calls[ 0 ][ 0 ] ).toEqual( 'Test Setup' ); + expect( spinner.info ).not.toHaveBeenCalled(); + } ); + + it( 'should throw AfterSetupError when process errors', () => { + execSync.mockImplementation( ( command ) => { + expect( command ).toEqual( 'Test Setup' ); + throw { stderr: 'Something bad happened.' }; + } ); + + expect( () => + executeAfterSetup( { afterSetup: 'Test Setup' }, spinner ) + ).toThrow( + new AfterSetupError( 'After Setup:\nSomething bad happened.' ) + ); + } ); +} ); diff --git a/packages/env/test/md5.js b/packages/env/lib/test/md5.js similarity index 94% rename from packages/env/test/md5.js rename to packages/env/lib/test/md5.js index 2d07a589c1768..ad40f0040f16c 100644 --- a/packages/env/test/md5.js +++ b/packages/env/lib/test/md5.js @@ -1,7 +1,8 @@ +'use strict'; /** * Internal dependencies */ -const md5 = require( '../lib/md5' ); +const md5 = require( '../md5' ); describe( 'md5', () => { it( 'creates a hash of a string', () => { diff --git a/packages/env/test/parse-xdebug-mode.js b/packages/env/lib/test/parse-xdebug-mode.js similarity index 94% rename from packages/env/test/parse-xdebug-mode.js rename to packages/env/lib/test/parse-xdebug-mode.js index 454611cd40d43..c84ad16bb81c3 100644 --- a/packages/env/test/parse-xdebug-mode.js +++ b/packages/env/lib/test/parse-xdebug-mode.js @@ -1,7 +1,8 @@ +'use strict'; /** * Internal dependencies */ -const parseXdebugMode = require( '../lib/parse-xdebug-mode' ); +const parseXdebugMode = require( '../parse-xdebug-mode' ); describe( 'parseXdebugMode', () => { it( 'throws an error if the passed value is neither a string nor undefined', () => { diff --git a/packages/env/lib/wordpress.js b/packages/env/lib/wordpress.js index fc8f6db567077..f238288498cf5 100644 --- a/packages/env/lib/wordpress.js +++ b/packages/env/lib/wordpress.js @@ -1,3 +1,4 @@ +'use strict'; /** * External dependencies */ @@ -14,7 +15,7 @@ const copyDir = util.promisify( require( 'copy-dir' ) ); /** * @typedef {import('./config').WPConfig} WPConfig - * @typedef {import('./config').WPServiceConfig} WPServiceConfig + * @typedef {import('./config').WPEnvironmentConfig} WPEnvironmentConfig * @typedef {import('./config').WPSource} WPSource * @typedef {'development'|'tests'} WPEnvironment * @typedef {'development'|'tests'|'all'} WPEnvironmentSelection @@ -86,6 +87,7 @@ async function configureWordPress( environment, config, spinner ) { [ 'bash', '-c', setupCommands.join( ' && ' ) ], { config: config.dockerComposeConfigPath, + commandOptions: [ '--rm' ], log: config.debug, } ); @@ -147,30 +149,13 @@ async function setupWordPressDirectories( config ) { config.env.development.coreSource.path, config.env.development.coreSource.testsPath ); - await createUploadsDir( config.env.development.coreSource.testsPath ); } - - const checkedPaths = {}; - for ( const { coreSource } of Object.values( config.env ) ) { - if ( coreSource && ! checkedPaths[ coreSource.path ] ) { - await createUploadsDir( coreSource.path ); - checkedPaths[ coreSource.path ] = true; - } - } -} - -async function createUploadsDir( corePath ) { - // Ensure the tests uploads folder is writeable for travis, - // creating the folder if necessary. - const uploadPath = path.join( corePath, 'wp-content/uploads' ); - await fs.mkdir( uploadPath, { recursive: true } ); - await fs.chmod( uploadPath, 0o0767 ); } /** * Returns true if all given environment configs have the same core source. * - * @param {WPServiceConfig[]} envs An array of environments to check. + * @param {WPEnvironmentConfig[]} envs An array of environments to check. * * @return {boolean} True if all the environments have the same core source. */ diff --git a/packages/env/test/parse-config.js b/packages/env/test/parse-config.js deleted file mode 100644 index c72bbd4f34e28..0000000000000 --- a/packages/env/test/parse-config.js +++ /dev/null @@ -1,61 +0,0 @@ -/** - * External dependencies - */ -const _path = require( 'path' ); - -/** - * Internal dependencies - */ -const { parseSourceString } = require( '../lib/config/parse-config' ); - -const parseSourceStringOptions = { workDirectoryPath: '.' }; -const currentDirectory = _path.resolve( '.' ); - -describe( 'parseSourceString', () => { - it( 'returns null for null source', () => { - const wpSource = parseSourceString( null, {} ); - expect( wpSource ).toBeNull(); - } ); -} ); - -const gitTests = [ - { - sourceString: 'ssh://git@github.com/short.git', - url: 'ssh://git@github.com/short.git', - ref: undefined, - path: currentDirectory + '/short', - clonePath: currentDirectory + '/short', - basename: 'short', - }, - { - sourceString: 'ssh://git@github.com/owner/long/path/repo.git', - url: 'ssh://git@github.com/owner/long/path/repo.git', - ref: undefined, - path: currentDirectory + '/owner/long/path/repo', - clonePath: currentDirectory + '/owner/long/path/repo', - basename: 'repo', - }, - { - sourceString: 'git+ssh://git@github.com/owner/repo.git#kitchen-sink', - url: 'git+ssh://git@github.com/owner/repo.git', - ref: 'kitchen-sink', - path: currentDirectory + '/owner/repo', - clonePath: currentDirectory + '/owner/repo', - basename: 'repo', - }, -]; - -describe.each( gitTests )( 'parseSourceString', ( source ) => { - it( `parses ${ source.sourceString }`, () => { - const { type, url, ref, path, clonePath, basename } = parseSourceString( - source.sourceString, - parseSourceStringOptions - ); - expect( type ).toBe( 'git' ); - expect( url ).toBe( source.url ); - expect( ref ).toBe( source.ref ); - expect( path ).toBe( source.path ); - expect( clonePath ).toBe( source.clonePath ); - expect( basename ).toBe( source.basename ); - } ); -} ); diff --git a/packages/eslint-plugin/CHANGELOG.md b/packages/eslint-plugin/CHANGELOG.md index e4c7cadec020e..6aa8d272f2c4d 100644 --- a/packages/eslint-plugin/CHANGELOG.md +++ b/packages/eslint-plugin/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Enhancement + +- Validate dependencies in `useSelect` and `useSuspenseSelect` hooks. ([#49900](https://github.com/WordPress/gutenberg/pull/49900)). + ## 14.5.0 (2023-04-26) ## 14.4.0 (2023-04-12) diff --git a/packages/eslint-plugin/configs/react.js b/packages/eslint-plugin/configs/react.js index a30db8442ecc3..3562b0e70074b 100644 --- a/packages/eslint-plugin/configs/react.js +++ b/packages/eslint-plugin/configs/react.js @@ -34,7 +34,12 @@ module.exports = { 'react/no-children-prop': 'off', 'react/prop-types': 'off', 'react/react-in-jsx-scope': 'off', - 'react-hooks/exhaustive-deps': 'warn', + 'react-hooks/exhaustive-deps': [ + 'warn', + { + additionalHooks: '(useSelect|useSuspenseSelect)', + }, + ], 'react-hooks/rules-of-hooks': 'error', }, }; diff --git a/packages/icons/src/library/details.js b/packages/icons/src/library/details.js index 11fa97eb8cb33..11d5835620648 100644 --- a/packages/icons/src/library/details.js +++ b/packages/icons/src/library/details.js @@ -4,17 +4,13 @@ import { SVG, Path } from '@wordpress/primitives'; const details = ( - + + ); diff --git a/packages/icons/src/library/level-up.js b/packages/icons/src/library/level-up.js index fc992c8dbada5..69989b01b7f73 100644 --- a/packages/icons/src/library/level-up.js +++ b/packages/icons/src/library/level-up.js @@ -4,7 +4,7 @@ import { SVG, Path } from '@wordpress/primitives'; const levelUp = ( - + ); diff --git a/packages/private-apis/src/implementation.js b/packages/private-apis/src/implementation.js index 4bda8dd039eb2..c12b431c4ceef 100644 --- a/packages/private-apis/src/implementation.js +++ b/packages/private-apis/src/implementation.js @@ -15,12 +15,14 @@ const CORE_MODULES_USING_PRIVATE_APIS = [ '@wordpress/blocks', '@wordpress/commands', '@wordpress/components', + '@wordpress/core-commands', '@wordpress/customize-widgets', '@wordpress/data', '@wordpress/edit-post', '@wordpress/edit-site', '@wordpress/edit-widgets', '@wordpress/editor', + '@wordpress/router', ]; /** diff --git a/packages/react-native-aztec/package.json b/packages/react-native-aztec/package.json index fa3a835fd92db..554e037c47d61 100644 --- a/packages/react-native-aztec/package.json +++ b/packages/react-native-aztec/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/react-native-aztec", - "version": "1.93.1", + "version": "1.94.0", "description": "Aztec view for react-native.", "private": true, "author": "The WordPress Contributors", diff --git a/packages/react-native-bridge/ios/GutenbergBridgeDelegate.swift b/packages/react-native-bridge/ios/GutenbergBridgeDelegate.swift index 25cc3af320b24..4b13e4e6a15a9 100644 --- a/packages/react-native-bridge/ios/GutenbergBridgeDelegate.swift +++ b/packages/react-native-bridge/ios/GutenbergBridgeDelegate.swift @@ -1,3 +1,5 @@ +import React + public struct MediaInfo: Encodable { public let id: Int32? public let url: String? diff --git a/packages/react-native-bridge/ios/RNReactNativeGutenbergBridge.swift b/packages/react-native-bridge/ios/RNReactNativeGutenbergBridge.swift index 9b5908a8b7bc7..bfc3507732138 100644 --- a/packages/react-native-bridge/ios/RNReactNativeGutenbergBridge.swift +++ b/packages/react-native-bridge/ios/RNReactNativeGutenbergBridge.swift @@ -1,3 +1,5 @@ +import React + struct GutenbergEvent { let name: String let body: Any? diff --git a/packages/react-native-bridge/ios/SourceFile.swift b/packages/react-native-bridge/ios/SourceFile.swift index b32a65c0b0f2c..a6deceed3549f 100644 --- a/packages/react-native-bridge/ios/SourceFile.swift +++ b/packages/react-native-bridge/ios/SourceFile.swift @@ -1,4 +1,4 @@ -import Foundation +import WebKit public struct SourceFile { enum SourceFileError: Error { diff --git a/packages/react-native-bridge/package.json b/packages/react-native-bridge/package.json index 6634af83b1f2a..81eb7a974b290 100644 --- a/packages/react-native-bridge/package.json +++ b/packages/react-native-bridge/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/react-native-bridge", - "version": "1.93.1", + "version": "1.94.0", "description": "Native bridge library used to integrate the block editor into a native App.", "private": true, "author": "The WordPress Contributors", diff --git a/packages/react-native-editor/CHANGELOG.md b/packages/react-native-editor/CHANGELOG.md index 92ebaecfd9121..b297e2052c868 100644 --- a/packages/react-native-editor/CHANGELOG.md +++ b/packages/react-native-editor/CHANGELOG.md @@ -10,6 +10,11 @@ For each user feature we should also add a importance categorization label to i --> ## Unreleased +- [*] Fix crash when trying to convert to regular blocks an undefined/deleted reusable block [#50475] +- [**] Tapping on a nested block now gets focus directly instead of having to tap multiple times depeding on the nesting levels. [#50108] + +## 1.94.0 +- [*] Split pasted content between title and body. [#37169] ## 1.93.1 - [**] Fix regression with the Color hook and ColorPanel. [#49917] diff --git a/packages/react-native-editor/__device-tests__/gutenberg-editor-drag-and-drop.test.js b/packages/react-native-editor/__device-tests__/gutenberg-editor-drag-and-drop.test.js index 5d7cce975e373..a13e6047f1712 100644 --- a/packages/react-native-editor/__device-tests__/gutenberg-editor-drag-and-drop.test.js +++ b/packages/react-native-editor/__device-tests__/gutenberg-editor-drag-and-drop.test.js @@ -4,7 +4,6 @@ import { blockNames } from './pages/editor-page'; import { clearClipboard, - clickElementOutsideOfTextInput, dragAndDropAfterElement, isAndroid, setClipboard, @@ -33,8 +32,10 @@ describe( 'Gutenberg Editor Drag & Drop blocks tests', () => { const spacerBlock = await editorPage.getBlockAtPosition( blockNames.spacer ); - const paragraphBlock = - await editorPage.getParagraphBlockWrapperAtPosition( 2 ); + const paragraphBlock = await editorPage.getBlockAtPosition( + blockNames.paragraph, + 2 + ); // Drag & drop the Spacer block after the Paragraph block await dragAndDropAfterElement( @@ -122,7 +123,7 @@ describe( 'Gutenberg Editor Drag & Drop blocks tests', () => { } ); - it( 'should be able to drag & drop a text-based block when the textinput is not focused', async () => { + it( 'should be able to drag & drop a text-based block when another textinput is focused', async () => { // Initialize the editor with two Paragraph blocks await editorPage.setHtmlContent( [ @@ -131,16 +132,15 @@ describe( 'Gutenberg Editor Drag & Drop blocks tests', () => { ].join( '\n\n' ) ); - // Get elements for both blocks - const firstParagraphBlock = - await editorPage.getParagraphBlockWrapperAtPosition( 1 ); - const secondParagraphBlock = - await editorPage.getParagraphBlockWrapperAtPosition( 2 ); + // Tap on the second block + const secondParagraphBlock = await editorPage.getBlockAtPosition( + blockNames.paragraph, + 2 + ); + await secondParagraphBlock.click(); - // Tap on the first Paragraph block outside of the textinput - await clickElementOutsideOfTextInput( - editorPage.driver, - firstParagraphBlock + const firstParagraphBlock = await editorPage.getBlockAtPosition( + blockNames.paragraph ); // Drag & drop the first Paragraph block after the second Paragraph block @@ -156,8 +156,5 @@ describe( 'Gutenberg Editor Drag & Drop blocks tests', () => { // Expect the second Paragraph block to have the expected content expect( secondBlockText ).toMatch( testData.shortText ); - - // Remove the block - await editorPage.removeBlock(); } ); } ); diff --git a/packages/react-native-editor/__device-tests__/gutenberg-editor-media-blocks-@canary.test.js b/packages/react-native-editor/__device-tests__/gutenberg-editor-media-blocks-@canary.test.js index 1a12e32b99e90..78215dcecc68c 100644 --- a/packages/react-native-editor/__device-tests__/gutenberg-editor-media-blocks-@canary.test.js +++ b/packages/react-native-editor/__device-tests__/gutenberg-editor-media-blocks-@canary.test.js @@ -131,6 +131,11 @@ onlyOniOS( 'Gutenberg Editor Cover Block test', () => { await coverBlock.click(); expect( coverBlock ).toBeTruthy(); + + // Navigate upwards to select parent block + const navigateUpElement = + await editorPage.waitForElementToBeDisplayedById( 'Navigate Up' ); + await navigateUpElement.click(); await editorPage.removeBlockAtPosition( blockNames.cover ); } ); @@ -144,6 +149,10 @@ onlyOniOS( 'Gutenberg Editor Cover Block test', () => { blockNames.cover ); await coverBlock.click(); + // Navigate upwards to select parent block + const navigateUpElement = + await editorPage.waitForElementToBeDisplayedById( 'Navigate Up' ); + await navigateUpElement.click(); await editorPage.openBlockSettings(); await editorPage.clickAddMediaFromCoverBlock(); diff --git a/packages/react-native-editor/__device-tests__/pages/editor-page.js b/packages/react-native-editor/__device-tests__/pages/editor-page.js index 6cad729d26ecb..433c3cd706c9c 100644 --- a/packages/react-native-editor/__device-tests__/pages/editor-page.js +++ b/packages/react-native-editor/__device-tests__/pages/editor-page.js @@ -91,7 +91,7 @@ class EditorPage { } const blockLocator = isAndroid() - ? `//android.view.ViewGroup[contains(@content-desc, "${ blockName } Block. Row ${ position }.")]//android.widget.EditText` + ? `//android.widget.Button[contains(@content-desc, "${ blockName } Block. Row ${ position }.")]//android.widget.EditText` : `//XCUIElementTypeButton[contains(@name, "${ blockName } Block. Row ${ position }.")]//XCUIElementTypeTextView`; return await waitForVisible( this.driver, blockLocator ); @@ -109,19 +109,9 @@ class EditorPage { position = 1, options = { autoscroll: false } ) { - let elementType; - switch ( blockName ) { - case blockNames.cover: - elementType = 'XCUIElementTypeButton'; - break; - default: - elementType = 'XCUIElementTypeOther'; - break; - } - const blockLocator = isAndroid() - ? `//android.view.ViewGroup[contains(@${ this.accessibilityIdXPathAttrib }, "${ blockName } Block. Row ${ position }")]` - : `(//${ elementType }[contains(@${ this.accessibilityIdXPathAttrib }, "${ blockName } Block. Row ${ position }")])[1]`; + ? `//android.widget.Button[contains(@${ this.accessibilityIdXPathAttrib }, "${ blockName } Block. Row ${ position }")]` + : `(//XCUIElementTypeOther[contains(@${ this.accessibilityIdXPathAttrib }, "${ blockName } Block. Row ${ position }")])[2]`; await waitForVisible( this.driver, blockLocator ); @@ -352,8 +342,10 @@ class EditorPage { } async openBlockSettings() { - const settingsButtonElement = 'Open Settings'; - const settingsButton = await this.waitForElementToBeDisplayedById( + const settingsButtonElement = isAndroid() + ? '//android.widget.Button[@content-desc="Open Settings"]/android.view.ViewGroup' + : '//XCUIElementTypeButton[@name="Open Settings"]'; + const settingsButton = await this.waitForElementToBeDisplayedByXPath( settingsButtonElement ); @@ -362,9 +354,9 @@ class EditorPage { async removeBlock() { const blockActionsButtonElement = isAndroid() - ? 'Open Block Actions Menu, Double tap to open Bottom Sheet with available options' - : 'Open Block Actions Menu'; - const blockActionsMenu = await this.waitForElementToBeDisplayedById( + ? '//android.widget.Button[contains(@content-desc, "Open Block Actions Menu")]' + : '//XCUIElementTypeButton[@name="Open Block Actions Menu"]'; + const blockActionsMenu = await this.waitForElementToBeDisplayedByXPath( blockActionsButtonElement ); await blockActionsMenu.click(); @@ -609,15 +601,6 @@ class EditorPage { // Paragraph Block functions // ========================= - async getParagraphBlockWrapperAtPosition( position = 1 ) { - // iOS needs a click to get the text element - const blockLocator = isAndroid() - ? `//android.view.ViewGroup[contains(@content-desc, "Paragraph Block. Row ${ position }")]` - : `(//XCUIElementTypeButton[contains(@name, "Paragraph Block. Row ${ position }")])`; - - return await waitForVisible( this.driver, blockLocator ); - } - async sendTextToParagraphBlock( position, text, clear ) { const paragraphs = text.split( '\n' ); for ( let i = 0; i < paragraphs.length; i++ ) { @@ -648,7 +631,7 @@ class EditorPage { async getNumberOfParagraphBlocks() { const paragraphBlockLocator = isAndroid() - ? `//android.view.ViewGroup[contains(@content-desc, "Paragraph Block. Row")]//android.widget.EditText` + ? `//android.widget.Button[contains(@content-desc, "Paragraph Block. Row")]//android.widget.EditText` : `(//XCUIElementTypeButton[contains(@name, "Paragraph Block. Row")])`; const locator = await this.driver.elementsByXPath( @@ -702,7 +685,7 @@ class EditorPage { : `//XCUIElementTypeButton[contains(@name, "List")]//XCUIElementTypeTextView`; const listBlockTextLocator = isAndroid() - ? `//android.view.ViewGroup[contains(@content-desc, "List Block. Row ${ position }")]//android.widget.EditText` + ? `//android.widget.Button[contains(@content-desc, "List Block. Row ${ position }")]//android.widget.EditText` : listBlockTextLocatorIOS; return await waitForVisible( this.driver, listBlockTextLocator ); @@ -907,7 +890,7 @@ class EditorPage { } const blockLocator = isAndroid() - ? `//android.view.ViewGroup[@content-desc="Shortcode Block. Row ${ position }"]/android.view.ViewGroup/android.view.ViewGroup/android.widget.EditText` + ? `//android.widget.Button[@content-desc="Shortcode Block. Row ${ position }"]//android.widget.EditText` : `//XCUIElementTypeButton[contains(@name, "Shortcode Block. Row ${ position }")]//XCUIElementTypeTextView`; return await waitForVisible( this.driver, blockLocator ); @@ -919,7 +902,7 @@ class EditorPage { async getButtonBlockTextInputAtPosition( position = 1 ) { const blockLocator = isAndroid() - ? `//android.view.ViewGroup[@content-desc="Button Block. Row ${ position }"]/android.view.ViewGroup[2]/android.view.ViewGroup/android.view.ViewGroup/android.widget.EditText` + ? `//android.widget.Button[@content-desc="Button Block. Row ${ position }"]//android.widget.EditText` : `//XCUIElementTypeButton[contains(@name, "Button Block. Row ${ position }")]//XCUIElementTypeTextView`; return await this.waitForElementToBeDisplayedByXPath( blockLocator ); diff --git a/packages/react-native-editor/ios/Podfile.lock b/packages/react-native-editor/ios/Podfile.lock index 5b0d6d7624f50..1c5441c010b17 100644 --- a/packages/react-native-editor/ios/Podfile.lock +++ b/packages/react-native-editor/ios/Podfile.lock @@ -13,7 +13,7 @@ PODS: - ReactCommon/turbomodule/core (= 0.69.4) - fmt (6.2.1) - glog (0.3.5) - - Gutenberg (1.93.1): + - Gutenberg (1.94.0): - React-Core (= 0.69.4) - React-CoreModules (= 0.69.4) - React-RCTImage (= 0.69.4) @@ -360,7 +360,7 @@ PODS: - React-Core - RNSVG (9.13.6): - React-Core - - RNTAztecView (1.93.1): + - RNTAztecView (1.94.0): - React-Core - WordPress-Aztec-iOS (~> 1.19.8) - SDWebImage (5.11.1): @@ -540,7 +540,7 @@ SPEC CHECKSUMS: FBReactNativeSpec: 2ff441cbe6e58c1778d8a5cf3311831a6a8c0809 fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9 glog: 3d02b25ca00c2d456734d0bcff864cbc62f6ae1a - Gutenberg: b505fef3d02c78e27de6d76e93ab8e8236d60ab0 + Gutenberg: 001ebfc576414f3e862e909b6f5abd5d38fe672e libwebp: 60305b2e989864154bd9be3d772730f08fc6a59c RCT-Folly: b9d9fe1fc70114b751c076104e52f3b1b5e5a95a RCTRequired: bd9d2ab0fda10171fcbcf9ba61a7df4dc15a28f4 @@ -582,7 +582,7 @@ SPEC CHECKSUMS: RNReanimated: bea6acb5fdcbd8ca27641180579d09e3434f803c RNScreens: 953633729a42e23ad0c93574d676b361e3335e8b RNSVG: 36a7359c428dcb7c6bce1cc546fbfebe069809b0 - RNTAztecView: 4f4c5ac657bab6a83446a09954fb40a231a9780a + RNTAztecView: 75c7cd1b1047035b64c3860d9d43fa2729ca59c6 SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d WordPress-Aztec-iOS: 7d11d598f14c82c727c08b56bd35fbeb7dafb504 diff --git a/packages/react-native-editor/package.json b/packages/react-native-editor/package.json index d52637dd113c7..a926ef425d07b 100644 --- a/packages/react-native-editor/package.json +++ b/packages/react-native-editor/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/react-native-editor", - "version": "1.93.1", + "version": "1.94.0", "description": "Mobile WordPress gutenberg editor.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/react-native-editor/src/api-fetch-setup.js b/packages/react-native-editor/src/api-fetch-setup.js index 661bff038aff3..9885f2c55c90f 100644 --- a/packages/react-native-editor/src/api-fetch-setup.js +++ b/packages/react-native-editor/src/api-fetch-setup.js @@ -84,8 +84,14 @@ export const isPathSupported = ( path, method ) => { ); }; -export const shouldEnableCaching = ( path ) => - ! DISABLED_CACHING_ENDPOINTS.some( ( pattern ) => pattern.test( path ) ); +export const shouldEnableCaching = ( path ) => { + const disabledEndpoints = applyFilters( + 'native.disabled_caching_endpoints', + DISABLED_CACHING_ENDPOINTS + ); + + return ! disabledEndpoints.some( ( pattern ) => pattern.test( path ) ); +}; export default () => { apiFetch.setFetchHandler( ( options ) => fetchHandler( options ) ); diff --git a/packages/react-native-editor/src/test/api-fetch-setup.test.js b/packages/react-native-editor/src/test/api-fetch-setup.test.js index 3c42d511853e8..efa31751ff742 100644 --- a/packages/react-native-editor/src/test/api-fetch-setup.test.js +++ b/packages/react-native-editor/src/test/api-fetch-setup.test.js @@ -93,4 +93,17 @@ describe( 'shouldEnableCaching', () => { expect( shouldEnableCaching( path ) ).toBe( false ); } ); } ); + + it( 'does not enable caching for endpoints provided to filter', () => { + addFilter( + 'native.disabled_caching_endpoints', + 'gutenberg-mobile', + ( endpoints ) => { + return [ ...endpoints, /wp\/v2\/categories/i ]; + } + ); + + // Filter was used to stop caching an endpoint from `enabledCachingPaths` array. + expect( shouldEnableCaching( 'wp/v2/categories' ) ).toBe( false ); + } ); } ); diff --git a/packages/report-flaky-tests/src/__tests__/__snapshots__/run.test.ts.snap b/packages/report-flaky-tests/src/__tests__/__snapshots__/run.test.ts.snap index 2770169e473fe..8920f757c0efc 100644 --- a/packages/report-flaky-tests/src/__tests__/__snapshots__/run.test.ts.snap +++ b/packages/report-flaky-tests/src/__tests__/__snapshots__/run.test.ts.snap @@ -1,67 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Report flaky tests should report flaky tests to issue on pull request: Created new flaky issue 1`] = ` -" -**Flaky test detected. This is an auto-generated issue by GitHub Actions. Please do NOT edit this manually.** - -## Test title -Should insert new template part on creation - -## Test path -\`specs/site-editor/template-part.test.js\` - -## Errors - -
- -
[2020-05-10T00:00:00.000Z] Test passed after 2 failed attempts on headBranch. - - -\`\`\` - ● Template Part › Template part block › Template part placeholder › Should insert new template part on creation - - expect(jest.fn()).not.toHaveErrored(expected) - - Expected mock function not to be called but it was called with: - ["TypeError: Cannot read properties of null (reading 'frameElement') - - at Vo (../../http:/localhost:8889/wp-content/plugins/gutenberg/build/block-editor/index.min.js?ver=dfd3a79ce1dc54c31b6ed591bcb0d55a:3:21925) - at ft (../../http:/localhost:8889/wp-content/plugins/gutenberg/build/vendors/react-dom.min.js?ver=6.1-alpha-53362:1:43451) - at Wt (../../http:/localhost:8889/wp-content/plugins/gutenberg/build/vendors/react-dom.min.js?ver=6.1-alpha-53362:1:50270) - at ts (../../http:/localhost:8889/wp-content/plugins/gutenberg/build/vendors/react-dom.min.js?ver=6.1-alpha-53362:1:112276) - at Fr (../../http:/localhost:8889/wp-content/plugins/gutenberg/build/vendors/react-dom.min.js?ver=6.1-alpha-53362:1:77758) - at Dr (../../http:/localhost:8889/wp-content/plugins/gutenberg/build/vendors/react-dom.min.js?ver=6.1-alpha-53362:1:77686) - at Rr (../../http:/localhost:8889/wp-content/plugins/gutenberg/build/vendors/react-dom.min.js?ver=6.1-alpha-53362:1:77549) - at Nr (../../http:/localhost:8889/wp-content/plugins/gutenberg/build/vendors/react-dom.min.js?ver=6.1-alpha-53362:1:74544) - at ../../http:/localhost:8889/wp-content/plugins/gutenberg/build/vendors/react-dom.min.js?ver=6.1-alpha-53362:1:30170 - at unstable_runWithPriority (../../http:/localhost:8889/wp-content/plugins/gutenberg/build/vendors/react.min.js?ver=6.1-alpha-53362:1:7430)"] - at runMicrotasks () - - ● Template Part › Template part block › Template part placeholder › Should insert new template part on creation - - expect(jest.fn()).not.toHaveErrored(expected) - - Expected mock function not to be called but it was called with: - ["TypeError: Cannot read properties of null (reading 'frameElement') - - at Vo (../../http:/localhost:8889/wp-content/plugins/gutenberg/build/block-editor/index.min.js?ver=dfd3a79ce1dc54c31b6ed591bcb0d55a:3:21925) - at ft (../../http:/localhost:8889/wp-content/plugins/gutenberg/build/vendors/react-dom.min.js?ver=6.1-alpha-53362:1:43451) - at Wt (../../http:/localhost:8889/wp-content/plugins/gutenberg/build/vendors/react-dom.min.js?ver=6.1-alpha-53362:1:50270) - at ts (../../http:/localhost:8889/wp-content/plugins/gutenberg/build/vendors/react-dom.min.js?ver=6.1-alpha-53362:1:112276) - at Fr (../../http:/localhost:8889/wp-content/plugins/gutenberg/build/vendors/react-dom.min.js?ver=6.1-alpha-53362:1:77758) - at Dr (../../http:/localhost:8889/wp-content/plugins/gutenberg/build/vendors/react-dom.min.js?ver=6.1-alpha-53362:1:77686) - at Rr (../../http:/localhost:8889/wp-content/plugins/gutenberg/build/vendors/react-dom.min.js?ver=6.1-alpha-53362:1:77549) - at Nr (../../http:/localhost:8889/wp-content/plugins/gutenberg/build/vendors/react-dom.min.js?ver=6.1-alpha-53362:1:74544) - at ../../http:/localhost:8889/wp-content/plugins/gutenberg/build/vendors/react-dom.min.js?ver=6.1-alpha-53362:1:30170 - at unstable_runWithPriority (../../http:/localhost:8889/wp-content/plugins/gutenberg/build/vendors/react.min.js?ver=6.1-alpha-53362:1:7430)"] - at runMicrotasks () - -\`\`\` - - -" -`; - exports[`Report flaky tests should report flaky tests to issue on pull request: Updated existing flaky issue 1`] = ` " **Flaky test detected. This is an auto-generated issue by GitHub Actions. Please do NOT edit this manually.** diff --git a/packages/report-flaky-tests/src/__tests__/run.test.ts b/packages/report-flaky-tests/src/__tests__/run.test.ts index c73c3fa2bc113..529dcd41cdcf8 100644 --- a/packages/report-flaky-tests/src/__tests__/run.test.ts +++ b/packages/report-flaky-tests/src/__tests__/run.test.ts @@ -152,14 +152,7 @@ describe( 'Report flaky tests', () => { 'Updated existing flaky issue' ); - expect( mockAPI.createIssue ).toHaveBeenCalledWith( - expect.objectContaining( { - title: `[Flaky Test] ${ newFlakyTest.title }`, - } ) - ); - expect( mockAPI.createIssue.mock.calls[ 0 ][ 0 ].body ).toMatchSnapshot( - 'Created new flaky issue' - ); + expect( mockAPI.createIssue ).not.toHaveBeenCalled(); expect( mockAPI.createCommentOnPR ).toHaveBeenCalledTimes( 1 ); expect( mockAPI.createCommentOnPR.mock.calls[ 0 ][ 0 ] ).toBe( 10 ); @@ -171,8 +164,7 @@ describe( 'Report flaky tests', () => { 🔍 Workflow run URL: https://github.com/WordPress/gutenberg/actions/runs/100 📝 Reported issues: - - #1 in \`/test/e2e/specs/editor/various/copy-cut-paste.spec.js\` - - #2 in \`specs/site-editor/template-part.test.js\`" + - #1 in \`/test/e2e/specs/editor/various/copy-cut-paste.spec.js\`" ` ); } ); diff --git a/packages/report-flaky-tests/src/run.ts b/packages/report-flaky-tests/src/run.ts index 9bfcbf16ce9ae..d68ced5be2403 100644 --- a/packages/report-flaky-tests/src/run.ts +++ b/packages/report-flaky-tests/src/run.ts @@ -45,12 +45,11 @@ async function run() { return; } - const headBranch = - github.context.eventName === 'pull_request' - ? // Cast the payload type: https://github.com/actions/toolkit/tree/main/packages/github#webhook-payload-typescript-definitions - ( github.context.payload as PullRequestEvent ).pull_request.head - .ref - : ref.replace( /^refs\/(heads|tag)\//, '' ); + const isPR = github.context.eventName === 'pull_request'; + const headBranch = isPR + ? // Cast the payload type: https://github.com/actions/toolkit/tree/main/packages/github#webhook-payload-typescript-definitions + ( github.context.payload as PullRequestEvent ).pull_request.head.ref + : ref.replace( /^refs\/(heads|tag)\//, '' ); const label = core.getInput( 'label', { required: true } ); const issues = await api.fetchAllIssuesLabeledFlaky( label ); @@ -138,7 +137,11 @@ async function run() { formattedTestResults, } ), } ); - } else { + } else if ( + ! reportedIssue && + // Don't create a flaky test issue if the test was run inside a PR. + ! isPR + ) { issue = await api.createIssue( { title: issueTitle, body: renderIssueBody( { @@ -151,42 +154,42 @@ async function run() { } ); } - reportedIssues.push( { - testTitle, - testPath, - issueNumber: issue.number, - issueUrl: issue.html_url, - } ); - core.info( `Reported flaky test to ${ issue.html_url }` ); + if ( issue ) { + reportedIssues.push( { + testTitle, + testPath, + issueNumber: issue.number, + issueUrl: issue.html_url, + } ); + core.info( `Reported flaky test to ${ issue.html_url }` ); + } } if ( reportedIssues.length === 0 ) { return; } - const { html_url: commentUrl } = - github.context.eventName === 'pull_request' - ? await api.createCommentOnPR( - // Cast the payload type: https://github.com/actions/toolkit/tree/main/packages/github#webhook-payload-typescript-definitions - ( github.context.payload as PullRequestEvent ).number, - renderCommitComment( { - runURL, - reportedIssues, - commitSHA: ( - github.context.payload as PullRequestEvent - ).pull_request.head.sha, - } ), - isReportComment - ) - : await api.createCommentOnCommit( - github.context.sha, - renderCommitComment( { - runURL, - reportedIssues, - commitSHA: github.context.sha, - } ), - isReportComment - ); + const { html_url: commentUrl } = isPR + ? await api.createCommentOnPR( + // Cast the payload type: https://github.com/actions/toolkit/tree/main/packages/github#webhook-payload-typescript-definitions + ( github.context.payload as PullRequestEvent ).number, + renderCommitComment( { + runURL, + reportedIssues, + commitSHA: ( github.context.payload as PullRequestEvent ) + .pull_request.head.sha, + } ), + isReportComment + ) + : await api.createCommentOnCommit( + github.context.sha, + renderCommitComment( { + runURL, + reportedIssues, + commitSHA: github.context.sha, + } ), + isReportComment + ); core.info( `Reported the summary of the flaky tests to ${ commentUrl }` ); } diff --git a/packages/rich-text/README.md b/packages/rich-text/README.md index 82e749bf8e810..b88d0ffea6b52 100644 --- a/packages/rich-text/README.md +++ b/packages/rich-text/README.md @@ -269,11 +269,13 @@ Check if the selection of a Rich Text value is collapsed or not. Collapsed means _Parameters_ -- _value_ `RichTextValue`: The rich text value to check. +- _props_ `RichTextValue`: The rich text value to check. +- _props.start_ `RichTextValue[ 'start' ]`: +- _props.end_ `RichTextValue[ 'end' ]`: _Returns_ -- `boolean|undefined`: True if the selection is collapsed, false if not, undefined if there is no selection. +- `boolean | undefined`: True if the selection is collapsed, false if not, undefined if there is no selection. ### isEmpty @@ -356,6 +358,10 @@ _Returns_ - `RichTextValue`: A new value with replacements applied. +### RichTextValue + +An object which represents a formatted string. See main `@wordpress/rich-text` documentation for more information. + ### slice Slice a Rich Text value from `startIndex` to `endIndex`. Indices are retrieved from the selection if none are provided. This is similar to `String.prototype.slice`. @@ -433,7 +439,7 @@ _Parameters_ _Returns_ -- `RichTextFormatType|undefined`: The previous format value, if it has been successfully unregistered; otherwise `undefined`. +- `WPFormat|undefined`: The previous format value, if it has been successfully unregistered; otherwise `undefined`. ### useAnchor @@ -443,7 +449,7 @@ _Parameters_ - _$1_ `Object`: Named parameters. - _$1.editableContentElement_ `HTMLElement|null`: The element containing the editable content. -- _$1.settings_ `RichTextFormatType`: The format type's settings. +- _$1.settings_ `WPFormat=`: The format type's settings. _Returns_ @@ -458,7 +464,7 @@ _Parameters_ - _$1_ `Object`: Named parameters. - _$1.ref_ `RefObject`: React ref of the element containing the editable content. - _$1.value_ `RichTextValue`: Value to check for selection. -- _$1.settings_ `RichTextFormatType`: The format type's settings. +- _$1.settings_ `WPFormat`: The format type's settings. _Returns_ diff --git a/packages/rich-text/package.json b/packages/rich-text/package.json index bb45ed339082a..f5bf3cd170dd4 100644 --- a/packages/rich-text/package.json +++ b/packages/rich-text/package.json @@ -28,6 +28,7 @@ "src/**/*.scss", "{src,build,build-module}/{index.js,store/index.js}" ], + "types": "build-types", "dependencies": { "@babel/runtime": "^7.16.0", "@wordpress/a11y": "file:../a11y", diff --git a/packages/rich-text/src/apply-format.js b/packages/rich-text/src/apply-format.js index a0a5c5f451430..c10f09bec852e 100644 --- a/packages/rich-text/src/apply-format.js +++ b/packages/rich-text/src/apply-format.js @@ -4,8 +4,8 @@ import { normaliseFormats } from './normalise-formats'; -/** @typedef {import('./create').RichTextValue} RichTextValue */ -/** @typedef {import('./create').RichTextFormat} RichTextFormat */ +/** @typedef {import('./types').RichTextValue} RichTextValue */ +/** @typedef {import('./types').RichTextFormat} RichTextFormat */ function replace( array, index, value ) { array = array.slice(); diff --git a/packages/rich-text/src/component/use-anchor-ref.js b/packages/rich-text/src/component/use-anchor-ref.js index b8452b874f440..d1de79d04928c 100644 --- a/packages/rich-text/src/component/use-anchor-ref.js +++ b/packages/rich-text/src/component/use-anchor-ref.js @@ -9,9 +9,12 @@ import deprecated from '@wordpress/deprecated'; */ import { getActiveFormat } from '../get-active-format'; -/** @typedef {import('@wordpress/element').RefObject} RefObject */ -/** @typedef {import('../register-format-type').RichTextFormatType} RichTextFormatType */ -/** @typedef {import('../create').RichTextValue} RichTextValue */ +/** + * @template T + * @typedef {import('@wordpress/element').RefObject} RefObject + */ +/** @typedef {import('../register-format-type').WPFormat} WPFormat */ +/** @typedef {import('../types').RichTextValue} RichTextValue */ /** * This hook, to be used in a format type's Edit component, returns the active @@ -23,7 +26,7 @@ import { getActiveFormat } from '../get-active-format'; * @param {RefObject} $1.ref React ref of the element * containing the editable content. * @param {RichTextValue} $1.value Value to check for selection. - * @param {RichTextFormatType} $1.settings The format type's settings. + * @param {WPFormat} $1.settings The format type's settings. * * @return {Element|Range} The active element or selection range. */ diff --git a/packages/rich-text/src/component/use-anchor.js b/packages/rich-text/src/component/use-anchor.js index 8985a540e5464..aa803df7c7666 100644 --- a/packages/rich-text/src/component/use-anchor.js +++ b/packages/rich-text/src/component/use-anchor.js @@ -3,8 +3,8 @@ */ import { useState, useLayoutEffect } from '@wordpress/element'; -/** @typedef {import('../register-format-type').RichTextFormatType} RichTextFormatType */ -/** @typedef {import('../create').RichTextValue} RichTextValue */ +/** @typedef {import('../register-format-type').WPFormat} WPFormat */ +/** @typedef {import('../types').RichTextValue} RichTextValue */ /** * Given a range and a format tag name and class name, returns the closest @@ -50,8 +50,8 @@ function getFormatElement( range, editableContentElement, tagName, className ) { /** * @typedef {Object} VirtualAnchorElement - * @property {Function} getBoundingClientRect A function returning a DOMRect - * @property {Document} ownerDocument The element's ownerDocument + * @property {() => DOMRect} getBoundingClientRect A function returning a DOMRect + * @property {Document} ownerDocument The element's ownerDocument */ /** @@ -117,10 +117,10 @@ function getAnchor( editableContentElement, tagName, className ) { * no format is active. The returned value is meant to be used for positioning * UI, e.g. by passing it to the `Popover` component via the `anchor` prop. * - * @param {Object} $1 Named parameters. - * @param {HTMLElement|null} $1.editableContentElement The element containing - * the editable content. - * @param {RichTextFormatType} $1.settings The format type's settings. + * @param {Object} $1 Named parameters. + * @param {HTMLElement|null} $1.editableContentElement The element containing + * the editable content. + * @param {WPFormat=} $1.settings The format type's settings. * @return {Element|VirtualAnchorElement|undefined|null} The active element or selection range. */ export function useAnchor( { editableContentElement, settings = {} } ) { diff --git a/packages/rich-text/src/concat.js b/packages/rich-text/src/concat.js index e758b6085d657..499015166dfa6 100644 --- a/packages/rich-text/src/concat.js +++ b/packages/rich-text/src/concat.js @@ -5,7 +5,7 @@ import { normaliseFormats } from './normalise-formats'; import { create } from './create'; -/** @typedef {import('./create').RichTextValue} RichTextValue */ +/** @typedef {import('./types').RichTextValue} RichTextValue */ /** * Concats a pair of rich text values. Not that this mutates `a` and does NOT diff --git a/packages/rich-text/src/create.js b/packages/rich-text/src/create.js index 7fdcf8adba762..863e6b984cc79 100644 --- a/packages/rich-text/src/create.js +++ b/packages/rich-text/src/create.js @@ -15,25 +15,7 @@ import { ZWNBSP, } from './special-characters'; -/** - * @typedef {Object} RichTextFormat - * - * @property {string} type Format type. - */ - -/** - * @typedef {Array} RichTextFormatList - */ - -/** - * @typedef {Object} RichTextValue - * - * @property {string} text Text. - * @property {Array} formats Formats. - * @property {Array} replacements Replacements. - * @property {number|undefined} start Selection start. - * @property {number|undefined} end Selection end. - */ +/** @typedef {import('./types').RichTextValue} RichTextValue */ function createEmptyValue() { return { diff --git a/packages/rich-text/src/get-active-format.js b/packages/rich-text/src/get-active-format.js index a259b094272f3..80c6f08ec88d9 100644 --- a/packages/rich-text/src/get-active-format.js +++ b/packages/rich-text/src/get-active-format.js @@ -3,8 +3,8 @@ */ import { getActiveFormats } from './get-active-formats'; -/** @typedef {import('./create').RichTextValue} RichTextValue */ -/** @typedef {import('./create').RichTextFormat} RichTextFormat */ +/** @typedef {import('./types').RichTextValue} RichTextValue */ +/** @typedef {import('./types').RichTextFormat} RichTextFormat */ /** * Gets the format object by type at the start of the selection. This can be diff --git a/packages/rich-text/src/get-active-formats.js b/packages/rich-text/src/get-active-formats.js index 09bbef0cf2d6c..e3bc7d8415de5 100644 --- a/packages/rich-text/src/get-active-formats.js +++ b/packages/rich-text/src/get-active-formats.js @@ -1,5 +1,5 @@ -/** @typedef {import('./create').RichTextValue} RichTextValue */ -/** @typedef {import('./create').RichTextFormatList} RichTextFormatList */ +/** @typedef {import('./types').RichTextValue} RichTextValue */ +/** @typedef {import('./types').RichTextFormatList} RichTextFormatList */ /** * Internal dependencies diff --git a/packages/rich-text/src/get-active-object.js b/packages/rich-text/src/get-active-object.js index 1c1ff4de552c9..a1e5623b65b73 100644 --- a/packages/rich-text/src/get-active-object.js +++ b/packages/rich-text/src/get-active-object.js @@ -4,8 +4,8 @@ import { OBJECT_REPLACEMENT_CHARACTER } from './special-characters'; -/** @typedef {import('./create').RichTextValue} RichTextValue */ -/** @typedef {import('./create').RichTextFormat} RichTextFormat */ +/** @typedef {import('./types').RichTextValue} RichTextValue */ +/** @typedef {import('./types').RichTextFormat} RichTextFormat */ /** * Gets the active object, if there is any. diff --git a/packages/rich-text/src/get-text-content.js b/packages/rich-text/src/get-text-content.js index 17fb064eb36d8..f400e4b56497f 100644 --- a/packages/rich-text/src/get-text-content.js +++ b/packages/rich-text/src/get-text-content.js @@ -6,7 +6,7 @@ import { LINE_SEPARATOR, } from './special-characters'; -/** @typedef {import('./create').RichTextValue} RichTextValue */ +/** @typedef {import('./types').RichTextValue} RichTextValue */ const pattern = new RegExp( `[${ OBJECT_REPLACEMENT_CHARACTER }${ LINE_SEPARATOR }]`, diff --git a/packages/rich-text/src/index.js b/packages/rich-text/src/index.ts similarity index 90% rename from packages/rich-text/src/index.js rename to packages/rich-text/src/index.ts index 33e6e054a68a6..1f59320df3a63 100644 --- a/packages/rich-text/src/index.js +++ b/packages/rich-text/src/index.ts @@ -33,3 +33,9 @@ export { useRichText as __unstableUseRichText, } from './component'; export { default as __unstableFormatEdit } from './component/format-edit'; + +/** + * An object which represents a formatted string. See main `@wordpress/rich-text` + * documentation for more information. + */ +export type { RichTextValue } from './types'; diff --git a/packages/rich-text/src/insert-line-separator.js b/packages/rich-text/src/insert-line-separator.js index cf87f1a184d2c..d7a5aa0f97593 100644 --- a/packages/rich-text/src/insert-line-separator.js +++ b/packages/rich-text/src/insert-line-separator.js @@ -5,7 +5,7 @@ import { insert } from './insert'; import { LINE_SEPARATOR } from './special-characters'; -/** @typedef {import('./create').RichTextValue} RichTextValue */ +/** @typedef {import('./types').RichTextValue} RichTextValue */ /** * Insert a line break character into a Rich Text value at the given diff --git a/packages/rich-text/src/insert-object.js b/packages/rich-text/src/insert-object.js index ef5ad3da00df5..ac8afb25b2010 100644 --- a/packages/rich-text/src/insert-object.js +++ b/packages/rich-text/src/insert-object.js @@ -5,8 +5,8 @@ import { insert } from './insert'; import { OBJECT_REPLACEMENT_CHARACTER } from './special-characters'; -/** @typedef {import('./create').RichTextValue} RichTextValue */ -/** @typedef {import('./create').RichTextFormat} RichTextFormat */ +/** @typedef {import('./types').RichTextValue} RichTextValue */ +/** @typedef {import('./types').RichTextFormat} RichTextFormat */ /** * Insert a format as an object into a Rich Text value at the given diff --git a/packages/rich-text/src/insert.js b/packages/rich-text/src/insert.js index 888e32bdc0dac..e908c676505d3 100644 --- a/packages/rich-text/src/insert.js +++ b/packages/rich-text/src/insert.js @@ -5,7 +5,7 @@ import { create } from './create'; import { normaliseFormats } from './normalise-formats'; -/** @typedef {import('./create').RichTextValue} RichTextValue */ +/** @typedef {import('./types').RichTextValue} RichTextValue */ /** * Insert a Rich Text value, an HTML string, or a plain text string, into a diff --git a/packages/rich-text/src/is-collapsed.js b/packages/rich-text/src/is-collapsed.ts similarity index 51% rename from packages/rich-text/src/is-collapsed.js rename to packages/rich-text/src/is-collapsed.ts index 763ffae3bd0db..fcde801479920 100644 --- a/packages/rich-text/src/is-collapsed.js +++ b/packages/rich-text/src/is-collapsed.ts @@ -1,4 +1,7 @@ -/** @typedef {import('./create').RichTextValue} RichTextValue */ +/** + * Internal dependencies + */ +import type { RichTextValue } from './types'; /** * Check if the selection of a Rich Text value is collapsed or not. Collapsed @@ -6,12 +9,15 @@ * is no selection, `undefined` will be returned. This is similar to * `window.getSelection().isCollapsed()`. * - * @param {RichTextValue} value The rich text value to check. - * - * @return {boolean|undefined} True if the selection is collapsed, false if not, - * undefined if there is no selection. + * @param props The rich text value to check. + * @param props.start + * @param props.end + * @return True if the selection is collapsed, false if not, undefined if there is no selection. */ -export function isCollapsed( { start, end } ) { +export function isCollapsed( { + start, + end, +}: RichTextValue ): boolean | undefined { if ( start === undefined || end === undefined ) { return; } diff --git a/packages/rich-text/src/is-empty.js b/packages/rich-text/src/is-empty.js index 1bc0a3525fce9..7baf296bd2a3d 100644 --- a/packages/rich-text/src/is-empty.js +++ b/packages/rich-text/src/is-empty.js @@ -3,7 +3,7 @@ */ import { LINE_SEPARATOR } from './special-characters'; -/** @typedef {import('./create').RichTextValue} RichTextValue */ +/** @typedef {import('./types').RichTextValue} RichTextValue */ /** * Check if a Rich Text value is Empty, meaning it contains no text or any diff --git a/packages/rich-text/src/is-format-equal.js b/packages/rich-text/src/is-format-equal.js index 1504923f2ba9e..dc6d9653770b7 100644 --- a/packages/rich-text/src/is-format-equal.js +++ b/packages/rich-text/src/is-format-equal.js @@ -1,4 +1,4 @@ -/** @typedef {import('./create').RichTextFormat} RichTextFormat */ +/** @typedef {import('./types').RichTextFormat} RichTextFormat */ /** * Optimised equality check for format objects. diff --git a/packages/rich-text/src/join.js b/packages/rich-text/src/join.js index 3c113623d5c70..805d2528f0c68 100644 --- a/packages/rich-text/src/join.js +++ b/packages/rich-text/src/join.js @@ -5,7 +5,7 @@ import { create } from './create'; import { normaliseFormats } from './normalise-formats'; -/** @typedef {import('./create').RichTextValue} RichTextValue */ +/** @typedef {import('./types').RichTextValue} RichTextValue */ /** * Combine an array of Rich Text values into one, optionally separated by diff --git a/packages/rich-text/src/normalise-formats.js b/packages/rich-text/src/normalise-formats.js index 395aa336c4acc..e73314151849e 100644 --- a/packages/rich-text/src/normalise-formats.js +++ b/packages/rich-text/src/normalise-formats.js @@ -4,7 +4,7 @@ import { isFormatEqual } from './is-format-equal'; -/** @typedef {import('./create').RichTextValue} RichTextValue */ +/** @typedef {import('./types').RichTextValue} RichTextValue */ /** * Normalises formats: ensures subsequent adjacent equal formats have the same diff --git a/packages/rich-text/src/remove-format.js b/packages/rich-text/src/remove-format.js index 794a15de7e17e..831b72e279faa 100644 --- a/packages/rich-text/src/remove-format.js +++ b/packages/rich-text/src/remove-format.js @@ -4,7 +4,7 @@ import { normaliseFormats } from './normalise-formats'; -/** @typedef {import('./create').RichTextValue} RichTextValue */ +/** @typedef {import('./types').RichTextValue} RichTextValue */ /** * Remove any format object from a Rich Text value by type from the given diff --git a/packages/rich-text/src/remove-line-separator.js b/packages/rich-text/src/remove-line-separator.js index 071bf9c524c46..fa45616a45a72 100644 --- a/packages/rich-text/src/remove-line-separator.js +++ b/packages/rich-text/src/remove-line-separator.js @@ -6,7 +6,7 @@ import { LINE_SEPARATOR } from './special-characters'; import { isCollapsed } from './is-collapsed'; import { remove } from './remove'; -/** @typedef {import('./create').RichTextValue} RichTextValue */ +/** @typedef {import('./types').RichTextValue} RichTextValue */ /** * Removes a line separator character, if existing, from a Rich Text value at diff --git a/packages/rich-text/src/remove.js b/packages/rich-text/src/remove.js index 71f981f8b55b1..c8db71949b35c 100644 --- a/packages/rich-text/src/remove.js +++ b/packages/rich-text/src/remove.js @@ -5,7 +5,7 @@ import { insert } from './insert'; import { create } from './create'; -/** @typedef {import('./create').RichTextValue} RichTextValue */ +/** @typedef {import('./types').RichTextValue} RichTextValue */ /** * Remove content from a Rich Text value between the given `startIndex` and diff --git a/packages/rich-text/src/replace.js b/packages/rich-text/src/replace.js index 98579a7bae739..f95b37e925b46 100644 --- a/packages/rich-text/src/replace.js +++ b/packages/rich-text/src/replace.js @@ -4,7 +4,7 @@ import { normaliseFormats } from './normalise-formats'; -/** @typedef {import('./create').RichTextValue} RichTextValue */ +/** @typedef {import('./types').RichTextValue} RichTextValue */ /** * Search a Rich Text value and replace the match(es) with `replacement`. This diff --git a/packages/rich-text/src/slice.js b/packages/rich-text/src/slice.js index bba068835f5af..94b48cbb47c49 100644 --- a/packages/rich-text/src/slice.js +++ b/packages/rich-text/src/slice.js @@ -1,4 +1,4 @@ -/** @typedef {import('./create').RichTextValue} RichTextValue */ +/** @typedef {import('./types').RichTextValue} RichTextValue */ /** * Slice a Rich Text value from `startIndex` to `endIndex`. Indices are diff --git a/packages/rich-text/src/split.js b/packages/rich-text/src/split.js index 08071d17349d7..cf329d7ef0985 100644 --- a/packages/rich-text/src/split.js +++ b/packages/rich-text/src/split.js @@ -4,7 +4,7 @@ import { replace } from './replace'; -/** @typedef {import('./create').RichTextValue} RichTextValue */ +/** @typedef {import('./types').RichTextValue} RichTextValue */ /** * Split a Rich Text value in two at the given `startIndex` and `endIndex`, or diff --git a/packages/rich-text/src/to-dom.js b/packages/rich-text/src/to-dom.js index 4e8a51ae5cb0e..828e3a4e3f6cb 100644 --- a/packages/rich-text/src/to-dom.js +++ b/packages/rich-text/src/to-dom.js @@ -6,7 +6,7 @@ import { toTree } from './to-tree'; import { createElement } from './create-element'; import { isRangeEqual } from './is-range-equal'; -/** @typedef {import('./create').RichTextValue} RichTextValue */ +/** @typedef {import('./types').RichTextValue} RichTextValue */ /** * Creates a path as an array of indices from the given root node to the given diff --git a/packages/rich-text/src/to-html-string.js b/packages/rich-text/src/to-html-string.js index 05a77211db983..0b2689248afb7 100644 --- a/packages/rich-text/src/to-html-string.js +++ b/packages/rich-text/src/to-html-string.js @@ -14,7 +14,7 @@ import { import { toTree } from './to-tree'; -/** @typedef {import('./create').RichTextValue} RichTextValue */ +/** @typedef {import('./types').RichTextValue} RichTextValue */ /** * Create an HTML string from a Rich Text value. If a `multilineTag` is diff --git a/packages/rich-text/src/toggle-format.js b/packages/rich-text/src/toggle-format.js index 747175c3d9d21..11e61a9d3976f 100644 --- a/packages/rich-text/src/toggle-format.js +++ b/packages/rich-text/src/toggle-format.js @@ -13,8 +13,8 @@ import { getActiveFormat } from './get-active-format'; import { removeFormat } from './remove-format'; import { applyFormat } from './apply-format'; -/** @typedef {import('./create').RichTextValue} RichTextValue */ -/** @typedef {import('./create').RichTextFormat} RichTextFormat */ +/** @typedef {import('./types').RichTextValue} RichTextValue */ +/** @typedef {import('./types').RichTextFormat} RichTextFormat */ /** * Toggles a format object to a Rich Text value at the current selection. diff --git a/packages/rich-text/src/types.ts b/packages/rich-text/src/types.ts new file mode 100644 index 0000000000000..1e4725260c27e --- /dev/null +++ b/packages/rich-text/src/types.ts @@ -0,0 +1,31 @@ +/** + * Stores the type of a rich rext format, such as core/bold. + */ +export type RichTextFormat = { + type: + | 'core/bold' + | 'core/italic' + | 'core/link ' + | 'core/strikethrough' + | 'core/image' + | string; +}; + +/** + * A list of rich text format types. + */ +export type RichTextFormatList = Array< RichTextFormat >; + +/** + * An object which represents a formatted string. The text property contains the + * text to be formatted, and the formats property contains an array which indicates + * the formats that are applied to each character in the text. See the main + * `@wordpress/rich-text` documentation for more detail. + */ +export type RichTextValue = { + text: string; + formats: Array< RichTextFormatList >; + replacements: Array< RichTextFormat >; + start: number | undefined; + end: number | undefined; +}; diff --git a/packages/rich-text/src/unregister-format-type.js b/packages/rich-text/src/unregister-format-type.js index 519e33362d5b6..a1616568f148e 100644 --- a/packages/rich-text/src/unregister-format-type.js +++ b/packages/rich-text/src/unregister-format-type.js @@ -8,14 +8,14 @@ import { select, dispatch } from '@wordpress/data'; */ import { store as richTextStore } from './store'; -/** @typedef {import('./register-format-type').RichTextFormatType} RichTextFormatType */ +/** @typedef {import('./register-format-type').WPFormat} WPFormat */ /** * Unregisters a format. * * @param {string} name Format name. * - * @return {RichTextFormatType|undefined} The previous format value, if it has + * @return {WPFormat|undefined} The previous format value, if it has * been successfully unregistered; * otherwise `undefined`. */ diff --git a/packages/rich-text/src/update-formats.js b/packages/rich-text/src/update-formats.js index 0b4fcf98d840b..668e1a73db8df 100644 --- a/packages/rich-text/src/update-formats.js +++ b/packages/rich-text/src/update-formats.js @@ -4,7 +4,7 @@ import { isFormatEqual } from './is-format-equal'; -/** @typedef {import('./create').RichTextValue} RichTextValue */ +/** @typedef {import('./types').RichTextValue} RichTextValue */ /** * Efficiently updates all the formats from `start` (including) until `end` diff --git a/packages/rich-text/tsconfig.json b/packages/rich-text/tsconfig.json new file mode 100644 index 0000000000000..53e2ee579d2cf --- /dev/null +++ b/packages/rich-text/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "declarationDir": "build-types", + "types": [ "gutenberg-env" ], + "checkJs": false + }, + "references": [ + { "path": "../a11y" }, + { "path": "../compose" }, + { "path": "../data" }, + { "path": "../deprecated" }, + { "path": "../element" }, + { "path": "../escape-html" }, + { "path": "../i18n" }, + { "path": "../keycodes" } + ], + "include": [ "src/**/*" ] +} diff --git a/packages/router/.npmrc b/packages/router/.npmrc new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/packages/router/CHANGELOG.md b/packages/router/CHANGELOG.md new file mode 100644 index 0000000000000..e04ce921cdfdc --- /dev/null +++ b/packages/router/CHANGELOG.md @@ -0,0 +1,5 @@ + + +## Unreleased + +Initial release. diff --git a/packages/router/README.md b/packages/router/README.md new file mode 100644 index 0000000000000..9964c31dfe6a8 --- /dev/null +++ b/packages/router/README.md @@ -0,0 +1,31 @@ +# Router + +Router is a generic package that allows to use browser routing in WordPress packages. + +## Installation + +Install the module + +```bash +npm install @wordpress/router --save +``` + +_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for such language features and APIs, you should include [the polyfill shipped in `@wordpress/babel-preset-default`](https://github.com/WordPress/gutenberg/tree/HEAD/packages/babel-preset-default#polyfill) in your code._ + +## API + + + +### privateApis + +Undocumented declaration. + + + +## Contributing to this package + +This is an individual package that's part of the Gutenberg project. The project is organized as a monorepo. It's made up of multiple self-contained software packages, each with a specific purpose. The packages in this monorepo are published to [npm](https://www.npmjs.com/) and used by [WordPress](https://make.wordpress.org/core/) as well as other software projects. + +To find out more about contributing to this package or Gutenberg as a whole, please read the project's main [contributor guide](https://github.com/WordPress/gutenberg/tree/HEAD/CONTRIBUTING.md). + +

Code is Poetry.

diff --git a/packages/router/package.json b/packages/router/package.json new file mode 100644 index 0000000000000..bf3ec89c35d50 --- /dev/null +++ b/packages/router/package.json @@ -0,0 +1,40 @@ +{ + "name": "@wordpress/router", + "version": "0.1.0", + "description": "Router API for WordPress pages.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "gutenberg", + "router" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/HEAD/packages/router/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/router" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "engines": { + "node": ">=12" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "dependencies": { + "@babel/runtime": "^7.16.0", + "@wordpress/element": "file:../element", + "@wordpress/private-apis": "file:../private-apis", + "@wordpress/url": "file:../url", + "history": "^5.1.0" + }, + "peerDependencies": { + "react": "^18.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/edit-site/src/utils/history.js b/packages/router/src/history.js similarity index 100% rename from packages/edit-site/src/utils/history.js rename to packages/router/src/history.js diff --git a/packages/router/src/index.js b/packages/router/src/index.js new file mode 100644 index 0000000000000..94878a556278a --- /dev/null +++ b/packages/router/src/index.js @@ -0,0 +1 @@ +export { privateApis } from './private-apis'; diff --git a/packages/router/src/private-apis.js b/packages/router/src/private-apis.js new file mode 100644 index 0000000000000..e3c465502369b --- /dev/null +++ b/packages/router/src/private-apis.js @@ -0,0 +1,22 @@ +/** + * WordPress dependencies + */ +import { __dangerousOptInToUnstableAPIsOnlyForCoreModules } from '@wordpress/private-apis'; + +/** + * Internal dependencies + */ +import { useHistory, useLocation, RouterProvider } from './router'; + +export const { lock, unlock } = + __dangerousOptInToUnstableAPIsOnlyForCoreModules( + 'I know using unstable features means my plugin or theme will inevitably break on the next WordPress release.', + '@wordpress/router' + ); + +export const privateApis = {}; +lock( privateApis, { + useHistory, + useLocation, + RouterProvider, +} ); diff --git a/packages/edit-site/src/components/routes/index.js b/packages/router/src/router.js similarity index 92% rename from packages/edit-site/src/components/routes/index.js rename to packages/router/src/router.js index 7ef19280d4443..e5449cef54c6a 100644 --- a/packages/edit-site/src/components/routes/index.js +++ b/packages/router/src/router.js @@ -11,7 +11,7 @@ import { /** * Internal dependencies */ -import history from '../../utils/history'; +import history from './history'; const RoutesContext = createContext(); const HistoryContext = createContext(); @@ -32,7 +32,7 @@ function getLocationWithParams( location ) { }; } -export function Routes( { children } ) { +export function RouterProvider( { children } ) { const [ location, setLocation ] = useState( () => getLocationWithParams( history.location ) ); diff --git a/packages/style-engine/class-wp-style-engine-css-declarations.php b/packages/style-engine/class-wp-style-engine-css-declarations.php index 6e7fdfc58e08f..9ed7529052325 100644 --- a/packages/style-engine/class-wp-style-engine-css-declarations.php +++ b/packages/style-engine/class-wp-style-engine-css-declarations.php @@ -17,22 +17,6 @@ * @access private */ class WP_Style_Engine_CSS_Declarations { - /** - * An array of valid CSS custom properties. - * CSS custom properties are permitted by safecss_filter_attr() - * since WordPress 6.1. See: https://core.trac.wordpress.org/ticket/56353. - * - * This whitelist exists so that the Gutenberg plugin maintains - * backwards compatibility with versions of WordPress < 6.1. - * - * It does not need to be backported to future versions of WordPress. - * - * @var array - */ - protected static $valid_custom_declarations = array( - '--wp--style--unstable-gallery-gap' => 'gap', - ); - /** * An array of CSS declarations (property => value pairs). * @@ -141,22 +125,6 @@ public function get_declarations() { protected static function filter_declaration( $property, $value, $spacer = '' ) { $filtered_value = wp_strip_all_tags( $value, true ); - /** - * Allows a specific list of CSS custom properties starting with `--wp--`. - * - * CSS custom properties are permitted by safecss_filter_attr() - * since WordPress 6.1. See: https://core.trac.wordpress.org/ticket/56353. - * - * This condition exists so that the Gutenberg plugin maintains - * backwards compatibility with versions of WordPress < 6.1. - * - * It does not need to be backported to future versions of WordPress. - */ - if ( '' !== $filtered_value && isset( static::$valid_custom_declarations[ $property ] ) ) { - return safecss_filter_attr( static::$valid_custom_declarations[ $property ] . ":{$spacer}{$value}" ) ? - "{$property}:{$spacer}{$value}" : ''; - } - if ( '' !== $filtered_value ) { return safecss_filter_attr( "{$property}:{$spacer}{$filtered_value}" ); } diff --git a/phpunit/block-supports/typography-test.php b/phpunit/block-supports/typography-test.php index 99376bf23adfc..6fd81bcbeca6c 100644 --- a/phpunit/block-supports/typography-test.php +++ b/phpunit/block-supports/typography-test.php @@ -655,7 +655,7 @@ public function data_generate_block_supports_font_size_fixtures() { 'returns clamp value using custom fluid config' => array( 'font_size_value' => '17px', 'theme_slug' => 'block-theme-child-with-fluid-typography-config', - 'expected_output' => 'font-size:clamp(16px, 1rem + ((1vw - 3.2px) * 0.078), 17px);', + 'expected_output' => 'font-size:clamp(16px, 1rem + ((1vw - 3.2px) * 0.147), 17px);', ), 'returns value when font size <= custom min font size bound' => array( 'font_size_value' => '15px', diff --git a/phpunit/class-gutenberg-rest-global-styles-revisions-controller-test.php b/phpunit/class-gutenberg-rest-global-styles-revisions-controller-test.php index e74b1c2dbe7de..6856638bf8754 100644 --- a/phpunit/class-gutenberg-rest-global-styles-revisions-controller-test.php +++ b/phpunit/class-gutenberg-rest-global-styles-revisions-controller-test.php @@ -6,6 +6,16 @@ class Gutenberg_REST_Global_Styles_Revisions_Controller_Test extends WP_Test_RES */ protected static $admin_id; + /** + * @var int + */ + protected static $second_admin_id; + + /** + * @var int + */ + protected static $author_id; + /** * @var int */ @@ -22,11 +32,21 @@ public function set_up() { * @param WP_UnitTest_Factory $factory Helper that lets us create fake data. */ public static function wpSetupBeforeClass( $factory ) { - self::$admin_id = $factory->user->create( + self::$admin_id = $factory->user->create( array( 'role' => 'administrator', ) ); + self::$second_admin_id = $factory->user->create( + array( + 'role' => 'administrator', + ) + ); + self::$author_id = $factory->user->create( + array( + 'role' => 'author', + ) + ); // This creates the global styles for the current theme. self::$global_styles_id = wp_insert_post( array( @@ -75,8 +95,8 @@ public function test_get_items() { 'post_content' => wp_json_encode( $config ), ); - $post_id = wp_update_post( $new_styles_post, true, false ); - $post = get_post( $post_id ); + wp_update_post( $new_styles_post, true, false ); + $request = new WP_REST_Request( 'GET', '/wp/v2/global-styles/' . self::$global_styles_id . '/revisions' ); $response = rest_get_server()->dispatch( $request ); $data = $response->get_data(); @@ -88,18 +108,12 @@ public function test_get_items() { // Dates. $this->assertArrayHasKey( 'date', $data[0], 'Check that an date key exists' ); $this->assertArrayHasKey( 'date_gmt', $data[0], 'Check that an date_gmt key exists' ); - $this->assertArrayHasKey( 'date_display', $data[0], 'Check that an date_display key exists' ); $this->assertArrayHasKey( 'modified', $data[0], 'Check that an modified key exists' ); $this->assertArrayHasKey( 'modified_gmt', $data[0], 'Check that an modified_gmt key exists' ); $this->assertArrayHasKey( 'modified_gmt', $data[0], 'Check that an modified_gmt key exists' ); // Author information. - $this->assertEquals( $post->post_author, $data[0]['author'], 'Check that author id returns expected value' ); - $this->assertEquals( get_the_author_meta( 'display_name', $post->post_author ), $data[0]['author_display_name'], 'Check that author display_name returns expected value' ); - $this->assertIsString( - $data[0]['author_avatar_url'], - 'Check that author avatar_url returns expected value type' - ); + $this->assertEquals( self::$admin_id, $data[0]['author'], 'Check that author id returns expected value' ); // Global styles. $this->assertEquals( @@ -116,6 +130,24 @@ public function test_get_items() { ), 'Check that the revision styles match the last updated styles.' ); + + // Checks that the revisions are returned for all eligible users. + wp_set_current_user( self::$second_admin_id ); + $config['styles']['color']['background'] = 'blue'; + $new_styles_post = array( + 'ID' => self::$global_styles_id, + 'post_content' => wp_json_encode( $config ), + ); + + wp_update_post( $new_styles_post, true, false ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/global-styles/' . self::$global_styles_id . '/revisions' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertCount( 2, $data, 'Check that two revisions exists' ); + $this->assertEquals( self::$second_admin_id, $data[0]['author'], 'Check that second author id returns expected value' ); + $this->assertEquals( self::$admin_id, $data[1]['author'], 'Check that second author id returns expected value' ); } /** @@ -126,21 +158,29 @@ public function test_get_item_schema() { $response = rest_get_server()->dispatch( $request ); $data = $response->get_data(); $properties = $data['schema']['properties']; - $this->assertCount( 12, $properties, 'Schema properties array does not have exactly 4 elements' ); + $this->assertCount( 9, $properties, 'Schema properties array does not have exactly 9 elements' ); $this->assertArrayHasKey( 'id', $properties, 'Schema properties array does not have "id" key' ); $this->assertArrayHasKey( 'styles', $properties, 'Schema properties array does not have "styles" key' ); $this->assertArrayHasKey( 'settings', $properties, 'Schema properties array does not have "settings" key' ); $this->assertArrayHasKey( 'parent', $properties, 'Schema properties array does not have "parent" key' ); $this->assertArrayHasKey( 'author', $properties, 'Schema properties array does not have "author" key' ); - $this->assertArrayHasKey( 'author_display_name', $properties, 'Schema properties array does not have "author_display_name" key' ); - $this->assertArrayHasKey( 'author_avatar_url', $properties, 'Schema properties array does not have "author_avatar_url" key' ); $this->assertArrayHasKey( 'date', $properties, 'Schema properties array does not have "date" key' ); $this->assertArrayHasKey( 'date_gmt', $properties, 'Schema properties array does not have "date_gmt" key' ); - $this->assertArrayHasKey( 'date_display', $properties, 'Schema properties array does not have "date_display" key' ); $this->assertArrayHasKey( 'modified', $properties, 'Schema properties array does not have "modified" key' ); $this->assertArrayHasKey( 'modified_gmt', $properties, 'Schema properties array does not have "modified_gmt" key' ); } + /** + * @covers Gutenberg_REST_Global_Styles_Revisions_Controller::get_item_permissions_check + */ + public function test_get_item_permissions_check() { + wp_set_current_user( self::$author_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/global-styles/' . self::$global_styles_id . '/revisions' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_cannot_view', $response, 403 ); + } + /** * @doesNotPerformAssertions */ diff --git a/phpunit/class-gutenberg-rest-templates-controller-test.php b/phpunit/class-gutenberg-rest-templates-controller-test.php index d8447704bd373..e86f9db5d848e 100644 --- a/phpunit/class-gutenberg-rest-templates-controller-test.php +++ b/phpunit/class-gutenberg-rest-templates-controller-test.php @@ -62,35 +62,43 @@ public function test_get_template_fallback() { $this->assertSame( 'index', $response->get_data()['slug'], 'Should fallback to `index.html`.' ); } - public function test_context_param() { - $this->markTestIncomplete(); - } + /** + * @doesNotPerformAssertions + */ + public function test_context_param() {} - public function test_get_items() { - $this->markTestIncomplete(); - } + /** + * @doesNotPerformAssertions + */ + public function test_get_items() {} - public function test_get_item() { - $this->markTestIncomplete(); - } + /** + * @doesNotPerformAssertions + */ + public function test_get_item() {} - public function test_create_item() { - $this->markTestIncomplete(); - } + /** + * @doesNotPerformAssertions + */ + public function test_create_item() {} - public function test_update_item() { - $this->markTestIncomplete(); - } + /** + * @doesNotPerformAssertions + */ + public function test_update_item() {} - public function test_delete_item() { - $this->markTestIncomplete(); - } + /** + * @doesNotPerformAssertions + */ + public function test_delete_item() {} - public function test_prepare_item() { - $this->markTestIncomplete(); - } + /** + * @doesNotPerformAssertions + */ + public function test_prepare_item() {} - public function test_get_item_schema() { - $this->markTestIncomplete(); - } + /** + * @doesNotPerformAssertions + */ + public function test_get_item_schema() {} } diff --git a/phpunit/class-override-script-test.php b/phpunit/class-override-script-test.php index b3af1acd80f44..0bc3bc5853ae1 100644 --- a/phpunit/class-override-script-test.php +++ b/phpunit/class-override-script-test.php @@ -40,7 +40,7 @@ public function test_localizes_script() { ); $script = $wp_scripts->query( 'gutenberg-dummy-script', 'registered' ); - $this->assertEquals( array( 'dependency', 'wp-i18n' ), $script->deps ); + $this->assertEquals( array( 'dependency' ), $script->deps ); } /** @@ -60,7 +60,7 @@ public function test_replaces_registered_properties() { $script = $wp_scripts->query( 'gutenberg-dummy-script', 'registered' ); $this->assertEquals( 'https://example.com/updated', $script->src ); - $this->assertEquals( array( 'updated-dependency', 'wp-i18n' ), $script->deps ); + $this->assertEquals( array( 'updated-dependency' ), $script->deps ); $this->assertEquals( 'updated-version', $script->ver ); $this->assertSame( 1, $script->args ); } @@ -82,7 +82,7 @@ public function test_registers_new_script() { $script = $wp_scripts->query( 'gutenberg-second-dummy-script', 'registered' ); $this->assertEquals( 'https://example.com/', $script->src ); - $this->assertEquals( array( 'dependency', 'wp-i18n' ), $script->deps ); + $this->assertEquals( array( 'dependency' ), $script->deps ); $this->assertEquals( 'version', $script->ver ); $this->assertSame( 1, $script->args ); } diff --git a/phpunit/class-wp-rest-block-pattern-categories-controller-test.php b/phpunit/class-wp-rest-block-pattern-categories-controller-test.php index bdbaa819ba3f8..d4fb4e1b08842 100644 --- a/phpunit/class-wp-rest-block-pattern-categories-controller-test.php +++ b/phpunit/class-wp-rest-block-pattern-categories-controller-test.php @@ -80,32 +80,38 @@ public function test_get_items() { /** * Abstract methods that we must implement. + * + * @doesNotPerformAssertions */ - public function test_context_param() { - $this->markTestIncomplete(); - } + public function test_context_param() {} - public function test_get_item() { - $this->markTestIncomplete(); - } + /** + * @doesNotPerformAssertions + */ + public function test_get_item() {} - public function test_create_item() { - $this->markTestIncomplete(); - } + /** + * @doesNotPerformAssertions + */ + public function test_create_item() {} - public function test_update_item() { - $this->markTestIncomplete(); - } + /** + * @doesNotPerformAssertions + */ + public function test_update_item() {} - public function test_delete_item() { - $this->markTestIncomplete(); - } + /** + * @doesNotPerformAssertions + */ + public function test_delete_item() {} - public function test_prepare_item() { - $this->markTestIncomplete(); - } + /** + * @doesNotPerformAssertions + */ + public function test_prepare_item() {} - public function test_get_item_schema() { - $this->markTestIncomplete(); - } + /** + * @doesNotPerformAssertions + */ + public function test_get_item_schema() {} } diff --git a/phpunit/class-wp-test-rest-users-controller.php b/phpunit/class-wp-test-rest-users-controller.php index 59c7c1ca3f22a..522c4d763c563 100644 --- a/phpunit/class-wp-test-rest-users-controller.php +++ b/phpunit/class-wp-test-rest-users-controller.php @@ -156,34 +156,50 @@ public function set_up() { /** * The following methods are implemented in core and tested. * We need to define them here because they exist in the abstract parent. + * + * @doesNotPerformAssertions */ - public function test_register_routes() { - $this->markTestIncomplete(); - } - public function test_context_param() { - $this->markTestIncomplete(); - } - public function test_get_item() { - $this->markTestIncomplete(); - } - public function test_prepare_item() { - $this->markTestIncomplete(); - } - public function test_create_item() { - $this->markTestIncomplete(); - } - public function test_update_item() { - $this->markTestIncomplete(); - } - public function test_delete_item() { - $this->markTestIncomplete(); - } - public function test_get_items() { - $this->markTestIncomplete(); - } - public function test_get_item_schema() { - $this->markTestIncomplete(); - } + public function test_register_routes() {} + + /** + * @doesNotPerformAssertions + */ + public function test_context_param() {} + + /** + * @doesNotPerformAssertions + */ + public function test_get_item() {} + + /** + * @doesNotPerformAssertions + */ + public function test_prepare_item() {} + + /** + * @doesNotPerformAssertions + */ + public function test_create_item() {} + + /** + * @doesNotPerformAssertions + */ + public function test_update_item() {} + + /** + * @doesNotPerformAssertions + */ + public function test_delete_item() {} + + /** + * @doesNotPerformAssertions + */ + public function test_get_items() {} + + /** + * @doesNotPerformAssertions + */ + public function test_get_item_schema() {} public function test_registered_query_params() { $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/users' ); diff --git a/phpunit/class-wp-theme-json-test.php b/phpunit/class-wp-theme-json-test.php index d7a28e2d6e238..7d63f62a2c1f0 100644 --- a/phpunit/class-wp-theme-json-test.php +++ b/phpunit/class-wp-theme-json-test.php @@ -2071,4 +2071,69 @@ public function data_process_blocks_custom_css() { ), ); } + + public function test_internal_syntax_is_converted_to_css_variables() { + $result = new WP_Theme_JSON_Gutenberg( + array( + 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, + 'styles' => array( + 'color' => array( + 'background' => 'var:preset|color|primary', + 'text' => 'var(--wp--preset--color--secondary)', + ), + 'elements' => array( + 'link' => array( + 'color' => array( + 'background' => 'var:preset|color|pri', + 'text' => 'var(--wp--preset--color--sec)', + ), + ), + ), + 'blocks' => array( + 'core/post-terms' => array( + 'typography' => array( 'fontSize' => 'var(--wp--preset--font-size--small)' ), + 'color' => array( 'background' => 'var:preset|color|secondary' ), + ), + 'core/navigation' => array( + 'elements' => array( + 'link' => array( + 'color' => array( + 'background' => 'var:preset|color|p', + 'text' => 'var(--wp--preset--color--s)', + ), + ), + ), + ), + 'core/quote' => array( + 'typography' => array( 'fontSize' => 'var(--wp--preset--font-size--d)' ), + 'color' => array( 'background' => 'var:preset|color|d' ), + 'variations' => array( + 'plain' => array( + 'typography' => array( 'fontSize' => 'var(--wp--preset--font-size--s)' ), + 'color' => array( 'background' => 'var:preset|color|s' ), + ), + ), + ), + ), + ), + ) + ); + $styles = $result->get_raw_data()['styles']; + + $this->assertEquals( 'var(--wp--preset--color--primary)', $styles['color']['background'], 'Top level: Assert the originally correct values are still correct.' ); + $this->assertEquals( 'var(--wp--preset--color--secondary)', $styles['color']['text'], 'Top level: Assert the originally correct values are still correct.' ); + + $this->assertEquals( 'var(--wp--preset--color--pri)', $styles['elements']['link']['color']['background'], 'Element top level: Assert the originally correct values are still correct.' ); + $this->assertEquals( 'var(--wp--preset--color--sec)', $styles['elements']['link']['color']['text'], 'Element top level: Assert the originally correct values are still correct.' ); + + $this->assertEquals( 'var(--wp--preset--font-size--small)', $styles['blocks']['core/post-terms']['typography']['fontSize'], 'Top block level: Assert the originally correct values are still correct.' ); + $this->assertEquals( 'var(--wp--preset--color--secondary)', $styles['blocks']['core/post-terms']['color']['background'], 'Top block level: Assert the internal variables are convert to CSS custom variables.' ); + + $this->assertEquals( 'var(--wp--preset--color--p)', $styles['blocks']['core/navigation']['elements']['link']['color']['background'], 'Elements block level: Assert the originally correct values are still correct.' ); + $this->assertEquals( 'var(--wp--preset--color--s)', $styles['blocks']['core/navigation']['elements']['link']['color']['text'], 'Elements block level: Assert the originally correct values are still correct.' ); + + $this->assertEquals( 'var(--wp--preset--font-size--s)', $styles['blocks']['core/quote']['variations']['plain']['typography']['fontSize'], 'Style variations: Assert the originally correct values are still correct.' ); + $this->assertEquals( 'var(--wp--preset--color--s)', $styles['blocks']['core/quote']['variations']['plain']['color']['background'], 'Style variations: Assert the internal variables are convert to CSS custom variables.' ); + + } } diff --git a/phpunit/data/themedir1/block-theme-child-with-fluid-typography-config/theme.json b/phpunit/data/themedir1/block-theme-child-with-fluid-typography-config/theme.json index d0ec32d9caac0..73864f2920039 100644 --- a/phpunit/data/themedir1/block-theme-child-with-fluid-typography-config/theme.json +++ b/phpunit/data/themedir1/block-theme-child-with-fluid-typography-config/theme.json @@ -2,6 +2,9 @@ "version": 2, "settings": { "appearanceTools": true, + "layout": { + "wideSize": "1000px" + }, "typography": { "fluid": { "minFontSize": "16px" diff --git a/phpunit/fonts-api/bc-layer/bc-layer-tests-dataset.php b/phpunit/fonts-api/bc-layer/bc-layer-tests-dataset.php new file mode 100644 index 0000000000000..6366aaec7df73 --- /dev/null +++ b/phpunit/fonts-api/bc-layer/bc-layer-tests-dataset.php @@ -0,0 +1,483 @@ + array( + 'fonts' => array( + array( + 'provider' => 'local', + 'font-family' => 'Merriweather', + 'font-style' => 'normal', + 'font-weight' => '200 900', + 'font-display' => 'fallback', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/merriweather.ttf.woff2', + ), + ), + 'expected' => array( + 'migration' => array( + 'Merriweather' => array( + array( + 'provider' => 'local', + 'font-family' => 'Merriweather', + 'font-style' => 'normal', + 'font-weight' => '200 900', + 'font-display' => 'fallback', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/merriweather.ttf.woff2', + ), + ), + ), + 'wp_register_webfonts' => array( 'merriweather' ), + 'get_registered' => array( 'merriweather', 'merriweather-200-900-normal' ), + ), + ), + '2 font in same font family' => array( + 'fonts' => array( + array( + 'provider' => 'local', + 'font-family' => 'Source Serif Pro', + 'font-style' => 'normal', + 'font-weight' => '300', + 'font-display' => 'fallback', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2', + 'font-display' => 'fallback', + ), + array( + 'provider' => 'local', + 'font-family' => 'Source Serif Pro', + 'font-style' => 'italic', + 'font-weight' => '900', + 'font-display' => 'fallback', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Italic.ttf.woff2', + 'font-display' => 'fallback', + ), + ), + 'expected' => array( + 'migration' => array( + 'Source Serif Pro' => array( + array( + 'provider' => 'local', + 'font-family' => 'Source Serif Pro', + 'font-style' => 'normal', + 'font-weight' => '300', + 'font-display' => 'fallback', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2', + 'font-display' => 'fallback', + ), + array( + 'provider' => 'local', + 'font-family' => 'Source Serif Pro', + 'font-style' => 'italic', + 'font-weight' => '900', + 'font-display' => 'fallback', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Italic.ttf.woff2', + 'font-display' => 'fallback', + ), + ), + ), + 'wp_register_webfonts' => array( 'source-serif-pro' ), + 'get_registered' => array( + 'source-serif-pro', + 'source-serif-pro-300-normal', + 'source-serif-pro-900-italic', + ), + ), + ), + 'Fonts in different font families' => array( + 'fonts' => array( + array( + 'provider' => 'local', + 'font-family' => 'Source Serif Pro', + 'font-style' => 'normal', + 'font-weight' => '300', + 'font-display' => 'fallback', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2', + 'font-display' => 'fallback', + ), + array( + 'provider' => 'local', + 'font-family' => 'Merriweather', + 'font-style' => 'normal', + 'font-weight' => '200 900', + 'font-display' => 'fallback', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/merriweather.ttf.woff2', + ), + array( + 'provider' => 'local', + 'font-family' => 'Source Serif Pro', + 'font-style' => 'italic', + 'font-weight' => '900', + 'font-display' => 'fallback', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Italic.ttf.woff2', + 'font-display' => 'fallback', + ), + ), + 'expected' => array( + 'migration' => array( + 'Source Serif Pro' => array( + array( + 'provider' => 'local', + 'font-family' => 'Source Serif Pro', + 'font-style' => 'normal', + 'font-weight' => '300', + 'font-display' => 'fallback', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2', + 'font-display' => 'fallback', + ), + array( + 'provider' => 'local', + 'font-family' => 'Source Serif Pro', + 'font-style' => 'italic', + 'font-weight' => '900', + 'font-display' => 'fallback', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Italic.ttf.woff2', + 'font-display' => 'fallback', + ), + ), + 'Merriweather' => array( + array( + 'provider' => 'local', + 'font-family' => 'Merriweather', + 'font-style' => 'normal', + 'font-weight' => '200 900', + 'font-display' => 'fallback', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/merriweather.ttf.woff2', + ), + ), + ), + 'wp_register_webfonts' => array( 'source-serif-pro', 'merriweather' ), + 'get_registered' => array( + 'source-serif-pro', + 'source-serif-pro-300-normal', + 'source-serif-pro-900-italic', + 'merriweather', + 'merriweather-200-900-normal', + ), + ), + ), + ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_not_deprecated_structure() { + return array( + '1 font' => array( + array( + 'Merriweather' => array( + array( + 'provider' => 'local', + 'font-family' => 'Merriweather', + 'font-style' => 'normal', + 'font-weight' => '200 900', + 'font-display' => 'fallback', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/merriweather.ttf.woff2', + ), + ), + ), + ), + '2 font in same font family' => array( + array( + 'Source Serif Pro' => array( + array( + 'provider' => 'local', + 'font-family' => 'Source Serif Pro', + 'font-style' => 'normal', + 'font-weight' => '300', + 'font-display' => 'fallback', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2', + 'font-display' => 'fallback', + ), + array( + 'provider' => 'local', + 'font-family' => 'Source Serif Pro', + 'font-style' => 'italic', + 'font-weight' => '900', + 'font-display' => 'fallback', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Italic.ttf.woff2', + 'font-display' => 'fallback', + ), + ), + ), + ), + 'Fonts in different font families' => array( + array( + 'source-serif-pro' => array( + array( + 'provider' => 'local', + 'font-family' => 'Source Serif Pro', + 'font-style' => 'normal', + 'font-weight' => '300', + 'font-display' => 'fallback', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2', + 'font-display' => 'fallback', + ), + array( + 'provider' => 'local', + 'font-family' => 'Source Serif Pro', + 'font-style' => 'italic', + 'font-weight' => '900', + 'font-display' => 'fallback', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Italic.ttf.woff2', + 'font-display' => 'fallback', + ), + ), + 'merriweather' => array( + array( + 'provider' => 'local', + 'font-family' => 'Merriweather', + 'font-style' => 'normal', + 'font-weight' => '200 900', + 'font-display' => 'fallback', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/merriweather.ttf.woff2', + ), + ), + ), + ), + ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_deprecated_structure_with_invalid_font_family() { + return array( + 'non-string' => array( + 'fonts' => array( + array( + 'provider' => 'local', + 'font-family' => null, + 'font-style' => 'normal', + 'font-weight' => '200 900', + 'font-display' => 'fallback', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/merriweather.ttf.woff2', + ), + ), + 'expected_message' => 'Font family not defined in the variation.', + ), + 'empty string in deprecated structure' => array( + 'fonts' => array( + '0' => array( + 'provider' => 'local', + 'font-style' => 'normal', + 'font-weight' => '300', + 'font-display' => 'fallback', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2', + 'font-display' => 'fallback', + ), + ), + 'expected_message' => 'Font family not found.', + ), + 'incorrect parameter in deprecated structure' => array( + 'fonts' => array( + array( + 'provider' => 'local', + 'FontFamily' => 'Source Serif Pro', + 'font-style' => 'normal', + 'font-weight' => '300', + 'font-display' => 'fallback', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2', + 'font-display' => 'fallback', + ), + array( + 'provider' => 'local', + 'font_family' => 'Merriweather', + 'font-style' => 'normal', + 'font-weight' => '200 900', + 'font-display' => 'fallback', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/merriweather.ttf.woff2', + ), + array( + 'provider' => 'local', + 'font family' => 'Source Serif Pro', + 'font-style' => 'italic', + 'font-weight' => '900', + 'font-display' => 'fallback', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Italic.ttf.woff2', + 'font-display' => 'fallback', + ), + ), + 'expected_message' => 'Font family not found.', + ), + ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_should_return_registered_webfonts() { + return array( + 'Single variation' => array( + 'fonts' => array( + 'Merriweather' => array( + array( + 'font-family' => 'Merriweather', + 'font-style' => 'normal', + 'font-weight' => '400', + 'src' => 'https://example.com/assets/fonts/merriweather.ttf.woff2', + ), + ), + ), + 'expected' => array( + 'merriweather' => array( + 'merriweather-400-normal' => array( + 'provider' => 'local', + 'font-family' => 'Merriweather', + 'font-style' => 'normal', + 'font-weight' => '400', + 'font-display' => 'fallback', + 'src' => 'https://example.com/assets/fonts/merriweather.ttf.woff2', + ), + ), + ), + ), + '2 variations' => array( + 'fonts' => array( + 'Source Serif Pro' => array( + 'source-serif-pro-200-900-normal' => array( + 'font-family' => 'Source Serif Pro', + 'font-style' => 'normal', + 'font-weight' => '200 900', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2', + ), + 'source-serif-pro-200-900-italic' => array( + 'font-family' => 'Source Serif Pro', + 'font-style' => 'italic', + 'font-weight' => '200 900', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Italic.ttf.woff2', + ), + ), + ), + 'expected' => array( + 'source-serif-pro' => array( + 'source-serif-pro-200-900-normal' => array( + 'provider' => 'local', + 'font-family' => 'Source Serif Pro', + 'font-style' => 'normal', + 'font-weight' => '200 900', + 'font-display' => 'fallback', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2', + ), + 'source-serif-pro-200-900-italic' => array( + 'provider' => 'local', + 'font-family' => 'Source Serif Pro', + 'font-style' => 'italic', + 'font-weight' => '200 900', + 'font-display' => 'fallback', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Italic.ttf.woff2', + ), + ), + ), + ), + 'Multiple font families' => array( + 'fonts' => array( + 'Merriweather' => array( + array( + 'font-family' => 'Merriweather', + 'font-style' => 'normal', + 'font-weight' => '400', + 'src' => 'https://example.com/assets/fonts/merriweather.ttf.woff2', + ), + ), + 'Source Serif Pro' => array( + 'source-serif-pro-200-900-normal' => array( + 'font-family' => 'Source Serif Pro', + 'font-style' => 'normal', + 'font-weight' => '200 900', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2', + ), + 'source-serif-pro-200-900-italic' => array( + 'font-family' => 'Source Serif Pro', + 'font-style' => 'italic', + 'font-weight' => '200 900', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Italic.ttf.woff2', + ), + ), + ), + 'expected' => array( + 'merriweather' => array( + 'merriweather-400-normal' => array( + 'provider' => 'local', + 'font-family' => 'Merriweather', + 'font-style' => 'normal', + 'font-weight' => '400', + 'font-display' => 'fallback', + 'src' => 'https://example.com/assets/fonts/merriweather.ttf.woff2', + ), + ), + 'source-serif-pro' => array( + 'source-serif-pro-200-900-normal' => array( + 'provider' => 'local', + 'font-family' => 'Source Serif Pro', + 'font-style' => 'normal', + 'font-weight' => '200 900', + 'font-display' => 'fallback', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2', + ), + 'source-serif-pro-200-900-italic' => array( + 'provider' => 'local', + 'font-family' => 'Source Serif Pro', + 'font-style' => 'italic', + 'font-weight' => '200 900', + 'font-display' => 'fallback', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Italic.ttf.woff2', + ), + ), + ), + ), + ); + } +} diff --git a/phpunit/fonts-api/bc-layer/fonts-bc-layer-testcase.php b/phpunit/fonts-api/bc-layer/fonts-bc-layer-testcase.php new file mode 100644 index 0000000000000..6c5e3bad800b8 --- /dev/null +++ b/phpunit/fonts-api/bc-layer/fonts-bc-layer-testcase.php @@ -0,0 +1,46 @@ +old_wp_webfonts = isset( $GLOBALS['wp_webfonts'] ) ? $GLOBALS['wp_webfonts'] : null; + $GLOBALS['wp_webfonts'] = null; + } + + public function tear_down() { + $GLOBALS['wp_webfonts'] = $this->old_wp_webfonts; + + parent::tear_down(); + } + + protected function set_up_webfonts_mock( $method ) { + $mock = $this->setup_object_mock( $method, WP_Webfonts::class ); + + // Set the global. + $GLOBALS['wp_webfonts'] = $mock; + + return $mock; + } +} diff --git a/phpunit/fonts-api/bc-layer/gutenbergFontsApiBcLayer/isDeprecatedStructure-test.php b/phpunit/fonts-api/bc-layer/gutenbergFontsApiBcLayer/isDeprecatedStructure-test.php new file mode 100644 index 0000000000000..e623ecf19f7d1 --- /dev/null +++ b/phpunit/fonts-api/bc-layer/gutenbergFontsApiBcLayer/isDeprecatedStructure-test.php @@ -0,0 +1,35 @@ +assertTrue( Gutenberg_Fonts_API_BC_Layer::is_deprecated_structure( $fonts ) ); + } + + /** + * @dataProvider data_not_deprecated_structure + * + * @param array $fonts Fonts to test. + */ + public function test_should_not_detect_deprecated_structure( array $fonts ) { + $this->assertFalse( Gutenberg_Fonts_API_BC_Layer::is_deprecated_structure( $fonts ) ); + } +} diff --git a/phpunit/fonts-api/bc-layer/gutenbergFontsApiBcLayer/migrateDeprecatedStructure-test.php b/phpunit/fonts-api/bc-layer/gutenbergFontsApiBcLayer/migrateDeprecatedStructure-test.php new file mode 100644 index 0000000000000..c0e2c77d9ca1c --- /dev/null +++ b/phpunit/fonts-api/bc-layer/gutenbergFontsApiBcLayer/migrateDeprecatedStructure-test.php @@ -0,0 +1,38 @@ +assertSameSets( $expected['migration'], Gutenberg_Fonts_API_BC_Layer::migrate_deprecated_structure( $fonts ) ); + } + + /** + * @dataProvider data_not_deprecated_structure + * + * @param array $fonts Fonts to test. + */ + public function test_should_return_fonts_and_not_throw_deprecation( array $fonts ) { + $this->assertSameSets( $fonts, Gutenberg_Fonts_API_BC_Layer::migrate_deprecated_structure( $fonts ) ); + } +} diff --git a/phpunit/fonts-api/bc-layer/wpRegisterWebfonts-test.php b/phpunit/fonts-api/bc-layer/wpRegisterWebfonts-test.php new file mode 100644 index 0000000000000..4a64068fee8c8 --- /dev/null +++ b/phpunit/fonts-api/bc-layer/wpRegisterWebfonts-test.php @@ -0,0 +1,62 @@ +assertSame( $expected['wp_register_webfonts'], $actual, 'Font family handle(s) should be returned' ); + $this->assertSame( $expected['get_registered'], $this->get_registered_handles(), 'Fonts should match registered queue' ); + } + + /** + * @dataProvider data_deprecated_structure_with_invalid_font_family + * + * @expectedDeprecated Gutenberg_Fonts_API_BC_Layer::migrate_deprecated_structure + * @expectedDeprecated wp_register_webfonts + * + * @param array $fonts Fonts to test. + * @param string $expected_message Expected notice message. + */ + public function test_should_not_register_with_undefined_font_family( array $fonts, $expected_message ) { + $this->expectNotice(); + $this->expectNoticeMessage( $expected_message ); + + $actual = wp_register_webfonts( $fonts ); + $this->assertSame( array(), $actual, 'Return value should be an empty array' ); + $this->assertEmpty( $this->get_registered_handles(), 'No fonts should have registered' ); + } +} diff --git a/phpunit/fonts-api/bc-layer/wpWebfonts/getAllWebfonts-test.php b/phpunit/fonts-api/bc-layer/wpWebfonts/getAllWebfonts-test.php new file mode 100644 index 0000000000000..d6c1e10b691e4 --- /dev/null +++ b/phpunit/fonts-api/bc-layer/wpWebfonts/getAllWebfonts-test.php @@ -0,0 +1,33 @@ +assertSame( $expected, wp_webfonts()->get_all_webfonts() ); + } +} diff --git a/phpunit/fonts-api/bc-layer/wpWebfonts/getFontSlug-test.php b/phpunit/fonts-api/bc-layer/wpWebfonts/getFontSlug-test.php new file mode 100644 index 0000000000000..df17cb69b621c --- /dev/null +++ b/phpunit/fonts-api/bc-layer/wpWebfonts/getFontSlug-test.php @@ -0,0 +1,134 @@ +assertSame( $expected, WP_Webfonts::get_font_slug( $to_convert ) ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_should_get_font_slug() { + return array( + 'font family: single word' => array( + 'to_convert' => 'Merriweather', + 'expected' => 'merriweather', + ), + 'variation: single word font-family' => array( + 'to_convert' => array( + 'font-family' => 'Merriweather', + 'font-style' => 'normal', + 'font-weight' => '400', + ), + 'expected' => 'merriweather', + ), + 'font family: multiword' => array( + 'to_convert' => 'Source Sans Pro', + 'expected' => 'source-sans-pro', + ), + 'variation: multiword font-family' => array( + 'to_convert' => array( + 'font-family' => 'Source Serif Pro', + 'font-style' => 'normal', + 'font-weight' => '200 900', + ), + 'expected' => 'source-serif-pro', + ), + 'font family: delimited by hyphens' => array( + 'to_convert' => 'source-serif-pro', + 'expected' => 'source-serif-pro', + ), + 'variation: font-family delimited by hyphens' => array( + 'to_convert' => array( + 'font-family' => 'source-serif-pro', + 'font-style' => 'normal', + 'font-weight' => '200 900', + ), + 'expected' => 'source-serif-pro', + ), + 'font family: delimited by underscore' => array( + 'to_convert' => 'source_serif_pro', + 'expected' => 'source_serif_pro', + ), + 'variation: font family delimited by underscore' => array( + 'to_convert' => array( + 'font-style' => 'normal', + 'font-weight' => '200 900', + 'font-family' => 'Source_Serif_Pro', + ), + 'expected' => 'source_serif_pro', + ), + 'font family: delimited by hyphens and underscore' => array( + 'to_convert' => 'my-custom_font_family', + 'expected' => 'my-custom_font_family', + ), + 'variation: font family delimited by hyphens and underscore' => array( + 'to_convert' => array( + 'font-weight' => '700', + 'font-family' => 'my-custom_font_family', + 'font-style' => 'italic', + ), + 'expected' => 'my-custom_font_family', + ), + 'font family: delimited mixture' => array( + 'to_convert' => 'My custom_font-family', + 'expected' => 'my-custom_font-family', + ), + 'variation: font family delimited mixture' => array( + 'to_convert' => array( + 'font-style' => 'italic', + 'font-family' => 'My custom_font-family', + 'font-weight' => '700', + ), + 'expected' => 'my-custom_font-family', + ), + ); + } + + /** + * @dataProvider data_should_not_get_font_slug + * + * @expectedDeprecated WP_Webfonts::get_font_slug + * + * @param array|string $to_convert Value to test. + */ + public function test_should_not_get_font_slug( $to_convert ) { + $this->assertFalse( WP_Webfonts::get_font_slug( $to_convert ) ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_should_not_get_font_slug() { + return array( + 'Empty string' => array( '' ), + 'Empty array' => array( array() ), + ); + } +} diff --git a/phpunit/fonts-api/bc-layer/wpWebfonts/getRegisteredWebfonts-test.php b/phpunit/fonts-api/bc-layer/wpWebfonts/getRegisteredWebfonts-test.php new file mode 100644 index 0000000000000..3b41d9d37b414 --- /dev/null +++ b/phpunit/fonts-api/bc-layer/wpWebfonts/getRegisteredWebfonts-test.php @@ -0,0 +1,32 @@ +assertSame( $expected, wp_webfonts()->get_registered_webfonts() ); + } +} diff --git a/phpunit/fonts-api/bc-layer/wpWebfonts/registerWebfont-test.php b/phpunit/fonts-api/bc-layer/wpWebfonts/registerWebfont-test.php new file mode 100644 index 0000000000000..faaaf0e7ef6b7 --- /dev/null +++ b/phpunit/fonts-api/bc-layer/wpWebfonts/registerWebfont-test.php @@ -0,0 +1,113 @@ +assertFalse( wp_webfonts()->register_webfont( $webfont ) ); + } + + /** + * @dataProvider data_should_register_webfont + * + * @expectedDeprecated wp_webfonts + * @expectedDeprecated WP_Webfonts::register_webfont + * + * @param array $input Font to register. + * @param string|false $expected Expected result. + */ + public function test_should_register_webfont( array $input, $expected ) { + $this->assertSame( $expected['register_webfont'], wp_webfonts()->register_webfont( ...$input ), 'Font-family handle should be returned' ); + $this->assertSame( $expected['get_registered'], $this->get_registered_handles(), 'Font should be registered' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_should_register_webfont() { + return array( + 'No font family or variation handles' => array( + 'input' => array( + array( + 'font-family' => 'Merriweather', + 'font-style' => 'italic', + 'font-weight' => '400', + 'src' => 'https://example.com/assets/fonts/merriweather.ttf.woff2', + ), + ), + 'expected' => array( + 'register_webfont' => 'merriweather', + 'get_registered' => array( 'merriweather', 'merriweather-400-italic' ), + ), + ), + 'Has font family handle but no variation handles' => array( + 'input' => array( + array( + 'font-family' => 'Source Serif Pro', + 'font-style' => 'normal', + 'font-weight' => '200 900', + 'font-display' => 'fallback', + 'font-stretch' => 'normal', + 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2', + ), + 'source-serif-pro', + ), + 'expected' => array( + 'register_webfont' => 'source-serif-pro', + 'get_registered' => array( 'source-serif-pro', 'source-serif-pro-200-900-normal' ), + ), + ), + 'No font family handle but has variation handle' => array( + 'input' => array( + array( + 'font-family' => 'Merriweather', + 'font-style' => 'italic', + 'font-weight' => '400', + 'src' => 'https://example.com/assets/fonts/merriweather.ttf.woff2', + ), + '', + 'merriweather-italic-400', + ), + 'expected' => array( + 'register_webfont' => 'merriweather', + 'get_registered' => array( 'merriweather', 'merriweather-italic-400' ), + ), + ), + 'Has font family and variation handles' => array( + 'input' => array( + array( + 'font-family' => 'Source Serif Pro', + 'font-style' => 'italic', + 'font-weight' => '200 900', + 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Italic.ttf.woff2', + ), + 'source-serif-pro', + 'source-serif-pro-variable-italic', + ), + 'expected' => array( + 'register_webfont' => 'source-serif-pro', + 'get_registered' => array( 'source-serif-pro', 'source-serif-pro-variable-italic' ), + ), + ), + ); + } +} diff --git a/phpunit/fonts-api/wpPrintFonts-test.php b/phpunit/fonts-api/wpPrintFonts-test.php index 932af8e990b31..cb500aaa0430f 100644 --- a/phpunit/fonts-api/wpPrintFonts-test.php +++ b/phpunit/fonts-api/wpPrintFonts-test.php @@ -15,31 +15,29 @@ */ class Tests_Fonts_WpPrintFonts extends WP_Fonts_TestCase { - public function test_should_return_empty_array_when_global_not_instance() { - global $wp_fonts; - wp_fonts(); - $wp_fonts = null; - + public function test_should_return_empty_array_when_no_fonts_registered() { $this->assertSame( array(), wp_print_fonts() ); - $this->assertNotInstanceOf( WP_Webfonts::class, $wp_fonts ); } /** - * Unit test to mock WP_Webfonts::do_items(). + * Unit test which mocks WP_Fonts methods. * * @dataProvider data_mocked_handles * - * @param string|string[]|false $handles Handles to test. - * @param array|string[] $expected Expected array of processed handles. + * @param string|string[] $handles Handles to test. */ - public function test_should_return_mocked_handles( $handles, $expected ) { - $mock = $this->set_up_mock( 'do_items' ); + public function test_should_return_mocked_handles( $handles ) { + $mock = $this->set_up_mock( array( 'get_registered_font_families', 'do_items' ) ); + $mock->expects( $this->once() ) + ->method( 'get_registered_font_families' ) + ->will( $this->returnValue( $handles ) ); + $mock->expects( $this->once() ) ->method( 'do_items' ) ->with( $this->identicalTo( $handles ) ) - ->will( $this->returnValue( $expected ) ); + ->will( $this->returnValue( $handles ) ); wp_print_fonts( $handles ); } @@ -51,13 +49,14 @@ public function test_should_return_mocked_handles( $handles, $expected ) { */ public function data_mocked_handles() { return array( - 'no handles' => array( - 'handles' => false, - 'expected' => array(), + 'font family' => array( + array( 'my-custom-font' ), ), - 'font family handles' => array( - 'handles' => array( 'my-custom-font' ), - 'expected' => array( 'my-custom-font' ), + 'multiple font families' => array( + array( + 'font1', + 'font2', + ), ), ); } @@ -124,4 +123,70 @@ private function setup_integrated_deps( array $setup, $wp_fonts, $enqueue = true $wp_fonts->enqueue( $setup['enqueued'] ); } } + + /** + * @dataProvider data_should_print_all_registered_fonts_for_iframed_editor + * + * @param string $fonts Fonts to register. + * @param array $expected Expected results. + */ + public function test_should_print_all_registered_fonts_for_iframed_editor( $fonts, $expected ) { + wp_register_fonts( $fonts ); + + $this->expectOutputString( $expected['output'] ); + $actual_done = wp_print_fonts( true ); + $this->assertSameSets( $expected['done'], $actual_done, 'All registered font-family handles should be returned' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_should_print_all_registered_fonts_for_iframed_editor() { + $local_fonts = $this->get_registered_local_fonts(); + $font_faces = $this->get_registered_fonts_css(); + + return array( + 'Merriweather with 1 variation' => array( + 'fonts' => array( 'merriweather' => $local_fonts['merriweather'] ), + 'expected' => array( + 'done' => array( 'merriweather', 'merriweather-200-900-normal' ), + 'output' => sprintf( + "\n", + $font_faces['merriweather-200-900-normal'] + ), + ), + ), + 'Source Serif Pro with 2 variations' => array( + 'fonts' => array( 'Source Serif Pro' => $local_fonts['Source Serif Pro'] ), + 'expected' => array( + 'done' => array( 'source-serif-pro', 'Source Serif Pro-300-normal', 'Source Serif Pro-900-italic' ), + 'output' => sprintf( + "\n", + $font_faces['Source Serif Pro-300-normal'], + $font_faces['Source Serif Pro-900-italic'] + ), + ), + ), + 'all fonts' => array( + 'fonts' => $local_fonts, + 'expected' => array( + 'done' => array( + 'merriweather', + 'merriweather-200-900-normal', + 'source-serif-pro', + 'Source Serif Pro-300-normal', + 'Source Serif Pro-900-italic', + ), + 'output' => sprintf( + "\n", + $font_faces['merriweather-200-900-normal'], + $font_faces['Source Serif Pro-300-normal'], + $font_faces['Source Serif Pro-900-italic'] + ), + ), + ), + ); + } } diff --git a/phpunit/fonts-api/wpRegisterFonts-test.php b/phpunit/fonts-api/wpRegisterFonts-test.php index 30ab64f3fa437..da54fd6438f97 100644 --- a/phpunit/fonts-api/wpRegisterFonts-test.php +++ b/phpunit/fonts-api/wpRegisterFonts-test.php @@ -101,220 +101,4 @@ public function data_fonts() { ), ); } - - /** - * @dataProvider data_deprecated_structure - * - * @expectedDeprecated WP_Webfonts::migrate_deprecated_structure - * - * @param array $fonts Fonts to test. - */ - public function test_should_throw_deprecation_with_deprecated_structure( array $fonts ) { - wp_register_fonts( $fonts ); - } - - /** - * @dataProvider data_deprecated_structure - * - * @expectedDeprecated WP_Webfonts::migrate_deprecated_structure - * - * @param array $fonts Fonts to test. - * @param array $expected Expected results. - */ - public function test_should_register_with_deprecated_structure( array $fonts, array $expected ) { - $actual = wp_register_fonts( $fonts ); - $this->assertSame( $expected['wp_register_fonts'], $actual, 'Font family handle(s) should be returned' ); - $this->assertSame( $expected['get_registered'], $this->get_registered_handles(), 'Fonts should match registered queue' ); - } - - /** - * Data provider. - * - * @return array - */ - public function data_deprecated_structure() { - return array( - '1 font' => array( - 'fonts' => array( - array( - 'provider' => 'local', - 'font-family' => 'Merriweather', - 'font-style' => 'normal', - 'font-weight' => '200 900', - 'font-display' => 'fallback', - 'font-stretch' => 'normal', - 'src' => 'https://example.com/assets/fonts/merriweather.ttf.woff2', - ), - ), - 'expected' => array( - 'wp_register_fonts' => array( 'merriweather' ), - 'get_registered' => array( 'merriweather', 'merriweather-200-900-normal' ), - ), - ), - '2 font in same font family' => array( - 'fonts' => array( - array( - 'provider' => 'local', - 'font-family' => 'Source Serif Pro', - 'font-style' => 'normal', - 'font-weight' => '300', - 'font-display' => 'fallback', - 'font-stretch' => 'normal', - 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2', - 'font-display' => 'fallback', - ), - array( - 'provider' => 'local', - 'font-family' => 'Source Serif Pro', - 'font-style' => 'italic', - 'font-weight' => '900', - 'font-display' => 'fallback', - 'font-stretch' => 'normal', - 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Italic.ttf.woff2', - 'font-display' => 'fallback', - ), - ), - 'expected' => array( - 'wp_register_fonts' => array( 'source-serif-pro' ), - 'get_registered' => array( - 'source-serif-pro', - 'source-serif-pro-300-normal', - 'source-serif-pro-900-italic', - ), - ), - ), - 'Fonts in different font families' => array( - 'fonts' => array( - array( - 'provider' => 'local', - 'font-family' => 'Source Serif Pro', - 'font-style' => 'normal', - 'font-weight' => '300', - 'font-display' => 'fallback', - 'font-stretch' => 'normal', - 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2', - 'font-display' => 'fallback', - ), - array( - 'provider' => 'local', - 'font-family' => 'Merriweather', - 'font-style' => 'normal', - 'font-weight' => '200 900', - 'font-display' => 'fallback', - 'font-stretch' => 'normal', - 'src' => 'https://example.com/assets/fonts/merriweather.ttf.woff2', - ), - array( - 'provider' => 'local', - 'font-family' => 'Source Serif Pro', - 'font-style' => 'italic', - 'font-weight' => '900', - 'font-display' => 'fallback', - 'font-stretch' => 'normal', - 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Italic.ttf.woff2', - 'font-display' => 'fallback', - ), - ), - 'expected' => array( - 'wp_register_fonts' => array( 'source-serif-pro', 'merriweather' ), - 'get_registered' => array( - 'source-serif-pro', - 'source-serif-pro-300-normal', - 'source-serif-pro-900-italic', - 'merriweather', - 'merriweather-200-900-normal', - ), - ), - ), - ); - } - - /** - * @dataProvider data_invalid_font_family - * - * @expectedDeprecated WP_Webfonts::migrate_deprecated_structure - * - * @param array $fonts Fonts to test. - * @param string $expected_message Expected notice message. - */ - public function test_should_not_register_with_undefined_font_family( array $fonts, $expected_message ) { - $this->expectNotice(); - $this->expectNoticeMessage( $expected_message ); - - $actual = wp_register_fonts( $fonts ); - $this->assertSame( array(), $actual, 'Return value should be an empty array' ); - $this->assertEmpty( $this->get_registered_handles(), 'No fonts should have registered' ); - } - - /** - * Data provider. - * - * @return array - */ - public function data_invalid_font_family() { - return array( - 'non-string' => array( - 'fonts' => array( - array( - 'provider' => 'local', - 'font-family' => null, - 'font-style' => 'normal', - 'font-weight' => '200 900', - 'font-display' => 'fallback', - 'font-stretch' => 'normal', - 'src' => 'https://example.com/assets/fonts/merriweather.ttf.woff2', - ), - ), - 'expected_message' => 'Font family not defined in the variation.', - ), - 'empty string in deprecated structure' => array( - 'fonts' => array( - '0' => array( - 'provider' => 'local', - 'font-style' => 'normal', - 'font-weight' => '300', - 'font-display' => 'fallback', - 'font-stretch' => 'normal', - 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2', - 'font-display' => 'fallback', - ), - ), - 'expected_message' => 'Font family not found.', - ), - 'incorrect parameter in deprecated structure' => array( - 'fonts' => array( - array( - 'provider' => 'local', - 'FontFamily' => 'Source Serif Pro', - 'font-style' => 'normal', - 'font-weight' => '300', - 'font-display' => 'fallback', - 'font-stretch' => 'normal', - 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2', - 'font-display' => 'fallback', - ), - array( - 'provider' => 'local', - 'font_family' => 'Merriweather', - 'font-style' => 'normal', - 'font-weight' => '200 900', - 'font-display' => 'fallback', - 'font-stretch' => 'normal', - 'src' => 'https://example.com/assets/fonts/merriweather.ttf.woff2', - ), - array( - 'provider' => 'local', - 'font family' => 'Source Serif Pro', - 'font-style' => 'italic', - 'font-weight' => '900', - 'font-display' => 'fallback', - 'font-stretch' => 'normal', - 'src' => 'https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Italic.ttf.woff2', - 'font-display' => 'fallback', - ), - ), - 'expected_message' => 'Font family not found.', - ), - ); - } } diff --git a/readme.txt b/readme.txt index ef8e6420a3c89..09a9d2b49e6f2 100644 --- a/readme.txt +++ b/readme.txt @@ -1,6 +1,6 @@ === Gutenberg === Contributors: matveb, joen, karmatosed -Tested up to: 6.1 +Tested up to: 6.2 Stable tag: V.V.V License: GPLv2 or later License URI: http://www.gnu.org/licenses/gpl-2.0.html diff --git a/schemas/json/theme.json b/schemas/json/theme.json index 4417daff97cd0..5f9fd13c04157 100644 --- a/schemas/json/theme.json +++ b/schemas/json/theme.json @@ -266,7 +266,7 @@ "type": "string" }, "wideSize": { - "description": "Sets the max-width of wide (`.alignwide`) content.", + "description": "Sets the max-width of wide (`.alignwide`) content. Also used as the maximum viewport when calculating fluid font sizes", "type": "string" } }, @@ -708,11 +708,11 @@ "type": "object", "properties": { "border": { - "description": "Settings related to borders.\nGutenberg plugin required.", + "description": "Settings related to borders.", "type": "object", "properties": { "radius": { - "description": "Allow users to set custom border radius.\nGutenberg plugin required.", + "description": "Allow users to set custom border radius.", "type": "boolean", "default": false } diff --git a/storybook/decorators/with-theme.js b/storybook/decorators/with-theme.js index 9f11dc63dbaed..083d929a5865a 100644 --- a/storybook/decorators/with-theme.js +++ b/storybook/decorators/with-theme.js @@ -19,8 +19,8 @@ const themes = { accent: '#3858e9', background: '#f0f0f0', }, - modern: { - accent: '#3858e9', + classic: { + accent: '#007cba', }, }; diff --git a/storybook/main.js b/storybook/main.js index a0f5c333bb0d8..fa59c739ea3d5 100644 --- a/storybook/main.js +++ b/storybook/main.js @@ -4,6 +4,7 @@ const stories = [ '../packages/components/src/**/stories/*.@(js|tsx|mdx)', '../packages/icons/src/**/stories/*.@(js|tsx|mdx)', '../packages/edit-site/src/**/stories/*.@(js|tsx|mdx)', + '../packages/components/README.mdx', ].filter( Boolean ); module.exports = { @@ -14,7 +15,7 @@ module.exports = { addons: [ { name: '@storybook/addon-docs', - options: { configureJSX: true }, + options: { configureJSX: true, transcludeMarkdown: true }, }, '@storybook/addon-controls', '@storybook/addon-viewport', diff --git a/storybook/preview.js b/storybook/preview.js index 9b4e1e334b739..ff73a95fa4131 100644 --- a/storybook/preview.js +++ b/storybook/preview.js @@ -6,7 +6,6 @@ import { WithMarginChecker } from './decorators/with-margin-checker'; import { WithMaxWidthWrapper } from './decorators/with-max-width-wrapper'; import { WithRTL } from './decorators/with-rtl'; import { WithTheme } from './decorators/with-theme'; -import './style.scss'; export const globalTypes = { direction: { @@ -31,7 +30,7 @@ export const globalTypes = { { value: 'default', title: 'Default' }, { value: 'darkBg', title: 'Dark (background)' }, { value: 'lightGrayBg', title: 'Light gray (background)' }, - { value: 'modern', title: 'Modern (accent)' }, + { value: 'classic', title: 'Classic (accent)' }, ], }, }, diff --git a/storybook/stories/docs/components/contributing.story.mdx b/storybook/stories/docs/components/contributing.story.mdx new file mode 100644 index 0000000000000..1ffd0c49d4020 --- /dev/null +++ b/storybook/stories/docs/components/contributing.story.mdx @@ -0,0 +1,6 @@ +import { Meta } from '@storybook/addon-docs'; +import Contributing from '@wordpress/components/CONTRIBUTING.md'; + + + + diff --git a/storybook/stories/docs/components/readme.story.mdx b/storybook/stories/docs/components/readme.story.mdx new file mode 100644 index 0000000000000..7996188ffb50b --- /dev/null +++ b/storybook/stories/docs/components/readme.story.mdx @@ -0,0 +1,6 @@ +import { Meta } from '@storybook/addon-docs'; +import Readme from '@wordpress/components/README.md'; + + + + diff --git a/storybook/stories/docs/introduction.story.mdx b/storybook/stories/docs/introduction.story.mdx index f353cf7c7e0ab..46079eafe47b4 100644 --- a/storybook/stories/docs/introduction.story.mdx +++ b/storybook/stories/docs/introduction.story.mdx @@ -7,7 +7,7 @@ import { InlineIcon } from './inline-icon'; ## Welcome! -The WordPress Gutenberg project uses Storybook to view and work with the UI components developed in the WordPress package [@wordpress/components](https://github.com/WordPress/gutenberg/tree/trunk/packages/components). +The WordPress Gutenberg project uses Storybook to view and work with the UI components developed in WordPress packages, especially [@wordpress/components](https://github.com/WordPress/gutenberg/tree/trunk/packages/components). On this interactive site you can browse individual components, their controls, options, and settings in isolation. You can also modify controls and arguments and see the changes right away. diff --git a/storybook/style.scss b/storybook/style.scss deleted file mode 100644 index 172fc48710b80..0000000000000 --- a/storybook/style.scss +++ /dev/null @@ -1,11 +0,0 @@ -@use "sass:math"; - -// Loading @wordpress/base-styles as they have mixins and other dependencies -// used within @wordpress/*/src/*.scss -@import "~@wordpress/base-styles/animations"; -@import "~@wordpress/base-styles/breakpoints"; -@import "~@wordpress/base-styles/colors"; -@import "~@wordpress/base-styles/mixins"; -@import "~@wordpress/base-styles/default-custom-properties"; -@import "~@wordpress/base-styles/variables"; -@import "~@wordpress/base-styles/z-index"; diff --git a/test/e2e/specs/editor/blocks/buttons.spec.js b/test/e2e/specs/editor/blocks/buttons.spec.js index 9d86e206f0219..13d5759ee7db9 100644 --- a/test/e2e/specs/editor/blocks/buttons.spec.js +++ b/test/e2e/specs/editor/blocks/buttons.spec.js @@ -182,11 +182,11 @@ test.describe( 'Buttons', () => { `role=region[name="Editor settings"i] >> role=tab[name="Styles"i]` ); await page.click( - 'role=region[name="Editor settings"i] >> role=button[name="Text"i]' + 'role=region[name="Editor settings"i] >> role=button[name="Color Text styles"i]' ); await page.click( 'role=button[name="Color: Cyan bluish gray"i]' ); await page.click( - 'role=region[name="Editor settings"i] >> role=button[name="Background"i]' + 'role=region[name="Editor settings"i] >> role=button[name="Color Background styles"i]' ); await page.click( 'role=button[name="Color: Vivid red"i]' ); @@ -211,13 +211,13 @@ test.describe( 'Buttons', () => { `role=region[name="Editor settings"i] >> role=tab[name="Styles"i]` ); await page.click( - 'role=region[name="Editor settings"i] >> role=button[name="Text"i]' + 'role=region[name="Editor settings"i] >> role=button[name="Color Text styles"i]' ); await page.click( 'role=button[name="Custom color picker."i]' ); await page.fill( 'role=textbox[name="Hex color"i]', 'ff0000' ); await page.click( - 'role=region[name="Editor settings"i] >> role=button[name="Background"i]' + 'role=region[name="Editor settings"i] >> role=button[name="Color Background styles"i]' ); await page.click( 'role=button[name="Custom color picker."i]' ); await page.fill( 'role=textbox[name="Hex color"i]', '00ff00' ); @@ -246,7 +246,7 @@ test.describe( 'Buttons', () => { `role=region[name="Editor settings"i] >> role=tab[name="Styles"i]` ); await page.click( - 'role=region[name="Editor settings"i] >> role=button[name="Background"i]' + 'role=region[name="Editor settings"i] >> role=button[name="Color Background styles"i]' ); await page.click( 'role=tab[name="Gradient"i]' ); await page.click( 'role=button[name="Gradient: Purple to yellow"i]' ); @@ -275,7 +275,7 @@ test.describe( 'Buttons', () => { `role=region[name="Editor settings"i] >> role=tab[name="Styles"i]` ); await page.click( - 'role=region[name="Editor settings"i] >> role=button[name="Background"i]' + 'role=region[name="Editor settings"i] >> role=button[name="Color Background styles"i]' ); await page.click( 'role=tab[name="Gradient"i]' ); await page.click( diff --git a/test/e2e/specs/editor/blocks/classic.spec.js b/test/e2e/specs/editor/blocks/classic.spec.js index 559fbc93a1054..cba1472caf916 100644 --- a/test/e2e/specs/editor/blocks/classic.spec.js +++ b/test/e2e/specs/editor/blocks/classic.spec.js @@ -84,10 +84,6 @@ test.describe( 'Classic', () => { ); await expect( galleryBlock ).toBeVisible(); - // Focus on the editor so that keyboard shortcuts work. - // See: https://github.com/WordPress/gutenberg/issues/46844 - await galleryBlock.focus(); - // Check that you can undo back to a Classic block gallery in one step. await pageUtils.pressKeys( 'primary+z' ); await expect( diff --git a/test/e2e/specs/editor/blocks/image.spec.js b/test/e2e/specs/editor/blocks/image.spec.js index 3684980affb29..eb5d9e2780b44 100644 --- a/test/e2e/specs/editor/blocks/image.spec.js +++ b/test/e2e/specs/editor/blocks/image.spec.js @@ -538,6 +538,188 @@ test.describe( 'Image', () => {
` ); } ); + + test( 'can be replaced by dragging-and-dropping images from the inserter', async ( { + page, + editor, + } ) => { + await editor.insertBlock( { name: 'core/image' } ); + const imageBlock = page.getByRole( 'document', { + name: 'Block: Image', + } ); + const blockLibrary = page.getByRole( 'region', { + name: 'Block Library', + } ); + + async function openMediaTab() { + await page + .getByRole( 'button', { name: 'Toggle block inserter' } ) + .click(); + + await blockLibrary.getByRole( 'tab', { name: 'Media' } ).click(); + + await blockLibrary + .getByRole( 'tabpanel', { name: 'Media' } ) + .getByRole( 'button', { name: 'Openverse' } ) + .click(); + } + + await openMediaTab(); + + // Drag the first image from the media library into the image block. + await blockLibrary + .getByRole( 'listbox', { name: 'Media List' } ) + .getByRole( 'option' ) + .first() + .dragTo( imageBlock ); + + await expect( async () => { + const blocks = await editor.getBlocks(); + expect( blocks ).toHaveLength( 1 ); + + const [ + { + attributes: { url }, + }, + ] = blocks; + expect( + await imageBlock.getByRole( 'img' ).getAttribute( 'src' ) + ).toBe( url ); + expect( + new URL( url ).host, + 'should be updated to the media library' + ).toBe( new URL( page.url() ).host ); + }, 'should update the image from the media inserter' ).toPass(); + const [ + { + attributes: { url: firstUrl }, + }, + ] = await editor.getBlocks(); + + await openMediaTab(); + + // Drag the second image from the media library into the image block. + await blockLibrary + .getByRole( 'listbox', { name: 'Media List' } ) + .getByRole( 'option' ) + .nth( 1 ) + .dragTo( imageBlock ); + + await expect( async () => { + const blocks = await editor.getBlocks(); + expect( blocks ).toHaveLength( 1 ); + + const [ + { + attributes: { url }, + }, + ] = blocks; + expect( url ).not.toBe( firstUrl ); + expect( + await imageBlock.getByRole( 'img' ).getAttribute( 'src' ) + ).toBe( url ); + expect( + new URL( url ).host, + 'should be updated to the media library' + ).toBe( new URL( page.url() ).host ); + }, 'should replace the original image with the second image' ).toPass(); + } ); + + test( 'should allow dragging and dropping HTML to media placeholder', async ( { + page, + editor, + } ) => { + await editor.insertBlock( { name: 'core/image' } ); + const imageBlock = page.getByRole( 'document', { + name: 'Block: Image', + } ); + + const html = ` +
+ Cat +
"Cat" by tomhouslay is licensed under CC BY-NC 2.0.
+
+ `; + + await page.evaluate( ( _html ) => { + const dummy = document.createElement( 'div' ); + dummy.style.width = '10px'; + dummy.style.height = '10px'; + dummy.style.zIndex = 99999; + dummy.style.position = 'fixed'; + dummy.style.top = 0; + dummy.style.left = 0; + dummy.draggable = 'true'; + dummy.addEventListener( 'dragstart', ( event ) => { + event.dataTransfer.setData( 'text/html', _html ); + setTimeout( () => { + dummy.remove(); + }, 0 ); + } ); + document.body.appendChild( dummy ); + }, html ); + + await page.mouse.move( 0, 0 ); + await page.mouse.down(); + await imageBlock.hover(); + await page.mouse.up(); + + const host = new URL( page.url() ).host; + + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/image', + attributes: { + link: expect.stringContaining( host ), + url: expect.stringContaining( host ), + id: expect.any( Number ), + alt: 'Cat', + caption: `"Cat" by tomhouslay is licensed under CC BY-NC 2.0.`, + }, + }, + ] ); + const url = ( await editor.getBlocks() )[ 0 ].attributes.url; + await expect( imageBlock.getByRole( 'img' ) ).toHaveAttribute( + 'src', + url + ); + } ); + + test( 'should appear in the frontend published post content', async ( { + editor, + imageBlockUtils, + page, + } ) => { + await editor.insertBlock( { name: 'core/image' } ); + const imageBlock = page.locator( + 'role=document[name="Block: Image"i]' + ); + await expect( imageBlock ).toBeVisible(); + + const filename = await imageBlockUtils.upload( + imageBlock.locator( 'data-testid=form-file-upload-input' ) + ); + + const imageInEditor = imageBlock.locator( 'role=img' ); + await expect( imageInEditor ).toBeVisible(); + await expect( imageInEditor ).toHaveAttribute( + 'src', + new RegExp( filename ) + ); + + const postId = await editor.publishPost(); + await page.goto( `/?p=${ postId }` ); + + const figureDom = page.getByRole( 'figure' ); + await expect( figureDom ).toBeVisible(); + + const imageDom = figureDom.locator( 'img' ); + await expect( imageDom ).toBeVisible(); + await expect( imageDom ).toHaveAttribute( + 'src', + new RegExp( filename ) + ); + } ); } ); class ImageBlockUtils { diff --git a/test/e2e/specs/editor/blocks/navigation.spec.js b/test/e2e/specs/editor/blocks/navigation.spec.js index 81bdf0d27e308..dc30314195731 100644 --- a/test/e2e/specs/editor/blocks/navigation.spec.js +++ b/test/e2e/specs/editor/blocks/navigation.spec.js @@ -678,7 +678,7 @@ test.describe( 'Navigation block', () => { name: 'Settings', } ) .getByRole( 'heading', { - name: 'Link Settings', + name: 'Settings', } ) ).toBeVisible(); @@ -802,6 +802,529 @@ test.describe( 'Navigation block', () => { } ); } ); +test.describe( 'Navigation block - Frontend interactivity', () => { + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.activateTheme( 'emptytheme' ); + await requestUtils.deleteAllTemplates( 'wp_template_part' ); + await requestUtils.deleteAllPages(); + await requestUtils.deleteAllMenus(); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.activateTheme( 'twentytwentyone' ); + } ); + + test.afterEach( async ( { requestUtils } ) => { + await requestUtils.deleteAllTemplates( 'wp_template_part' ); + await requestUtils.deleteAllPages(); + await requestUtils.deleteAllMenus(); + } ); + + test.describe( 'Overlay menu', () => { + test.beforeEach( async ( { admin, editor, requestUtils } ) => { + await admin.visitSiteEditor( { + postId: 'emptytheme//header', + postType: 'wp_template_part', + } ); + await editor.canvas.click( 'body' ); + await requestUtils.createNavigationMenu( { + title: 'Hidden menu', + content: ` + + + `, + } ); + await editor.insertBlock( { + name: 'core/navigation', + attributes: { overlayMenu: 'always' }, + } ); + await editor.saveSiteEditorEntities(); + } ); + + test( 'overlay menu opens on click on open menu button', async ( { + page, + } ) => { + await page.goto( '/' ); + const overlayMenuFirstElement = page.getByRole( 'link', { + name: 'Item 1', + } ); + const openMenuButton = page.getByRole( 'button', { + name: 'Open menu', + } ); + + await expect( overlayMenuFirstElement ).toBeHidden(); + await openMenuButton.click(); + await expect( overlayMenuFirstElement ).toBeVisible(); + } ); + + test( 'overlay menu closes on click on close menu button', async ( { + page, + } ) => { + await page.goto( '/' ); + const overlayMenuFirstElement = page.getByRole( 'link', { + name: 'Item 1', + } ); + const openMenuButton = page.getByRole( 'button', { + name: 'Open menu', + } ); + const closeMenuButton = page.getByRole( 'button', { + name: 'Close menu', + } ); + await expect( overlayMenuFirstElement ).toBeHidden(); + await openMenuButton.click(); + await expect( overlayMenuFirstElement ).toBeVisible(); + await closeMenuButton.click(); + await expect( overlayMenuFirstElement ).toBeHidden(); + } ); + + test( 'overlay menu closes on ESC key', async ( { page } ) => { + await page.goto( '/' ); + const overlayMenuFirstElement = page.getByRole( 'link', { + name: 'Item 1', + } ); + const openMenuButton = page.getByRole( 'button', { + name: 'Open menu', + } ); + await expect( overlayMenuFirstElement ).toBeHidden(); + await openMenuButton.focus(); + await page.keyboard.press( 'Enter' ); + await expect( overlayMenuFirstElement ).toBeVisible(); + await page.keyboard.press( 'Escape' ); + await expect( overlayMenuFirstElement ).toBeHidden(); + await expect( openMenuButton ).toBeFocused(); + } ); + + test( 'overlay menu focuses on first element after opening', async ( { + page, + } ) => { + await page.goto( '/' ); + const overlayMenuFirstElement = page.getByRole( 'link', { + name: 'Item 1', + } ); + const openMenuButton = page.getByRole( 'button', { + name: 'Open menu', + } ); + await expect( overlayMenuFirstElement ).toBeHidden(); + await openMenuButton.focus(); + await page.keyboard.press( 'Enter' ); + await expect( overlayMenuFirstElement ).toBeVisible(); + await expect( overlayMenuFirstElement ).toBeFocused(); + } ); + + test( 'overlay menu traps focus', async ( { page } ) => { + await page.goto( '/' ); + const overlayMenuFirstElement = page.getByRole( 'link', { + name: 'Item 1', + } ); + const openMenuButton = page.getByRole( 'button', { + name: 'Open menu', + } ); + const closeMenuButton = page.getByRole( 'button', { + name: 'Close menu', + } ); + await expect( overlayMenuFirstElement ).toBeHidden(); + await openMenuButton.focus(); + await page.keyboard.press( 'Enter' ); + await expect( overlayMenuFirstElement ).toBeVisible(); + await expect( overlayMenuFirstElement ).toBeFocused(); + await page.keyboard.press( 'Tab' ); + await page.keyboard.press( 'Tab' ); + await expect( closeMenuButton ).toBeFocused(); + await page.keyboard.press( 'Shift+Tab' ); + await page.keyboard.press( 'Shift+Tab' ); + await expect( overlayMenuFirstElement ).toBeFocused(); + } ); + } ); + + test.describe( 'Submenus (Open on click)', () => { + test.beforeEach( async ( { admin, editor, requestUtils } ) => { + await admin.visitSiteEditor( { + postId: 'emptytheme//header', + postType: 'wp_template_part', + } ); + await editor.canvas.click( 'body' ); + await requestUtils.createNavigationMenu( { + title: 'Hidden menu', + content: ` + + + + + + + + + + + + + `, + } ); + await editor.insertBlock( { + name: 'core/navigation', + attributes: { overlayMenu: 'off', openSubmenusOnClick: true }, + } ); + await editor.saveSiteEditorEntities(); + } ); + + test( 'submenu opens on click', async ( { page } ) => { + await page.goto( '/' ); + const simpleSubmenuButton = page.getByRole( 'button', { + name: 'Simple Submenu', + } ); + const innerElement = page.getByRole( 'link', { + name: 'Simple Submenu Link 1', + } ); + await expect( innerElement ).toBeHidden(); + await simpleSubmenuButton.click(); + await expect( innerElement ).toBeVisible(); + } ); + + test( 'nested submenu opens on click', async ( { page } ) => { + await page.goto( '/' ); + const complexSubmenuButton = page.getByRole( 'button', { + name: 'Complex Submenu', + } ); + const nestedSubmenuButton = page.getByRole( 'button', { + name: 'Nested Submenu', + } ); + const firstLevelElement = page.getByRole( 'link', { + name: 'Complex Submenu Link 1', + } ); + const secondLevelElement = page.getByRole( 'link', { + name: 'Nested Submenu Link 1', + } ); + await complexSubmenuButton.click(); + await expect( firstLevelElement ).toBeVisible(); + await expect( secondLevelElement ).toBeHidden(); + + await nestedSubmenuButton.click(); + await expect( firstLevelElement ).toBeVisible(); + await expect( secondLevelElement ).toBeVisible(); + } ); + + test( 'submenu closes on click outside', async ( { page } ) => { + await page.goto( '/' ); + const simpleSubmenuButton = page.getByRole( 'button', { + name: 'Simple Submenu', + } ); + const innerElement = page.getByRole( 'link', { + name: 'Simple Submenu Link 1', + } ); + await expect( innerElement ).toBeHidden(); + await simpleSubmenuButton.click(); + await expect( innerElement ).toBeVisible(); + await page.click( 'body' ); + await expect( innerElement ).toBeHidden(); + } ); + + test( 'submenu closes on ESC key', async ( { page } ) => { + await page.goto( '/' ); + const simpleSubmenuButton = page.getByRole( 'button', { + name: 'Simple Submenu', + } ); + const innerElement = page.getByRole( 'link', { + name: 'Simple Submenu Link 1', + } ); + await expect( innerElement ).toBeHidden(); + await simpleSubmenuButton.focus(); + await page.keyboard.press( 'Enter' ); + await expect( innerElement ).toBeVisible(); + await page.keyboard.press( 'Escape' ); + await expect( innerElement ).toBeHidden(); + await expect( simpleSubmenuButton ).toBeFocused(); + } ); + + test( 'submenu closes on tab outside submenu', async ( { page } ) => { + await page.goto( '/' ); + const simpleSubmenuButton = page.getByRole( 'button', { + name: 'Simple Submenu', + } ); + const complexSubmenuButton = page.getByRole( 'button', { + name: 'Complex Submenu', + } ); + const innerElement = page.getByRole( 'link', { + name: 'Simple Submenu Link 1', + } ); + await expect( innerElement ).toBeHidden(); + await simpleSubmenuButton.focus(); + await page.keyboard.press( 'Enter' ); + await expect( innerElement ).toBeVisible(); + // Tab to first element. + await page.keyboard.press( 'Tab' ); + // Tab outside the submenu. + await page.keyboard.press( 'Tab' ); + await expect( innerElement ).toBeHidden(); + await expect( complexSubmenuButton ).toBeFocused(); + } ); + + test( 'nested submenu closes on click outside', async ( { page } ) => { + await page.goto( '/' ); + const complexSubmenuButton = page.getByRole( 'button', { + name: 'Complex Submenu', + } ); + const nestedSubmenuButton = page.getByRole( 'button', { + name: 'Nested Submenu', + } ); + const firstLevelElement = page.getByRole( 'link', { + name: 'Complex Submenu Link 1', + } ); + const secondLevelElement = page.getByRole( 'link', { + name: 'Nested Submenu Link 1', + } ); + await complexSubmenuButton.click(); + await expect( firstLevelElement ).toBeVisible(); + await expect( secondLevelElement ).toBeHidden(); + + await nestedSubmenuButton.click(); + await expect( firstLevelElement ).toBeVisible(); + await expect( secondLevelElement ).toBeVisible(); + + await page.click( 'body' ); + await expect( firstLevelElement ).toBeHidden(); + await expect( secondLevelElement ).toBeHidden(); + } ); + + test( 'nested submenu closes on ESC key', async ( { page } ) => { + await page.goto( '/' ); + const complexSubmenuButton = page.getByRole( 'button', { + name: 'Complex Submenu', + } ); + const nestedSubmenuButton = page.getByRole( 'button', { + name: 'Nested Submenu', + } ); + const firstLevelElement = page.getByRole( 'link', { + name: 'Complex Submenu Link 1', + } ); + const secondLevelElement = page.getByRole( 'link', { + name: 'Nested Submenu Link 1', + } ); + await complexSubmenuButton.focus(); + await page.keyboard.press( 'Enter' ); + await expect( firstLevelElement ).toBeVisible(); + await expect( secondLevelElement ).toBeHidden(); + + await nestedSubmenuButton.click(); + await expect( firstLevelElement ).toBeVisible(); + await expect( secondLevelElement ).toBeVisible(); + + await page.keyboard.press( 'Escape' ); + await expect( firstLevelElement ).toBeHidden(); + await expect( secondLevelElement ).toBeHidden(); + await expect( complexSubmenuButton ).toBeFocused(); + } ); + + test( 'only nested submenu closes on tab outside', async ( { + page, + } ) => { + await page.goto( '/' ); + const complexSubmenuButton = page.getByRole( 'button', { + name: 'Complex Submenu', + } ); + const nestedSubmenuButton = page.getByRole( 'button', { + name: 'Nested Submenu', + } ); + const firstLevelElement = page.getByRole( 'link', { + name: 'Complex Submenu Link 1', + } ); + const secondLevelElement = page.getByRole( 'link', { + name: 'Nested Submenu Link 1', + } ); + await complexSubmenuButton.focus(); + await page.keyboard.press( 'Enter' ); + await expect( firstLevelElement ).toBeVisible(); + await expect( secondLevelElement ).toBeHidden(); + + await nestedSubmenuButton.click(); + await expect( firstLevelElement ).toBeVisible(); + await expect( secondLevelElement ).toBeVisible(); + + // Tab to nested submenu first element. + await page.keyboard.press( 'Tab' ); + // Tab outside the nested submenu. + await page.keyboard.press( 'Tab' ); + await expect( firstLevelElement ).toBeVisible(); + await expect( secondLevelElement ).toBeHidden(); + // Tab outside the complex submenu. + await page.keyboard.press( 'Tab' ); + await expect( firstLevelElement ).toBeHidden(); + } ); + } ); + + test.describe( 'Submenus (Arrow setting)', () => { + test.beforeEach( async ( { admin, editor, requestUtils } ) => { + await admin.visitSiteEditor( { + postId: 'emptytheme//header', + postType: 'wp_template_part', + } ); + await editor.canvas.click( 'body' ); + await requestUtils.createNavigationMenu( { + title: 'Hidden menu', + content: ` + + + + + + + `, + } ); + await editor.insertBlock( { + name: 'core/navigation', + attributes: { overlayMenu: 'off' }, + } ); + await editor.saveSiteEditorEntities(); + } ); + + test( 'submenu opens on click in the arrow', async ( { page } ) => { + await page.goto( '/' ); + const arrowButton = page.getByRole( 'button', { + name: 'Submenu submenu', + } ); + const nestedSubmenuArrowButton = page.getByRole( 'button', { + name: 'Nested Menu submenu', + } ); + const firstLevelElement = page.getByRole( 'link', { + name: 'Submenu Link', + } ); + const secondLevelElement = page.getByRole( 'link', { + name: 'Nested Menu Link', + } ); + + await expect( firstLevelElement ).toBeHidden(); + await expect( secondLevelElement ).toBeHidden(); + await arrowButton.click(); + await expect( firstLevelElement ).toBeVisible(); + await expect( secondLevelElement ).toBeHidden(); + await nestedSubmenuArrowButton.click(); + await expect( firstLevelElement ).toBeVisible(); + await expect( secondLevelElement ).toBeVisible(); + await page.click( 'body' ); + await expect( firstLevelElement ).toBeHidden(); + await expect( secondLevelElement ).toBeHidden(); + } ); + } ); + + test.describe( 'Page list block', () => { + test.beforeEach( async ( { admin, editor, page, requestUtils } ) => { + // Create parent page. + await admin.createNewPost( { + postType: 'page', + title: 'Parent Page', + } ); + await editor.publishPost(); + + // Create subpage. + await admin.createNewPost( { + postType: 'page', + title: 'Subpage', + } ); + await editor.openDocumentSettingsSidebar(); + const parentPageList = page.getByLabel( 'Parent page:' ); + if ( await parentPageList.isHidden() ) { + await page + .getByRole( 'button', { + name: 'Page Attributes', + } ) + .click(); + } + await parentPageList.click(); + await page + .getByRole( 'option', { + name: 'Parent Page', + } ) + .click(); + await editor.publishPost(); + + await admin.visitSiteEditor( { + postId: 'emptytheme//header', + postType: 'wp_template_part', + } ); + await editor.canvas.click( 'body' ); + await requestUtils.createNavigationMenu( { + title: 'Hidden menu', + content: ` + + + `, + } ); + await editor.insertBlock( { + name: 'core/navigation', + attributes: { overlayMenu: 'off', openSubmenusOnClick: true }, + } ); + await editor.saveSiteEditorEntities(); + } ); + + test( 'page-list submenu opens on click', async ( { page } ) => { + await page.goto( '/' ); + const submenuButton = page.getByRole( 'button', { + name: 'Parent Page', + } ); + const innerElement = page.getByRole( 'link', { + name: 'Subpage', + } ); + await expect( innerElement ).toBeHidden(); + await submenuButton.click(); + await expect( innerElement ).toBeVisible(); + } ); + + test( 'page-list submenu closes on click outside', async ( { + page, + } ) => { + await page.goto( '/' ); + const submenuButton = page.getByRole( 'button', { + name: 'Parent Page', + } ); + const innerElement = page.getByRole( 'link', { + name: 'Subpage', + } ); + await expect( innerElement ).toBeHidden(); + await submenuButton.click(); + await expect( innerElement ).toBeVisible(); + await page.click( 'body' ); + await expect( innerElement ).toBeHidden(); + } ); + + test( 'page-list submenu closes on ESC key', async ( { page } ) => { + await page.goto( '/' ); + const submenuButton = page.getByRole( 'button', { + name: 'Parent Page', + } ); + const innerElement = page.getByRole( 'link', { + name: 'Subpage', + } ); + await expect( innerElement ).toBeHidden(); + await submenuButton.focus(); + await page.keyboard.press( 'Enter' ); + await expect( innerElement ).toBeVisible(); + await page.keyboard.press( 'Escape' ); + await expect( innerElement ).toBeHidden(); + await expect( submenuButton ).toBeFocused(); + } ); + + test( 'page-list submenu closes on tab outside submenu', async ( { + page, + } ) => { + await page.goto( '/' ); + const submenuButton = page.getByRole( 'button', { + name: 'Parent Page', + } ); + const innerElement = page.getByRole( 'link', { + name: 'Subpage', + } ); + await expect( innerElement ).toBeHidden(); + await submenuButton.focus(); + await page.keyboard.press( 'Enter' ); + await expect( innerElement ).toBeVisible(); + // Tab to first element. + await page.keyboard.press( 'Tab' ); + // Tab outside the submenu. + await page.keyboard.press( 'Tab' ); + await expect( innerElement ).toBeHidden(); + } ); + } ); +} ); + class LinkControl { constructor( { page } ) { this.page = page; diff --git a/test/e2e/specs/editor/blocks/pullquote.spec.js b/test/e2e/specs/editor/blocks/pullquote.spec.js new file mode 100644 index 0000000000000..9b20204a624ec --- /dev/null +++ b/test/e2e/specs/editor/blocks/pullquote.spec.js @@ -0,0 +1,57 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'Quote', () => { + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost(); + } ); + + test( 'can be created by converting a quote and converted back to quote', async ( { + editor, + page, + } ) => { + await page.click( 'role=button[name="Add default block"i]' ); + + await page.keyboard.type( 'test' ); + await editor.transformBlockTo( 'core/quote' ); + + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/quote', + attributes: { value: '' }, + innerBlocks: [ + { + name: 'core/paragraph', + attributes: { content: 'test' }, + }, + ], + }, + ] ); + + await editor.transformBlockTo( 'core/pullquote' ); + + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/pullquote', + attributes: { value: 'test' }, + innerBlocks: [], + }, + ] ); + await editor.transformBlockTo( 'core/quote' ); + + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/quote', + attributes: { value: '' }, + innerBlocks: [ + { + name: 'core/paragraph', + attributes: { content: 'test' }, + }, + ], + }, + ] ); + } ); +} ); diff --git a/test/e2e/specs/editor/various/adding-patterns.spec.js b/test/e2e/specs/editor/various/adding-patterns.spec.js new file mode 100644 index 0000000000000..94b9dbd2646e8 --- /dev/null +++ b/test/e2e/specs/editor/various/adding-patterns.spec.js @@ -0,0 +1,30 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'adding patterns', () => { + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost(); + } ); + + test( 'should insert a block pattern', async ( { page, editor } ) => { + await page.click( + 'role=region[name="Editor top bar"i] >> role=button[name="Toggle block inserter"i]' + ); + + await page.fill( + 'role=region[name="Block Library"i] >> role=searchbox[name="Search for blocks and patterns"i]', + 'Social links with a shared background color' + ); + + await page.click( + 'role=option[name="Social links with a shared background color"i]' + ); + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/social-links', + }, + ] ); + } ); +} ); diff --git a/test/e2e/specs/editor/various/keep-styles-on-block-transforms.spec.js b/test/e2e/specs/editor/various/keep-styles-on-block-transforms.spec.js new file mode 100644 index 0000000000000..e4857f84d46c3 --- /dev/null +++ b/test/e2e/specs/editor/various/keep-styles-on-block-transforms.spec.js @@ -0,0 +1,99 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'Keep styles on block transforms', () => { + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost(); + } ); + + test( 'Should keep colors during a transform', async ( { + page, + editor, + } ) => { + await page.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( '## Heading' ); + await editor.openDocumentSettingsSidebar(); + await page.click( 'role=button[name="Color Text styles"i]' ); + await page.click( 'role=button[name="Color: Luminous vivid orange"i]' ); + + await page.click( 'role=button[name="Heading"i]' ); + await page.click( 'role=menuitem[name="Paragraph"i]' ); + + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { + content: 'Heading', + textColor: 'luminous-vivid-orange', + }, + }, + ] ); + } ); + + test( 'Should keep the font size during a transform from multiple blocks into multiple blocks', async ( { + page, + pageUtils, + editor, + } ) => { + // Create a paragraph block with some content. + await page.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( 'Line 1 to be made large' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( 'Line 2 to be made large' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( 'Line 3 to be made large' ); + await pageUtils.pressKeys( 'shift+ArrowUp' ); + await pageUtils.pressKeys( 'shift+ArrowUp' ); + await page.click( 'role=radio[name="Large"i]' ); + await page.click( 'role=button[name="Paragraph"i]' ); + await page.click( 'role=menuitem[name="Heading"i]' ); + + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/heading', + attributes: { fontSize: 'large' }, + }, + { + name: 'core/heading', + attributes: { fontSize: 'large' }, + }, + { + name: 'core/heading', + attributes: { fontSize: 'large' }, + }, + ] ); + } ); + + test( 'Should not include styles in the group block when grouping a block', async ( { + page, + editor, + } ) => { + // Create a paragraph block with some content. + await page.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( 'Line 1 to be made large' ); + await page.click( 'role=radio[name="Large"i]' ); + await editor.showBlockToolbar(); + await page.click( 'role=button[name="Paragraph"i]' ); + await page.click( 'role=menuitem[name="Group"i]' ); + + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/group', + attributes: expect.not.objectContaining( { + fontSize: 'large', + } ), + innerBlocks: [ + { + name: 'core/paragraph', + attributes: { + content: 'Line 1 to be made large', + fontSize: 'large', + }, + }, + ], + }, + ] ); + } ); +} ); diff --git a/test/e2e/specs/editor/various/post-editor-template-mode.spec.js b/test/e2e/specs/editor/various/post-editor-template-mode.spec.js index f9a7aa8970d3e..c7aed497dc25c 100644 --- a/test/e2e/specs/editor/various/post-editor-template-mode.spec.js +++ b/test/e2e/specs/editor/various/post-editor-template-mode.spec.js @@ -102,6 +102,32 @@ test.describe( 'Post Editor Template mode', () => { ).toBeVisible(); } ); + test( 'Allow editing the title of a new custom template', async ( { + page, + postEditorTemplateMode, + } ) => { + async function editTemplateTitle( newTitle ) { + await page + .getByRole( 'button', { name: 'Template Options' } ) + .click(); + + await page + .getByRole( 'textbox', { name: 'Title' } ) + .fill( newTitle ); + + const editorContent = page.getByLabel( 'Editor Content' ); + await editorContent.click(); + } + + await postEditorTemplateMode.createPostAndSaveDraft(); + await postEditorTemplateMode.createNewTemplate( 'Foobar' ); + await editTemplateTitle( 'Barfoo' ); + + await expect( + page.getByRole( 'button', { name: 'Template Options' } ) + ).toHaveText( 'Barfoo' ); + } ); + test.describe( 'Delete Post Template Confirmation Dialog', () => { test.beforeAll( async ( { requestUtils } ) => { await requestUtils.activateTheme( 'twentytwentyone' ); diff --git a/test/e2e/specs/editor/various/switch-to-draft.spec.js b/test/e2e/specs/editor/various/switch-to-draft.spec.js index a501d8982947c..59837a3d3c765 100644 --- a/test/e2e/specs/editor/various/switch-to-draft.spec.js +++ b/test/e2e/specs/editor/various/switch-to-draft.spec.js @@ -35,6 +35,7 @@ test.describe( 'Clicking "Switch to draft" on a published/scheduled post/page', page, switchToDraftUtils, pageUtils, + editor, } ) => { await pageUtils.setBrowserViewport( viewport ); @@ -44,6 +45,8 @@ test.describe( 'Clicking "Switch to draft" on a published/scheduled post/page', postStatus === 'schedule' ); + await editor.openDocumentSettingsSidebar(); + await switchToDraftUtils.switchToDraftButton.click(); await page @@ -100,9 +103,9 @@ class SwitchToDraftUtils { this.#admin = admin; this.#requestUtils = requestUtils; - this.switchToDraftButton = page - .getByRole( 'region', { name: 'Editor top bar' } ) - .getByRole( 'button', { name: 'draft' } ); + this.switchToDraftButton = page.locator( + 'role=button[name="Switch to draft"i]' + ); } /** diff --git a/test/e2e/specs/editor/various/undo.spec.js b/test/e2e/specs/editor/various/undo.spec.js new file mode 100644 index 0000000000000..29b34ea416ff2 --- /dev/null +++ b/test/e2e/specs/editor/various/undo.spec.js @@ -0,0 +1,491 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.use( { + undoUtils: async ( { page }, use ) => { + await use( new UndoUtils( { page } ) ); + }, +} ); + +test.describe( 'undo', () => { + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost(); + } ); + + test( 'should undo typing after a pause', async ( { + editor, + page, + pageUtils, + undoUtils, + } ) => { + await page.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( 'before pause' ); + await editor.page.waitForTimeout( 1000 ); + await page.keyboard.type( ' after pause' ); + + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'before pause after pause' }, + }, + ] ); + + await pageUtils.pressKeys( 'primary+z' ); + + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'before pause' }, + }, + ] ); + await expect.poll( undoUtils.getSelection ).toEqual( { + blockIndex: 0, + startOffset: 'before pause'.length, + endOffset: 'before pause'.length, + } ); + + await pageUtils.pressKeys( 'primary+z' ); + + await expect.poll( editor.getEditedPostContent ).toBe( '' ); + await expect.poll( undoUtils.getSelection ).toEqual( { + blockIndex: 0, + } ); + + await pageUtils.pressKeys( 'primaryShift+z' ); + + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'before pause' }, + }, + ] ); + await expect.poll( undoUtils.getSelection ).toEqual( { + blockIndex: 0, + startOffset: 'before pause'.length, + endOffset: 'before pause'.length, + } ); + + await pageUtils.pressKeys( 'primaryShift+z' ); + + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'before pause after pause' }, + }, + ] ); + await expect.poll( undoUtils.getSelection ).toEqual( { + blockIndex: 0, + startOffset: 'before pause after pause'.length, + endOffset: 'before pause after pause'.length, + } ); + } ); + + test( 'should undo typing after non input change', async ( { + editor, + page, + pageUtils, + undoUtils, + } ) => { + await page.click( 'role=button[name="Add default block"i]' ); + + await page.keyboard.type( 'before keyboard ' ); + await pageUtils.pressKeys( 'primary+b' ); + await page.keyboard.type( 'after keyboard' ); + + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { + content: 'before keyboard after keyboard', + }, + }, + ] ); + + await pageUtils.pressKeys( 'primary+z' ); + + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { + content: 'before keyboard ', + }, + }, + ] ); + await expect.poll( undoUtils.getSelection ).toEqual( { + blockIndex: 0, + startOffset: 'before keyboard '.length, + endOffset: 'before keyboard '.length, + } ); + + await pageUtils.pressKeys( 'primary+z' ); + + await expect.poll( editor.getEditedPostContent ).toBe( '' ); + await expect.poll( undoUtils.getSelection ).toEqual( { + blockIndex: 0, + } ); + + await pageUtils.pressKeys( 'primaryShift+z' ); + + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { + content: 'before keyboard ', + }, + }, + ] ); + await expect.poll( undoUtils.getSelection ).toEqual( { + blockIndex: 0, + startOffset: 'before keyboard '.length, + endOffset: 'before keyboard '.length, + } ); + + await pageUtils.pressKeys( 'primaryShift+z' ); + + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { + content: 'before keyboard after keyboard', + }, + }, + ] ); + await expect.poll( undoUtils.getSelection ).toEqual( { + blockIndex: 0, + startOffset: 'before keyboard after keyboard'.length, + endOffset: 'before keyboard after keyboard'.length, + } ); + } ); + + test( 'should undo bold', async ( { page, pageUtils } ) => { + await page.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( 'test' ); + await page.click( 'role=button[name="Save draft"i]' ); + await expect( + page.locator( + 'role=button[name="Dismiss this notice"i] >> text=Draft saved' + ) + ).toBeVisible(); + await page.reload(); + await page.click( '[data-type="core/paragraph"]' ); + await pageUtils.pressKeys( 'primary+a' ); + await pageUtils.pressKeys( 'primary+b' ); + await pageUtils.pressKeys( 'primary+z' ); + const activeElementLocator = page.locator( ':focus' ); + await expect( activeElementLocator ).toHaveText( 'test' ); + } ); + + test( 'Should undo/redo to expected level intervals', async ( { + editor, + page, + pageUtils, + undoUtils, + } ) => { + await page.click( 'role=button[name="Add default block"i]' ); + + const firstBlock = await editor.getEditedPostContent(); + + await page.keyboard.type( 'This' ); + + const firstText = await editor.getEditedPostContent(); + + await page.keyboard.press( 'Enter' ); + + const secondBlock = await editor.getEditedPostContent(); + + await page.keyboard.type( 'is' ); + + const secondText = await editor.getEditedPostContent(); + + await page.keyboard.press( 'Enter' ); + + const thirdBlock = await editor.getEditedPostContent(); + + await page.keyboard.type( 'test' ); + + const thirdText = await editor.getEditedPostContent(); + + await pageUtils.pressKeys( 'primary+z' ); // Undo 3rd paragraph text. + + await expect.poll( editor.getEditedPostContent ).toBe( thirdBlock ); + await expect.poll( undoUtils.getSelection ).toEqual( { + blockIndex: 2, + } ); + + await pageUtils.pressKeys( 'primary+z' ); // Undo 3rd block. + + await expect.poll( editor.getEditedPostContent ).toBe( secondText ); + await expect.poll( undoUtils.getSelection ).toEqual( { + blockIndex: 1, + startOffset: 'is'.length, + endOffset: 'is'.length, + } ); + + await pageUtils.pressKeys( 'primary+z' ); // Undo 2nd paragraph text. + + await expect.poll( editor.getEditedPostContent ).toBe( secondBlock ); + await expect.poll( undoUtils.getSelection ).toEqual( { + blockIndex: 1, + } ); + + await pageUtils.pressKeys( 'primary+z' ); // Undo 2nd block. + + await expect.poll( editor.getEditedPostContent ).toBe( firstText ); + await expect.poll( undoUtils.getSelection ).toEqual( { + blockIndex: 0, + startOffset: 'This'.length, + endOffset: 'This'.length, + } ); + + await pageUtils.pressKeys( 'primary+z' ); // Undo 1st paragraph text. + + await expect.poll( editor.getEditedPostContent ).toBe( firstBlock ); + await expect.poll( undoUtils.getSelection ).toEqual( { + blockIndex: 0, + } ); + + await pageUtils.pressKeys( 'primary+z' ); // Undo 1st block. + + await expect.poll( editor.getEditedPostContent ).toBe( '' ); + await expect.poll( undoUtils.getSelection ).toEqual( {} ); + // After undoing every action, there should be no more undo history. + await expect( + page.locator( 'role=button[name="Undo"]' ) + ).toBeDisabled(); + + await pageUtils.pressKeys( 'primaryShift+z' ); // Redo 1st block. + + await expect.poll( editor.getEditedPostContent ).toBe( firstBlock ); + await expect.poll( undoUtils.getSelection ).toEqual( { + blockIndex: 0, + startOffset: 0, + endOffset: 0, + } ); + // After redoing one change, the undo button should be enabled again. + await expect( + page.locator( 'role=button[name="Undo"]' ) + ).toBeEnabled(); + + await pageUtils.pressKeys( 'primaryShift+z' ); // Redo 1st paragraph text. + + await expect.poll( editor.getEditedPostContent ).toBe( firstText ); + await expect.poll( undoUtils.getSelection ).toEqual( { + blockIndex: 0, + startOffset: 'This'.length, + endOffset: 'This'.length, + } ); + + await pageUtils.pressKeys( 'primaryShift+z' ); // Redo 2nd block. + + await expect.poll( editor.getEditedPostContent ).toBe( secondBlock ); + await expect.poll( undoUtils.getSelection ).toEqual( { + blockIndex: 1, + startOffset: 0, + endOffset: 0, + } ); + + await pageUtils.pressKeys( 'primaryShift+z' ); // Redo 2nd paragraph text. + + await expect.poll( editor.getEditedPostContent ).toBe( secondText ); + await expect.poll( undoUtils.getSelection ).toEqual( { + blockIndex: 1, + startOffset: 'is'.length, + endOffset: 'is'.length, + } ); + + await pageUtils.pressKeys( 'primaryShift+z' ); // Redo 3rd block. + + await expect.poll( editor.getEditedPostContent ).toBe( thirdBlock ); + await expect.poll( undoUtils.getSelection ).toEqual( { + blockIndex: 2, + startOffset: 0, + endOffset: 0, + } ); + + await pageUtils.pressKeys( 'primaryShift+z' ); // Redo 3rd paragraph text. + + await expect.poll( editor.getEditedPostContent ).toBe( thirdText ); + await expect.poll( undoUtils.getSelection ).toEqual( { + blockIndex: 2, + startOffset: 'test'.length, + endOffset: 'test'.length, + } ); + } ); + + test( 'should undo for explicit persistence editing post', async ( { + page, + pageUtils, + editor, + } ) => { + // Regression test: An issue had occurred where the creation of an + // explicit undo level would interfere with blocks values being synced + // correctly to the block editor. + // + // See: https://github.com/WordPress/gutenberg/issues/14950 + + // Issue is demonstrated from an edited post: create, save, and reload. + await page.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( 'original' ); + await page.click( 'role=button[name="Save draft"i]' ); + await expect( + page.locator( + 'role=button[name="Dismiss this notice"i] >> text=Draft saved' + ) + ).toBeVisible(); + await page.reload(); + + // Issue is demonstrated by forcing state merges (multiple inputs) on + // an existing text after a fresh reload. + await page.click( '[data-type="core/paragraph"] >> nth=0' ); + await page.keyboard.type( 'modified' ); + + // The issue is demonstrated after the one second delay to trigger the + // creation of an explicit undo persistence level. + await editor.page.waitForTimeout( 1000 ); + + await pageUtils.pressKeys( 'primary+z' ); + + // Assert against the _visible_ content. Since editor state with the + // regression present was accurate, it would produce the correct + // content. The issue had manifested in the form of what was shown to + // the user since the blocks state failed to sync to block editor. + const activeElementLocator = page.locator( ':focus' ); + await expect( activeElementLocator ).toHaveText( 'original' ); + } ); + + test( 'should not create undo levels when saving', async ( { + editor, + page, + pageUtils, + } ) => { + await page.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( '1' ); + await page.click( 'role=button[name="Save draft"i]' ); + await expect( + page.locator( + 'role=button[name="Dismiss this notice"i] >> text=Draft saved' + ) + ).toBeVisible(); + await pageUtils.pressKeys( 'primary+z' ); + + await expect.poll( editor.getEditedPostContent ).toBe( '' ); + } ); + + test( 'should not create undo levels when publishing', async ( { + editor, + page, + pageUtils, + } ) => { + await page.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( '1' ); + await editor.publishPost(); + await pageUtils.pressKeys( 'primary+z' ); + + await expect.poll( editor.getEditedPostContent ).toBe( '' ); + } ); + + test( 'should immediately create an undo level on typing', async ( { + editor, + page, + pageUtils, + } ) => { + await page.click( 'role=button[name="Add default block"i]' ); + + await page.keyboard.type( '1' ); + await page.click( 'role=button[name="Save draft"i]' ); + await expect( + page.locator( + 'role=button[name="Dismiss this notice"i] >> text=Draft saved' + ) + ).toBeVisible(); + await page.reload(); + + // Expect undo button to be disabled. + await expect( + page.locator( 'role=button[name="Undo"]' ) + ).toBeDisabled(); + await page.click( '[data-type="core/paragraph"]' ); + + await page.keyboard.type( '2' ); + + // Expect undo button to be enabled. + await expect( + page.locator( 'role=button[name="Undo"]' ) + ).toBeEnabled(); + + await pageUtils.pressKeys( 'primary+z' ); + + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { + content: '1', + }, + }, + ] ); + } ); + + test( 'should be able to undo and redo when transient changes have been made and we update/publish', async ( { + editor, + page, + pageUtils, + } ) => { + // Typing consecutive characters in a `Paragraph` block updates the same + // block attribute as in the previous action and results in transient edits + // and skipping `undo` history steps. + const text = 'tonis'; + await page.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( text ); + await editor.publishPost(); + await pageUtils.pressKeys( 'primary+z' ); + await expect.poll( editor.getEditedPostContent ).toBe( '' ); + await expect( + page.locator( 'role=button[name="Redo"]' ) + ).not.toBeDisabled(); + await page.click( 'role=button[name="Redo"]' ); + + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { + content: 'tonis', + }, + }, + ] ); + } ); +} ); + +class UndoUtils { + constructor( { page } ) { + this.page = page; + + this.getSelection = this.getSelection.bind( this ); + } + + async getSelection() { + return await this.page.evaluate( () => { + const selectedBlockId = window.wp.data + .select( 'core/block-editor' ) + .getSelectedBlockClientId(); + const blockIndex = window.wp.data + .select( 'core/block-editor' ) + .getBlockIndex( selectedBlockId ); + + if ( blockIndex === -1 ) { + return {}; + } + + return { + blockIndex, + startOffset: window.wp.data + .select( 'core/block-editor' ) + .getSelectionStart().offset, + endOffset: window.wp.data + .select( 'core/block-editor' ) + .getSelectionEnd().offset, + }; + } ); + } +} diff --git a/test/e2e/specs/site-editor/push-to-global-styles.spec.js b/test/e2e/specs/site-editor/push-to-global-styles.spec.js index 06c634a1f5498..4f51cbd88aad6 100644 --- a/test/e2e/specs/site-editor/push-to-global-styles.spec.js +++ b/test/e2e/specs/site-editor/push-to-global-styles.spec.js @@ -35,7 +35,6 @@ test.describe( 'Push to Global Styles button', () => { await page .getByRole( 'button', { name: 'Heading block styles' } ) .click(); - await page.getByRole( 'button', { name: 'Typography styles' } ).click(); // Headings should not have uppercase await expect( @@ -82,12 +81,13 @@ test.describe( 'Push to Global Styles button', () => { ).toBeDisabled(); // Navigate again to Styles -> Blocks -> Heading -> Typography - await page.getByRole( 'button', { name: 'Styles' } ).click(); + await page + .getByRole( 'button', { name: 'Styles', exact: true } ) + .click(); await page.getByRole( 'button', { name: 'Blocks styles' } ).click(); await page .getByRole( 'button', { name: 'Heading block styles' } ) .click(); - await page.getByRole( 'button', { name: 'Typography styles' } ).click(); // Headings should now have uppercase await expect( diff --git a/test/e2e/specs/site-editor/style-book.spec.js b/test/e2e/specs/site-editor/style-book.spec.js index 56d0ca0cf20f1..5cdf1c2a0e59e 100644 --- a/test/e2e/specs/site-editor/style-book.spec.js +++ b/test/e2e/specs/site-editor/style-book.spec.js @@ -27,7 +27,7 @@ test.describe( 'Style Book', () => { ).toBeVisible(); } ); - test( 'should disable toolbar butons when open', async ( { page } ) => { + test( 'should disable toolbar buttons when open', async ( { page } ) => { await expect( page.locator( 'role=button[name="Toggle block inserter"i]' ) ).not.toBeVisible(); @@ -111,7 +111,6 @@ test.describe( 'Style Book', () => { } ) => { await page.click( 'role=button[name="Blocks styles"]' ); await page.click( 'role=button[name="Heading block styles"]' ); - await page.click( 'role=button[name="Typography styles"]' ); await page .frameLocator( '[name="style-book-canvas"]' ) diff --git a/test/e2e/specs/site-editor/style-variations.spec.js b/test/e2e/specs/site-editor/style-variations.spec.js index 84a5eefb1cda7..f24f0691b42a4 100644 --- a/test/e2e/specs/site-editor/style-variations.spec.js +++ b/test/e2e/specs/site-editor/style-variations.spec.js @@ -86,13 +86,13 @@ test.describe( 'Global styles variations', () => { await expect( page.locator( - 'role=button[name="Background"i] >> .component-color-indicator' + 'role=button[name="Color Background styles"i] >> .component-color-indicator' ) ).toHaveCSS( 'background', /rgb\(202, 105, 211\)/ ); await expect( page.locator( - 'role=button[name="Text"i] >> .component-color-indicator' + 'role=button[name="Color Text styles"i] >> .component-color-indicator' ) ).toHaveCSS( 'background', /rgb\(74, 7, 74\)/ ); @@ -127,13 +127,13 @@ test.describe( 'Global styles variations', () => { await expect( page.locator( - 'role=button[name="Background"i] >> .component-color-indicator' + 'role=button[name="Color Background styles"i] >> .component-color-indicator' ) ).toHaveCSS( 'background', /rgb\(255, 239, 11\)/ ); await expect( page.locator( - 'role=button[name="Text"i] >> .component-color-indicator' + 'role=button[name="Color Text styles"i] >> .component-color-indicator' ) ).toHaveCSS( 'background', /rgb\(25, 25, 17\)/ ); diff --git a/test/e2e/specs/site-editor/title.spec.js b/test/e2e/specs/site-editor/title.spec.js index fada3fbe41e1e..585b75c1507be 100644 --- a/test/e2e/specs/site-editor/title.spec.js +++ b/test/e2e/specs/site-editor/title.spec.js @@ -61,7 +61,7 @@ test.describe( 'Site editor title', () => { 'role=treegrid[name="Block navigation structure"i]' ); await listView.locator( 'role=gridcell >> text="header"' ).click(); - await page.click( 'role=button[name="Close List View Sidebar"i]' ); + await page.click( 'role=button[name="Close"i]' ); // Evaluate the document settings secondary title. const secondaryTitle = page.locator( diff --git a/test/e2e/specs/site-editor/user-global-styles-revisions.spec.js b/test/e2e/specs/site-editor/user-global-styles-revisions.spec.js new file mode 100644 index 0000000000000..8459d148c7f00 --- /dev/null +++ b/test/e2e/specs/site-editor/user-global-styles-revisions.spec.js @@ -0,0 +1,168 @@ +/** + * WordPress dependencies + */ +const { + test, + expect, + Editor, +} = require( '@wordpress/e2e-test-utils-playwright' ); + +test.use( { + editor: async ( { page }, use ) => { + await use( new Editor( { page } ) ); + }, + userGlobalStylesRevisions: async ( { page, requestUtils }, use ) => { + await use( new UserGlobalStylesRevisions( { page, requestUtils } ) ); + }, +} ); + +test.describe( 'Global styles revisions', () => { + test.beforeAll( async ( { requestUtils } ) => { + await Promise.all( [ + requestUtils.activateTheme( 'emptytheme' ), + requestUtils.deleteAllTemplates( 'wp_template' ), + requestUtils.deleteAllTemplates( 'wp_template_part' ), + ] ); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.activateTheme( 'twentytwentyone' ); + } ); + + test.beforeEach( async ( { admin } ) => { + await admin.visitSiteEditor( { + canvas: 'edit', + } ); + } ); + + test( 'should display revisions UI when there is more than 1 revision', async ( { + page, + editor, + userGlobalStylesRevisions, + } ) => { + const currentRevisions = + await userGlobalStylesRevisions.getGlobalStylesRevisions(); + await userGlobalStylesRevisions.openStylesPanel(); + + // Change a style and save it. + await page.getByRole( 'button', { name: 'Colors styles' } ).click(); + + await page + .getByRole( 'button', { name: 'Color Background styles' } ) + .click(); + await page + .getByRole( 'button', { name: 'Color: Black' } ) + .click( { force: true } ); + + await editor.saveSiteEditorEntities(); + + /* + * Change a style and save it again. + * We need more than 2 revisions to show the UI. + */ + await page + .getByRole( 'button', { name: 'Color Background styles' } ) + .click(); + await page + .getByRole( 'button', { name: 'Color: Cyan bluish gray' } ) + .click( { force: true } ); + + await editor.saveSiteEditorEntities(); + + // Now there should be enough revisions to show the revisions UI. + await userGlobalStylesRevisions.openRevisions(); + + const revisionButtons = page.getByRole( 'button', { + name: /^Changes saved by /, + } ); + + await expect( revisionButtons ).toHaveCount( + currentRevisions.length + 2 + ); + } ); + + test( 'should warn of unsaved changes before loading reset revision', async ( { + page, + editor, + userGlobalStylesRevisions, + } ) => { + await userGlobalStylesRevisions.openStylesPanel(); + await page.getByRole( 'button', { name: 'Colors styles' } ).click(); + await page + .getByRole( 'button', { name: 'Color Background styles' } ) + .click(); + await page + .getByRole( 'button', { name: 'Color: Luminous vivid amber' } ) + .click( { force: true } ); + + await userGlobalStylesRevisions.openRevisions(); + + const unSavedButton = page.getByRole( 'button', { + name: /^Unsaved changes/, + } ); + + await expect( unSavedButton ).toBeVisible(); + + await page + .getByRole( 'button', { name: /^Changes saved by / } ) + .last() + .click(); + + await page.getByRole( 'button', { name: 'Apply' } ).click(); + + const confirm = page.getByRole( 'dialog' ); + await expect( confirm ).toBeVisible(); + await expect( confirm ).toHaveText( + /^Loading this revision will discard all unsaved changes/ + ); + + // This is to make sure there are no lingering unsaved changes. + await page + .getByRole( 'dialog' ) + .getByRole( 'button', { name: 'Cancel' } ) + .click(); + await editor.saveSiteEditorEntities(); + } ); +} ); + +class UserGlobalStylesRevisions { + constructor( { page, requestUtils } ) { + this.page = page; + this.requestUtils = requestUtils; + } + + async disableWelcomeGuide() { + // Turn off the welcome guide. + await this.page.evaluate( () => { + window.wp.data + .dispatch( 'core/preferences' ) + .set( 'core/edit-site', 'welcomeGuideStyles', false ); + } ); + } + + async getGlobalStylesRevisions() { + const stylesPostId = + await this.requestUtils.getCurrentThemeGlobalStylesPostId(); + if ( stylesPostId ) { + return await this.requestUtils.getThemeGlobalStylesRevisions( + stylesPostId + ); + } + return []; + } + + async openRevisions() { + await this.page + .getByRole( 'button', { name: 'Styles actions' } ) + .click(); + await this.page.getByRole( 'menuitem', { name: 'Revisions' } ).click(); + } + + async openStylesPanel() { + await this.disableWelcomeGuide(); + await this.page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'button', { name: 'Styles' } ) + .click(); + } +} diff --git a/test/native/__mocks__/styleMock.js b/test/native/__mocks__/styleMock.js index 7fd1637e07ba7..5b7087778e04c 100644 --- a/test/native/__mocks__/styleMock.js +++ b/test/native/__mocks__/styleMock.js @@ -178,4 +178,10 @@ module.exports = { mediaAreaPadding: { width: 12, }, + defaultAppender: { + marginLeft: 16, + }, + 'components-picker__button-title': { + color: 'white', + }, }; diff --git a/test/native/integration-test-helpers/README.md b/test/native/integration-test-helpers/README.md index 900f8f2fb896d..49b63794ed8ff 100644 --- a/test/native/integration-test-helpers/README.md +++ b/test/native/integration-test-helpers/README.md @@ -54,6 +54,10 @@ Changes the text of a RichText component. Paste content into a RichText component. +### [`setupApiFetch`](https://github.com/WordPress/gutenberg/blob/HEAD/test/native/integration-test-helpers/setup-api-fetch.js) + +Sets up the `apiFetch` library for testing by mocking request responses. + ### [`setupCoreBlocks`](https://github.com/WordPress/gutenberg/blob/HEAD/test/native/integration-test-helpers/setup-core-blocks.js) Registers all core blocks or a specific list of blocks before running tests, once the tests are run, all registered blocks are unregistered. @@ -66,6 +70,10 @@ Sets up Media Picker mock functions. Sets up the media upload mock functions for testing. +### [`setupPicker`](https://github.com/WordPress/gutenberg/blob/HEAD/test/native/integration-test-helpers/setup-picker.js) + +Sets up the Picker component for testing. + ### [`changeTextOfTextInput`](https://github.com/WordPress/gutenberg/blob/HEAD/test/native/integration-test-helpers/text-input-change-text.js) Changes the text of a TextInput component. diff --git a/test/native/integration-test-helpers/add-block.js b/test/native/integration-test-helpers/add-block.js index ec52453bebe34..197968be3edbf 100644 --- a/test/native/integration-test-helpers/add-block.js +++ b/test/native/integration-test-helpers/add-block.js @@ -1,12 +1,19 @@ +/** + * WordPress dependencies + */ +import { Platform } from '@wordpress/element'; + /** * External dependencies */ -import { fireEvent } from '@testing-library/react-native'; +import { act, fireEvent } from '@testing-library/react-native'; +import { AccessibilityInfo } from 'react-native'; /** * Internal dependencies */ import { waitFor } from './wait-for'; +import { withFakeTimers } from './with-fake-timers'; /** * Adds a block via the block picker. @@ -38,4 +45,13 @@ export const addBlock = async ( } ); fireEvent.press( await waitFor( () => getByText( blockName ) ) ); + + // On iOS the action for inserting a block is delayed (https://bit.ly/3AVALqH). + // Hence, we need to wait for the different steps until the the block is inserted. + if ( Platform.isIOS ) { + await withFakeTimers( async () => { + await AccessibilityInfo.isScreenReaderEnabled(); + act( () => jest.runOnlyPendingTimers() ); + } ); + } }; diff --git a/test/native/integration-test-helpers/index.js b/test/native/integration-test-helpers/index.js index 39f441f4fae93..13e74a69c4a66 100644 --- a/test/native/integration-test-helpers/index.js +++ b/test/native/integration-test-helpers/index.js @@ -14,9 +14,11 @@ export { openBlockSettings } from './open-block-settings'; export { selectRangeInRichText } from './rich-text-select-range'; export { typeInRichText } from './rich-text-type'; export { pasteIntoRichText } from './rich-text-paste'; +export { setupApiFetch } from './setup-api-fetch'; export { setupCoreBlocks } from './setup-core-blocks'; export { setupMediaPicker } from './setup-media-picker'; export { setupMediaUpload } from './setup-media-upload'; +export { setupPicker } from './setup-picker'; export { changeTextOfTextInput } from './text-input-change-text'; export { transformBlock } from './transform-block'; export { triggerBlockListLayout } from './trigger-block-list-layout'; diff --git a/test/native/integration-test-helpers/setup-api-fetch.js b/test/native/integration-test-helpers/setup-api-fetch.js new file mode 100644 index 0000000000000..e8b20e261630b --- /dev/null +++ b/test/native/integration-test-helpers/setup-api-fetch.js @@ -0,0 +1,50 @@ +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; + +/** + * Sets up the `apiFetch` library for testing by mocking request responses. + * + * Example: + * + * const responses = [ + * { + * request: { + * path: `/wp/v2/media/1?context=edit`, + * }, + * response: { + * source_url: 'https://image-1.jpg', + * id: 1, + * }, + * }, + * { + * request: { + * path: `/wp/v2/media/2?context=edit`, + * }, + * response: { + * source_url: 'https://image-2.jpg', + * id: 2, + * }, + * }, + * ]; + * setupApiFetch( responses ); + * ... + * expect( apiFetch ).toHaveBeenCalledWith( responses[1].request ); + * + * @param {object[]} responses Array with the potential responses to return upon requests. + * + */ +export function setupApiFetch( responses ) { + apiFetch.mockImplementation( async ( options ) => { + const matchedResponse = responses.find( + ( { request: { path, method } } ) => { + return ( + path === options.path && + ( ! options.method || method === options.method ) + ); + } + ); + return matchedResponse?.response; + } ); +} diff --git a/test/native/integration-test-helpers/setup-media-picker.js b/test/native/integration-test-helpers/setup-media-picker.js index 72646f0138e08..e99f918974750 100644 --- a/test/native/integration-test-helpers/setup-media-picker.js +++ b/test/native/integration-test-helpers/setup-media-picker.js @@ -20,9 +20,11 @@ import { requestMediaPicker } from '@wordpress/react-native-bridge'; */ export const setupMediaPicker = () => { let mediaPickerCallback; + let multipleItems; requestMediaPicker.mockImplementation( ( source, filter, multiple, callback ) => { mediaPickerCallback = callback; + multipleItems = multiple; } ); return { @@ -34,16 +36,25 @@ export const setupMediaPicker = () => { mediaPickerCallback ), mediaPickerCallback: async ( ...mediaItems ) => - act( async () => + act( async () => { + const items = mediaItems.map( + ( { + localId, + localUrl, + type = 'image', + url, + id, + metadata, + } ) => ( { + type, + url: url ?? localUrl, + id: id ?? localId, + metadata, + } ) + ); mediaPickerCallback( - mediaItems.map( - ( { localId, localUrl, type = 'image' } ) => ( { - type, - url: localUrl, - id: localId, - } ) - ) - ) - ), + items.length === 1 && ! multipleItems ? items[ 0 ] : items + ); + } ), }; }; diff --git a/test/native/integration-test-helpers/setup-media-upload.js b/test/native/integration-test-helpers/setup-media-upload.js index 1adf10bc0e240..5ce2d19c3e971 100644 --- a/test/native/integration-test-helpers/setup-media-upload.js +++ b/test/native/integration-test-helpers/setup-media-upload.js @@ -50,6 +50,7 @@ export const setupMediaUpload = () => { mediaId: mediaItem.localId, mediaUrl: mediaItem.serverUrl, mediaServerId: mediaItem.serverId, + metadata: mediaItem.metadata, } ); } ), notifyFailedState: async ( mediaItem ) => diff --git a/test/native/integration-test-helpers/setup-picker.js b/test/native/integration-test-helpers/setup-picker.js new file mode 100644 index 0000000000000..a762070631b4a --- /dev/null +++ b/test/native/integration-test-helpers/setup-picker.js @@ -0,0 +1,40 @@ +/** + * WordPress dependencies + */ +import { Platform } from '@wordpress/element'; + +/** + * External dependencies + */ +import { fireEvent } from '@testing-library/react-native'; +import { ActionSheetIOS } from 'react-native'; + +/** + * Sets up the Picker component for testing. + * + * @typedef {Object} PickerMockFunctions + * @property {Function} selectOption Selects one of the options of the picker. + * + * @param {import('@testing-library/react-native').RenderAPI} screen A Testing Library screen. + * @param {string[]} options Array with the options of the picker. + * + * @return {PickerMockFunctions} Picker functions. + */ +export function setupPicker( screen, options ) { + let selectOption = ( option ) => { + fireEvent.press( screen.getByText( option ) ); + }; + if ( Platform.isIOS ) { + let onOptionSelected; + ActionSheetIOS.showActionSheetWithOptions.mockImplementation( + ( _, callback ) => { + onOptionSelected = callback; + } + ); + // The index passed is incremented by one as the first + // option of the picker is `Cancel`. + selectOption = ( option ) => + onOptionSelected( options.indexOf( option ) + 1 ); + } + return { selectOption }; +} diff --git a/test/native/integration-test-helpers/wait-for-store-resolvers.js b/test/native/integration-test-helpers/wait-for-store-resolvers.js index ce4fa664e1a2c..ebf2c9cd1a1e7 100644 --- a/test/native/integration-test-helpers/wait-for-store-resolvers.js +++ b/test/native/integration-test-helpers/wait-for-store-resolvers.js @@ -25,7 +25,7 @@ export async function waitForStoreResolvers( fn ) { const result = fn(); // Advance all timers allowing store resolvers to resolve. - act( () => jest.runAllTimers() ); + act( () => jest.runOnlyPendingTimers() ); // The store resolvers perform several API fetches during editor // initialization. The most straightforward approach to ensure all of them diff --git a/test/native/setup.js b/test/native/setup.js index e9eeca07b2779..be643a1fb9017 100644 --- a/test/native/setup.js +++ b/test/native/setup.js @@ -228,6 +228,9 @@ jest.mock( }, } ) ); +jest.mock( 'react-native/Libraries/ActionSheetIOS/ActionSheetIOS', () => ( { + showActionSheetWithOptions: jest.fn(), +} ) ); // The mock provided by the package itself does not appear to work correctly. // Specifically, the mock provides a named export, where the module itself uses diff --git a/tools/webpack/blocks.js b/tools/webpack/blocks.js index f2c30984141fb..d8bcf0559fdbd 100644 --- a/tools/webpack/blocks.js +++ b/tools/webpack/blocks.js @@ -74,134 +74,210 @@ const createEntrypoints = () => { }, {} ); }; -module.exports = { - ...baseConfig, - name: 'blocks', - entry: createEntrypoints(), - output: { - devtoolNamespace: 'wp', - filename: './build/block-library/blocks/[name].min.js', - path: join( __dirname, '..', '..' ), - }, - plugins: [ - ...plugins, - new DependencyExtractionWebpackPlugin( { injectPolyfill: false } ), - new CopyWebpackPlugin( { - patterns: [].concat( - [ - 'style', - 'style-rtl', - 'editor', - 'editor-rtl', - 'theme', - 'theme-rtl', - ].map( ( filename ) => ( { - from: `./packages/block-library/build-style/*/${ filename }.css`, - to( { absoluteFilename } ) { - const [ , dirname ] = absoluteFilename.match( - new RegExp( - `([\\w-]+)${ escapeRegExp( - sep - ) }${ filename }\\.css$` - ) - ); - - return join( - 'build/block-library/blocks', - dirname, - filename + '.css' - ); - }, - transform: stylesTransform, - } ) ), - Object.entries( { - './packages/block-library/src/': - 'build/block-library/blocks/', - './packages/edit-widgets/src/blocks/': - 'build/edit-widgets/blocks/', - './packages/widgets/src/blocks/': 'build/widgets/blocks/', - } ).flatMap( ( [ from, to ] ) => [ - { - from: `${ from }/**/index.php`, +module.exports = [ + { + ...baseConfig, + name: 'blocks', + entry: createEntrypoints(), + output: { + devtoolNamespace: 'wp', + filename: './build/block-library/blocks/[name].min.js', + path: join( __dirname, '..', '..' ), + }, + plugins: [ + ...plugins, + new DependencyExtractionWebpackPlugin( { injectPolyfill: false } ), + new CopyWebpackPlugin( { + patterns: [].concat( + [ + 'style', + 'style-rtl', + 'editor', + 'editor-rtl', + 'theme', + 'theme-rtl', + ].map( ( filename ) => ( { + from: `./packages/block-library/build-style/*/${ filename }.css`, to( { absoluteFilename } ) { const [ , dirname ] = absoluteFilename.match( new RegExp( `([\\w-]+)${ escapeRegExp( sep - ) }index\\.php$` + ) }${ filename }\\.css$` ) ); - return join( to, `${ dirname }.php` ); + return join( + 'build/block-library/blocks', + dirname, + filename + '.css' + ); }, - transform: ( content ) => { - const prefix = 'gutenberg_'; - content = content.toString(); + transform: stylesTransform, + } ) ), + Object.entries( { + './packages/block-library/src/': + 'build/block-library/blocks/', + './packages/edit-widgets/src/blocks/': + 'build/edit-widgets/blocks/', + './packages/widgets/src/blocks/': + 'build/widgets/blocks/', + } ).flatMap( ( [ from, to ] ) => [ + { + from: `${ from }/**/index.php`, + to( { absoluteFilename } ) { + const [ , dirname ] = absoluteFilename.match( + new RegExp( + `([\\w-]+)${ escapeRegExp( + sep + ) }index\\.php$` + ) + ); - // Within content, search and prefix any function calls from - // `prefixFunctions` list. This is needed because some functions - // are called inside block files, but have been declared elsewhere. - // So with the rename we can call Gutenberg override functions, but the - // block will still call the core function when updates are back ported. - content = content.replace( - new RegExp( prefixFunctions.join( '|' ), 'g' ), - ( match ) => - `${ prefix }${ match.replace( - /^wp_/, - '' - ) }` - ); + return join( to, `${ dirname }.php` ); + }, + transform: ( content ) => { + const prefix = 'gutenberg_'; + content = content.toString(); - // Within content, search for any function definitions. For - // each, replace every other reference to it in the file. - return ( - Array.from( - content.matchAll( - /^\s*function ([^\(]+)/gm - ) - ) - .reduce( ( result, [ , functionName ] ) => { - // Prepend the Gutenberg prefix, substituting any - // other core prefix (e.g. "wp_"). - return result.replace( - new RegExp( functionName, 'g' ), - ( match ) => - prefix + - match.replace( /^wp_/, '' ) - ); - }, content ) - // The core blocks override procedure takes place in - // the init action default priority to ensure that core - // blocks would have been registered already. Since the - // blocks implementations occur at the default priority - // and due to WordPress hooks behavior not considering - // mutations to the same priority during another's - // callback, the Gutenberg build blocks are modified - // to occur at a later priority. - .replace( - /(add_action\(\s*'init',\s*'gutenberg_register_block_[^']+'(?!,))/, - '$1, 20' + // Within content, search and prefix any function calls from + // `prefixFunctions` list. This is needed because some functions + // are called inside block files, but have been declared elsewhere. + // So with the rename we can call Gutenberg override functions, but the + // block will still call the core function when updates are back ported. + content = content.replace( + new RegExp( + prefixFunctions.join( '|' ), + 'g' + ), + ( match ) => + `${ prefix }${ match.replace( + /^wp_/, + '' + ) }` + ); + + // Within content, search for any function definitions. For + // each, replace every other reference to it in the file. + return ( + Array.from( + content.matchAll( + /^\s*function ([^\(]+)/gm + ) ) - ); + .reduce( + ( result, [ , functionName ] ) => { + // Prepend the Gutenberg prefix, substituting any + // other core prefix (e.g. "wp_"). + return result.replace( + new RegExp( + functionName, + 'g' + ), + ( match ) => + prefix + + match.replace( + /^wp_/, + '' + ) + ); + }, + content + ) + // The core blocks override procedure takes place in + // the init action default priority to ensure that core + // blocks would have been registered already. Since the + // blocks implementations occur at the default priority + // and due to WordPress hooks behavior not considering + // mutations to the same priority during another's + // callback, the Gutenberg build blocks are modified + // to occur at a later priority. + .replace( + /(add_action\(\s*'init',\s*'gutenberg_register_block_[^']+'(?!,))/, + '$1, 20' + ) + ); + }, + noErrorOnMissing: true, }, - noErrorOnMissing: true, - }, - { - from: `${ from }/*/block.json`, - to( { absoluteFilename } ) { - const [ , dirname ] = absoluteFilename.match( - new RegExp( - `([\\w-]+)${ escapeRegExp( - sep - ) }block\\.json$` - ) - ); + { + from: `${ from }/*/block.json`, + to( { absoluteFilename } ) { + const [ , dirname ] = absoluteFilename.match( + new RegExp( + `([\\w-]+)${ escapeRegExp( + sep + ) }block\\.json$` + ) + ); - return join( to, dirname, 'block.json' ); + return join( to, dirname, 'block.json' ); + }, }, + ] ) + ), + } ), + ].filter( Boolean ), + }, + { + entry: { + navigation: + './packages/block-library/src/navigation/interactivity.js', + }, + output: { + devtoolNamespace: 'wp', + filename: './build/block-library/interactive-blocks/[name].min.js', + path: join( __dirname, '..', '..' ), + }, + optimization: { + runtimeChunk: { + name: 'vendors', + }, + splitChunks: { + cacheGroups: { + vendors: { + name: 'vendors', + test: /[\\/]node_modules[\\/]/, + minSize: 0, + chunks: 'all', }, - ] ) - ), - } ), - ].filter( Boolean ), -}; + interactivity: { + name: 'interactivity', + test: /[\\/]utils\/interactivity[\\/]/, + chunks: 'all', + minSize: 0, + priority: -10, + }, + }, + }, + }, + module: { + rules: [ + { + test: /\.(j|t)sx?$/, + exclude: /node_modules/, + use: [ + { + loader: require.resolve( 'babel-loader' ), + options: { + cacheDirectory: + process.env.BABEL_CACHE_DIRECTORY || true, + babelrc: false, + configFile: false, + presets: [ + [ + '@babel/preset-react', + { + runtime: 'automatic', + importSource: 'preact', + }, + ], + ], + }, + }, + ], + }, + ], + }, + }, +]; diff --git a/tsconfig.json b/tsconfig.json index d933bc14c84e4..7f4d4054bea88 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -40,6 +40,7 @@ { "path": "packages/react-i18n" }, { "path": "packages/redux-routine" }, { "path": "packages/report-flaky-tests" }, + { "path": "packages/rich-text" }, { "path": "packages/style-engine" }, { "path": "packages/token-list" }, { "path": "packages/url" }, diff --git a/webpack.config.js b/webpack.config.js index 9a29ed7782268..f1c5ce803adc1 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -5,4 +5,4 @@ const blocksConfig = require( './tools/webpack/blocks' ); const developmentConfigs = require( './tools/webpack/development' ); const packagesConfig = require( './tools/webpack/packages' ); -module.exports = [ blocksConfig, packagesConfig, ...developmentConfigs ]; +module.exports = [ ...blocksConfig, packagesConfig, ...developmentConfigs ];