From d43ce7c0bb4d84a7928826df884a57e747589e29 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 29 Mar 2026 12:28:54 -0400 Subject: [PATCH 01/23] build(deps): Bump @wordpress/wordcount from 4.41.0 to 4.42.0 (#408) Bumps [@wordpress/wordcount](https://github.com/WordPress/gutenberg/tree/HEAD/packages/wordcount) from 4.41.0 to 4.42.0. - [Release notes](https://github.com/WordPress/gutenberg/releases) - [Changelog](https://github.com/WordPress/gutenberg/blob/trunk/packages/wordcount/CHANGELOG.md) - [Commits](https://github.com/WordPress/gutenberg/commits/@wordpress/wordcount@4.42.0/packages/wordcount) --- updated-dependencies: - dependency-name: "@wordpress/wordcount" dependency-version: 4.42.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8d9fe2755..ea3091de4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,7 +59,7 @@ "@wordpress/viewport": "^6.38.0", "@wordpress/warning": "^3.42.0", "@wordpress/widgets": "^4.38.0", - "@wordpress/wordcount": "^4.38.0", + "@wordpress/wordcount": "^4.42.0", "clsx": "^2.1.1", "jquery": "^3.7.1", "lodash": "^4.17.23", @@ -10017,9 +10017,9 @@ } }, "node_modules/@wordpress/wordcount": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@wordpress/wordcount/-/wordcount-4.41.0.tgz", - "integrity": "sha512-/22Fon1owjl4VnSD6ewReUZ+wEcNUsxEcZoKF/ew0CqOaprMwog3kPWq3Jub6Mt0aBWmjtrJ7Nhuo+wVkQ3Kog==", + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@wordpress/wordcount/-/wordcount-4.42.0.tgz", + "integrity": "sha512-H27okPtQPwgvuLNijYBRjFTbPx9ogSCKvly1/Ps/FFJ8xv1YCL/fPcSFwQ5limXikX0gr4o5DN9PbF22jvUe8A==", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", diff --git a/package.json b/package.json index b46d2f2d6..e88295087 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ "@wordpress/viewport": "^6.38.0", "@wordpress/warning": "^3.42.0", "@wordpress/widgets": "^4.38.0", - "@wordpress/wordcount": "^4.38.0", + "@wordpress/wordcount": "^4.42.0", "clsx": "^2.1.1", "jquery": "^3.7.1", "lodash": "^4.17.23", From 24387519c9b95fcc857edb30f660b40d0bc169ac Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 29 Mar 2026 16:44:20 +0000 Subject: [PATCH 02/23] build(deps): Bump @wordpress/primitives from 4.41.0 to 4.42.0 (#406) Bumps [@wordpress/primitives](https://github.com/WordPress/gutenberg/tree/HEAD/packages/primitives) from 4.41.0 to 4.42.0. - [Release notes](https://github.com/WordPress/gutenberg/releases) - [Changelog](https://github.com/WordPress/gutenberg/blob/trunk/packages/primitives/CHANGELOG.md) - [Commits](https://github.com/WordPress/gutenberg/commits/@wordpress/primitives@4.42.0/packages/primitives) --- updated-dependencies: - dependency-name: "@wordpress/primitives" dependency-version: 4.42.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 10 +++++----- package.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index ea3091de4..cc2d94ac8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,7 +46,7 @@ "@wordpress/plugins": "^7.38.0", "@wordpress/preferences": "^4.38.0", "@wordpress/preferences-persistence": "^2.41.0", - "@wordpress/primitives": "^4.38.0", + "@wordpress/primitives": "^4.42.0", "@wordpress/priority-queue": "^3.38.0", "@wordpress/private-apis": "^1.38.0", "@wordpress/rich-text": "^7.38.0", @@ -9587,12 +9587,12 @@ } }, "node_modules/@wordpress/primitives": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@wordpress/primitives/-/primitives-4.41.0.tgz", - "integrity": "sha512-ohPpBUkjkYQPki6Nl2BneFYapJE5xpxONVJfGMg0FGBqvzbuyR8pmGeH4PVfQGeGJ4xUwSdNcxfQ6nzyoT4bLA==", + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@wordpress/primitives/-/primitives-4.42.0.tgz", + "integrity": "sha512-kXqtXZcLNfMR3EiGm5XBilsDIf5+hmiQ+xPpJjYqcN7HHcyeE5+TwIzeuSdO2i8RnKnRYKV7yhj6EmmZr6ldYQ==", "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/element": "^6.41.0", + "@wordpress/element": "^6.42.0", "clsx": "^2.1.1" }, "engines": { diff --git a/package.json b/package.json index e88295087..8cfb608af 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "@wordpress/plugins": "^7.38.0", "@wordpress/preferences": "^4.38.0", "@wordpress/preferences-persistence": "^2.41.0", - "@wordpress/primitives": "^4.38.0", + "@wordpress/primitives": "^4.42.0", "@wordpress/priority-queue": "^3.38.0", "@wordpress/private-apis": "^1.38.0", "@wordpress/rich-text": "^7.38.0", From c82cad3764bfdcf151905e7fb06f6a980551b2a8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 01:03:40 +0000 Subject: [PATCH 03/23] build(deps): Bump @wordpress/rich-text from 7.41.0 to 7.42.0 (#407) * build(deps): Bump @wordpress/rich-text from 7.41.0 to 7.42.0 Bumps [@wordpress/rich-text](https://github.com/WordPress/gutenberg/tree/HEAD/packages/rich-text) from 7.41.0 to 7.42.0. - [Release notes](https://github.com/WordPress/gutenberg/releases) - [Changelog](https://github.com/WordPress/gutenberg/blob/trunk/packages/rich-text/CHANGELOG.md) - [Commits](https://github.com/WordPress/gutenberg/commits/@wordpress/rich-text@7.42.0/packages/rich-text) --- updated-dependencies: - dependency-name: "@wordpress/rich-text" dependency-version: 7.42.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * build: Generate @wordpress/rich-text patch --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: David Calhoun --- package-lock.json | 44 +++++++++---------- package.json | 2 +- ...atch => @wordpress+rich-text+7.42.0.patch} | 0 3 files changed, 23 insertions(+), 23 deletions(-) rename patches/{@wordpress+rich-text+7.41.0.patch => @wordpress+rich-text+7.42.0.patch} (100%) diff --git a/package-lock.json b/package-lock.json index cc2d94ac8..b5635b0d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,7 +49,7 @@ "@wordpress/primitives": "^4.42.0", "@wordpress/priority-queue": "^3.38.0", "@wordpress/private-apis": "^1.38.0", - "@wordpress/rich-text": "^7.38.0", + "@wordpress/rich-text": "^7.42.0", "@wordpress/router": "^1.38.0", "@wordpress/server-side-render": "^6.14.0", "@wordpress/shortcode": "^4.38.0", @@ -7859,13 +7859,13 @@ } }, "node_modules/@wordpress/a11y": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@wordpress/a11y/-/a11y-4.41.0.tgz", - "integrity": "sha512-OMv/whQt3eTftN1EIZ1FjbuYQUATzFKUEv+qE8mvfOWTX2wEcVIXrSDJa8iL+h+lpIbsWiwxFYiRlyXSmzVqkQ==", + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@wordpress/a11y/-/a11y-4.42.0.tgz", + "integrity": "sha512-7NdHXJt3XDBXujkhoZkuBzZJIY+LrG0r2aPSJVujoHwioBR3V+HgdcGa1xydAnKtdiNnYa8fEdlWqk49EEQ0MA==", "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/dom-ready": "^4.41.0", - "@wordpress/i18n": "^6.14.0" + "@wordpress/dom-ready": "^4.42.0", + "@wordpress/i18n": "^6.15.0" }, "engines": { "node": ">=18.12.0", @@ -8502,9 +8502,9 @@ } }, "node_modules/@wordpress/dom-ready": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@wordpress/dom-ready/-/dom-ready-4.41.0.tgz", - "integrity": "sha512-cxYJYa4UOmmn4LZibNAREz0RnMmiv2LNxPZl6OcxnZAv8X2fwhU6bbUO//avdhar1cki2bdntISawsc0Rcf0xg==", + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@wordpress/dom-ready/-/dom-ready-4.42.0.tgz", + "integrity": "sha512-ujShJCmo9Y6yqX9tD7100M7MwiEPiDsaN2OQzX1vA+2lp099muq73376zv9ISBkK0C8uUbMZzw2B+JTmBiyGtw==", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", @@ -9673,21 +9673,21 @@ } }, "node_modules/@wordpress/rich-text": { - "version": "7.41.0", - "resolved": "https://registry.npmjs.org/@wordpress/rich-text/-/rich-text-7.41.0.tgz", - "integrity": "sha512-JJn63a5XXqrQkKBiJX22zkmuDo7Mk7es35I5y5aP3dsVpNyMHLujXk3CjaMVtZ3ye6j20jjOXWoR0KLViiREQw==", + "version": "7.42.0", + "resolved": "https://registry.npmjs.org/@wordpress/rich-text/-/rich-text-7.42.0.tgz", + "integrity": "sha512-BmX2JZd5a53/FdAjuHJDswQD1YEmHrx8fhf921K8YbP/h043qwfjTPp1FVBxAmXP02QWGrgE2iSZEZWzbG+ACw==", "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/a11y": "^4.41.0", - "@wordpress/compose": "^7.41.0", - "@wordpress/data": "^10.41.0", - "@wordpress/deprecated": "^4.41.0", - "@wordpress/dom": "^4.41.0", - "@wordpress/element": "^6.41.0", - "@wordpress/escape-html": "^3.41.0", - "@wordpress/i18n": "^6.14.0", - "@wordpress/keycodes": "^4.41.0", - "@wordpress/private-apis": "^1.41.0", + "@wordpress/a11y": "^4.42.0", + "@wordpress/compose": "^7.42.0", + "@wordpress/data": "^10.42.0", + "@wordpress/deprecated": "^4.42.0", + "@wordpress/dom": "^4.42.0", + "@wordpress/element": "^6.42.0", + "@wordpress/escape-html": "^3.42.0", + "@wordpress/i18n": "^6.15.0", + "@wordpress/keycodes": "^4.42.0", + "@wordpress/private-apis": "^1.42.0", "colord": "2.9.3", "memize": "^2.1.0" }, diff --git a/package.json b/package.json index 8cfb608af..a865f4b2e 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "@wordpress/primitives": "^4.42.0", "@wordpress/priority-queue": "^3.38.0", "@wordpress/private-apis": "^1.38.0", - "@wordpress/rich-text": "^7.38.0", + "@wordpress/rich-text": "^7.42.0", "@wordpress/router": "^1.38.0", "@wordpress/server-side-render": "^6.14.0", "@wordpress/shortcode": "^4.38.0", diff --git a/patches/@wordpress+rich-text+7.41.0.patch b/patches/@wordpress+rich-text+7.42.0.patch similarity index 100% rename from patches/@wordpress+rich-text+7.41.0.patch rename to patches/@wordpress+rich-text+7.42.0.patch From e564dd012c5ff6738766bb46629146ffa581d84d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 01:21:04 +0000 Subject: [PATCH 04/23] build(deps): Bump @wordpress/global-styles-engine from 1.8.0 to 1.9.0 (#409) Bumps [@wordpress/global-styles-engine](https://github.com/WordPress/gutenberg/tree/HEAD/packages/global-styles-engine) from 1.8.0 to 1.9.0. - [Release notes](https://github.com/WordPress/gutenberg/releases) - [Commits](https://github.com/WordPress/gutenberg/commits/@wordpress/global-styles-engine@1.9.0/packages/global-styles-engine) --- updated-dependencies: - dependency-name: "@wordpress/global-styles-engine" dependency-version: 1.9.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 82 +++++++++++++++++++++++------------------------ package.json | 2 +- 2 files changed, 42 insertions(+), 42 deletions(-) diff --git a/package-lock.json b/package-lock.json index b5635b0d0..6a7761016 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ "@wordpress/element": "^6.38.0", "@wordpress/escape-html": "^3.38.0", "@wordpress/format-library": "^5.41.0", - "@wordpress/global-styles-engine": "^1.5.0", + "@wordpress/global-styles-engine": "^1.9.0", "@wordpress/hooks": "^4.38.0", "@wordpress/html-entities": "^4.38.0", "@wordpress/i18n": "^6.11.0", @@ -7983,9 +7983,9 @@ } }, "node_modules/@wordpress/blob": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@wordpress/blob/-/blob-4.41.0.tgz", - "integrity": "sha512-EDAs8jcRPG6sF6SEEfmNErKpgnuZSUy3f1i2N1gBs82IsNuluCplkyszK3J4wtr5Nu8FyQtL8oMZYoIybt0Kkg==", + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@wordpress/blob/-/blob-4.42.0.tgz", + "integrity": "sha512-UwUUrdmjEJTgvelFdVg7c7s/240Siv6bFIurv0o9XY4Bu46z4NHJI96nUiziKRNTRm0lcvzS6CZxeRTT/m1Fig==", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", @@ -8123,9 +8123,9 @@ } }, "node_modules/@wordpress/block-serialization-default-parser": { - "version": "5.41.0", - "resolved": "https://registry.npmjs.org/@wordpress/block-serialization-default-parser/-/block-serialization-default-parser-5.41.0.tgz", - "integrity": "sha512-rJdhfuWhLdvc0zGPUzYbnXb9cN5awQo8/XDxEH9q/5NJUFT/y0N1r5Cws2Cws03qiQLJyKCR0DML7M69V6T/6Q==", + "version": "5.42.0", + "resolved": "https://registry.npmjs.org/@wordpress/block-serialization-default-parser/-/block-serialization-default-parser-5.42.0.tgz", + "integrity": "sha512-XcX6gOeQOuG0RrUqJV1dadPBUi77uhLhpGfQH/s8vmAEGSWqgJAbjWwUKD8RP6wCGY+IE3Gayd5zu48aVRlB4A==", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", @@ -8133,26 +8133,26 @@ } }, "node_modules/@wordpress/blocks": { - "version": "15.14.0", - "resolved": "https://registry.npmjs.org/@wordpress/blocks/-/blocks-15.14.0.tgz", - "integrity": "sha512-k78BDR4IV+V+n/py6e5br89yxdqTPmUec72aUoiJwnm3FHWHjy6xc2t792WeTMYOsctj1lQFPfxp7LcCuptMWQ==", + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/@wordpress/blocks/-/blocks-15.15.0.tgz", + "integrity": "sha512-xUlxCqIV8UuH5MjtPQBnxEwvhe2CCrZ+WqGJ+ffLxQQGHbe513zXl8sr79FdE2noec4Bj8VdOp1oH8zlESQEUA==", "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/autop": "^4.41.0", - "@wordpress/blob": "^4.41.0", - "@wordpress/block-serialization-default-parser": "^5.41.0", - "@wordpress/data": "^10.41.0", - "@wordpress/deprecated": "^4.41.0", - "@wordpress/dom": "^4.41.0", - "@wordpress/element": "^6.41.0", - "@wordpress/hooks": "^4.41.0", - "@wordpress/html-entities": "^4.41.0", - "@wordpress/i18n": "^6.14.0", - "@wordpress/is-shallow-equal": "^5.41.0", - "@wordpress/private-apis": "^1.41.0", - "@wordpress/rich-text": "^7.41.0", - "@wordpress/shortcode": "^4.41.0", - "@wordpress/warning": "^3.41.0", + "@wordpress/autop": "^4.42.0", + "@wordpress/blob": "^4.42.0", + "@wordpress/block-serialization-default-parser": "^5.42.0", + "@wordpress/data": "^10.42.0", + "@wordpress/deprecated": "^4.42.0", + "@wordpress/dom": "^4.42.0", + "@wordpress/element": "^6.42.0", + "@wordpress/hooks": "^4.42.0", + "@wordpress/html-entities": "^4.42.0", + "@wordpress/i18n": "^6.15.0", + "@wordpress/is-shallow-equal": "^5.42.0", + "@wordpress/private-apis": "^1.42.0", + "@wordpress/rich-text": "^7.42.0", + "@wordpress/shortcode": "^4.42.0", + "@wordpress/warning": "^3.42.0", "change-case": "^4.1.2", "colord": "^2.7.0", "fast-deep-equal": "^3.1.3", @@ -9134,15 +9134,15 @@ } }, "node_modules/@wordpress/global-styles-engine": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@wordpress/global-styles-engine/-/global-styles-engine-1.8.0.tgz", - "integrity": "sha512-l8eOwkihry/rBwTv50ihNBojhWL9dG/0mztgVMzW7Kp5mh0jiomVGtJRPggfWiYVecOuFg4WkDncEUN9HW8poA==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@wordpress/global-styles-engine/-/global-styles-engine-1.9.0.tgz", + "integrity": "sha512-dYg1XYyaDQDzFnmlGUUqStdb672VsjfHfi0JZrkuTfSVQ7GjgV2MTmft2S4ZaUx+fIyKzbDWXn8HNVi4apGgcA==", "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/blocks": "^15.14.0", - "@wordpress/data": "^10.41.0", - "@wordpress/i18n": "^6.14.0", - "@wordpress/style-engine": "^2.41.0", + "@wordpress/blocks": "^15.15.0", + "@wordpress/data": "^10.42.0", + "@wordpress/i18n": "^6.15.0", + "@wordpress/style-engine": "^2.42.0", "colord": "^2.9.2", "deepmerge": "^4.3.0", "fast-deep-equal": "^3.1.3", @@ -9200,9 +9200,9 @@ } }, "node_modules/@wordpress/html-entities": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@wordpress/html-entities/-/html-entities-4.41.0.tgz", - "integrity": "sha512-Q+3VNVZiHqzWgrhNNsqrOp+LWt1cDANcP46VuJ6cOOKazA1vA2JcR/0QEoa7HrYCS+KdCCFdwuUpbjQfw2wgrQ==", + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@wordpress/html-entities/-/html-entities-4.42.0.tgz", + "integrity": "sha512-uI1vF5+KCdxQiPY4rzkaM1oZY5SX1lvSB+Uxndv2WCmc3lvTwabYos7wfXVYySxHRMOrG6KsqOWDzaK7h/b3NQ==", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", @@ -9764,9 +9764,9 @@ } }, "node_modules/@wordpress/shortcode": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@wordpress/shortcode/-/shortcode-4.41.0.tgz", - "integrity": "sha512-OpeBbl4o5Xp/5cAyvG7ObYo01Of/vLHJPf+Dh8VcFsKuH7zEz0uHaHj5YmS0dHl30mnjrZVVX5eX30Y3JrWFkQ==", + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@wordpress/shortcode/-/shortcode-4.42.0.tgz", + "integrity": "sha512-vaXEGjis5IqvPtSMYZgrT2zg5HwjePrs5fgWCwYfX5r/uiizfkeOSedpTBSH/FLpQQTMMeFsr22DLcuF0qdyeA==", "license": "GPL-2.0-or-later", "dependencies": { "memize": "^2.0.1" @@ -9777,9 +9777,9 @@ } }, "node_modules/@wordpress/style-engine": { - "version": "2.41.0", - "resolved": "https://registry.npmjs.org/@wordpress/style-engine/-/style-engine-2.41.0.tgz", - "integrity": "sha512-wqj31IMQWsKGuZZfhYfaShtDFuzyH6xk7oQRGemgnULeTcuZLks4pxQye9IF6/0yEnCeUdDmng3nX9t+scb7VA==", + "version": "2.42.0", + "resolved": "https://registry.npmjs.org/@wordpress/style-engine/-/style-engine-2.42.0.tgz", + "integrity": "sha512-mrqmz7Ldp5d150oIQdoMvMRFtWXHZbkoeOYKpxPOeo2EwNldkU5zQSkU196/Z7nFvMNKr9yMt+OgnpWTIZvpcg==", "license": "GPL-2.0-or-later", "dependencies": { "change-case": "^4.1.2" diff --git a/package.json b/package.json index a865f4b2e..84e097110 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "@wordpress/element": "^6.38.0", "@wordpress/escape-html": "^3.38.0", "@wordpress/format-library": "^5.41.0", - "@wordpress/global-styles-engine": "^1.5.0", + "@wordpress/global-styles-engine": "^1.9.0", "@wordpress/hooks": "^4.38.0", "@wordpress/html-entities": "^4.38.0", "@wordpress/i18n": "^6.11.0", From c1306f1e1b6b6348ee3739463e74e7b291514d94 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:51:54 -0400 Subject: [PATCH 05/23] build(deps): Bump @wordpress/preferences from 4.41.0 to 4.42.0 (#405) --- package-lock.json | 136 +++++++++++++++++++++++++++++----------------- package.json | 2 +- 2 files changed, 87 insertions(+), 51 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6a7761016..12b35d445 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ "@wordpress/notices": "^5.38.0", "@wordpress/patterns": "^2.38.0", "@wordpress/plugins": "^7.38.0", - "@wordpress/preferences": "^4.38.0", + "@wordpress/preferences": "^4.42.0", "@wordpress/preferences-persistence": "^2.41.0", "@wordpress/primitives": "^4.42.0", "@wordpress/priority-queue": "^3.38.0", @@ -123,12 +123,12 @@ "license": "MIT" }, "node_modules/@ariakit/react": { - "version": "0.4.21", - "resolved": "https://registry.npmjs.org/@ariakit/react/-/react-0.4.21.tgz", - "integrity": "sha512-UjP99Y7cWxA5seRECEE0RPZFImkLGFIWPflp65t0BVZwlMw4wp9OJZRHMrnkEkKl5KBE2NR/gbbzwHc6VNGzsA==", + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/@ariakit/react/-/react-0.4.24.tgz", + "integrity": "sha512-kL0+7ZdPXM8uJ2/cCudm94QKh2DAcE8kNcPnFgnyXaMhStpvkEIumSEu0dIHAGkv7s6NWWANrGZK7ADwcXjoXw==", "license": "MIT", "dependencies": { - "@ariakit/react-core": "0.4.21" + "@ariakit/react-core": "0.4.24" }, "funding": { "type": "opencollective", @@ -140,9 +140,9 @@ } }, "node_modules/@ariakit/react-core": { - "version": "0.4.21", - "resolved": "https://registry.npmjs.org/@ariakit/react-core/-/react-core-0.4.21.tgz", - "integrity": "sha512-rUI9uB/gT3mROFja/ka7/JukkdljIZR3eq3BGiQqX4Ni/KBMDvPK8FvVLnC0TGzWcqNY2bbfve8QllvHzuw4fQ==", + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/@ariakit/react-core/-/react-core-0.4.24.tgz", + "integrity": "sha512-MuqDooqkeaYCeMpvj+ygcONb2bS3CGniD3mW99l7P8Fioa+/kPvQCQfJjC6pR9mWFPCRiOpDjfXGREaYgm5olQ==", "license": "MIT", "dependencies": { "@ariakit/core": "0.4.18", @@ -7973,9 +7973,9 @@ } }, "node_modules/@wordpress/base-styles": { - "version": "6.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/base-styles/-/base-styles-6.17.0.tgz", - "integrity": "sha512-6o4Tp9rrGaa2ExnkCjvBZl9CVETFptb6NWtpikrkhGC2HtCSFhXWMzYheK0t+4xSJcssrpm6BMSAQGGGFm6+Tg==", + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/base-styles/-/base-styles-6.18.0.tgz", + "integrity": "sha512-c9C8gE49uFsR6S8zmfhH8xFR8FrrkpO289sscv5jRABHeH21irwP/yGuEbkJiUqIqV9Rm2+HbQay4+F5M8DYfA==", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", @@ -8212,12 +8212,12 @@ } }, "node_modules/@wordpress/components": { - "version": "32.3.0", - "resolved": "https://registry.npmjs.org/@wordpress/components/-/components-32.3.0.tgz", - "integrity": "sha512-wGDbN2rXEqTTDRHKA+Yn0Gi/dTiLfVK7u/YPAfkQXzvuV4qMGzOz2sSdxQPKmvmogWa6EA0i8Udo9dBYqG1Nig==", + "version": "32.4.0", + "resolved": "https://registry.npmjs.org/@wordpress/components/-/components-32.4.0.tgz", + "integrity": "sha512-aGXtHZbrvA2upy/Qwti1lFjt0BPqezoYx2Le5x/0P841Ss4O/9XtAvJGg99hityIugbzEdDzkWKcdoBRwa2FBQ==", "license": "GPL-2.0-or-later", "dependencies": { - "@ariakit/react": "^0.4.21", + "@ariakit/react": "^0.4.22", "@date-fns/utc": "^2.1.1", "@emotion/cache": "^11.14.0", "@emotion/css": "^11.13.5", @@ -8230,24 +8230,24 @@ "@types/highlight-words-core": "1.2.1", "@types/react": "^18.3.27", "@use-gesture/react": "^10.3.1", - "@wordpress/a11y": "^4.41.0", - "@wordpress/base-styles": "^6.17.0", - "@wordpress/compose": "^7.41.0", - "@wordpress/date": "^5.41.0", - "@wordpress/deprecated": "^4.41.0", - "@wordpress/dom": "^4.41.0", - "@wordpress/element": "^6.41.0", - "@wordpress/escape-html": "^3.41.0", - "@wordpress/hooks": "^4.41.0", - "@wordpress/html-entities": "^4.41.0", - "@wordpress/i18n": "^6.14.0", - "@wordpress/icons": "^11.8.0", - "@wordpress/is-shallow-equal": "^5.41.0", - "@wordpress/keycodes": "^4.41.0", - "@wordpress/primitives": "^4.41.0", - "@wordpress/private-apis": "^1.41.0", - "@wordpress/rich-text": "^7.41.0", - "@wordpress/warning": "^3.41.0", + "@wordpress/a11y": "^4.42.0", + "@wordpress/base-styles": "^6.18.0", + "@wordpress/compose": "^7.42.0", + "@wordpress/date": "^5.42.0", + "@wordpress/deprecated": "^4.42.0", + "@wordpress/dom": "^4.42.0", + "@wordpress/element": "^6.42.0", + "@wordpress/escape-html": "^3.42.0", + "@wordpress/hooks": "^4.42.0", + "@wordpress/html-entities": "^4.42.0", + "@wordpress/i18n": "^6.15.0", + "@wordpress/icons": "^12.0.0", + "@wordpress/is-shallow-equal": "^5.42.0", + "@wordpress/keycodes": "^4.42.0", + "@wordpress/primitives": "^4.42.0", + "@wordpress/private-apis": "^1.42.0", + "@wordpress/rich-text": "^7.42.0", + "@wordpress/warning": "^3.42.0", "change-case": "^4.1.2", "clsx": "^2.1.1", "colord": "^2.7.0", @@ -8286,6 +8286,24 @@ "csstype": "^3.2.2" } }, + "node_modules/@wordpress/components/node_modules/@wordpress/icons": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@wordpress/icons/-/icons-12.0.0.tgz", + "integrity": "sha512-bDYsBGb1Ig/HWMt7aNrFWeABrD2wbReMazn9cZxUnXTf9ZFFrmG8PEdwmmJErDiEH9MvvAzLxadcNylWNNgeZA==", + "license": "GPL-2.0-or-later", + "dependencies": { + "@wordpress/element": "^6.42.0", + "@wordpress/primitives": "^4.42.0", + "change-case": "4.1.2" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + }, + "peerDependencies": { + "react": "^18.0.0" + } + }, "node_modules/@wordpress/compose": { "version": "7.42.0", "resolved": "https://registry.npmjs.org/@wordpress/compose/-/compose-7.42.0.tgz", @@ -8444,12 +8462,12 @@ } }, "node_modules/@wordpress/date": { - "version": "5.41.0", - "resolved": "https://registry.npmjs.org/@wordpress/date/-/date-5.41.0.tgz", - "integrity": "sha512-cfFvqlTiLDyhMtTqTMxmujvFfohz6tUxIBVMji5xc2hHyv0z/8XZw1Z0JiGpEr3blBXp/Y0R3E60EQ4G83iU7Q==", + "version": "5.42.0", + "resolved": "https://registry.npmjs.org/@wordpress/date/-/date-5.42.0.tgz", + "integrity": "sha512-iTzp5vkJm3lX2V2vRdu1PK/Z9LNREWGSic5yYNKTxmOXd7FUu93hCb4hfxYReJHRqSq8lQORbvxxF/b3JeM3yA==", "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/deprecated": "^4.41.0", + "@wordpress/deprecated": "^4.42.0", "moment": "^2.29.4", "moment-timezone": "^0.5.40" }, @@ -9533,21 +9551,21 @@ } }, "node_modules/@wordpress/preferences": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@wordpress/preferences/-/preferences-4.41.0.tgz", - "integrity": "sha512-IVpbqqn5ZqXhkzwRBf4rTuGFTcxRJyzyqqVHleZNvMNdbpktrF8moUh9pTwCY+0PeSicL9vc1WusOt/TzAmucQ==", + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@wordpress/preferences/-/preferences-4.42.0.tgz", + "integrity": "sha512-WjCfTsUWJL2TfBrS4YPrdUveiTNCQLvpcYNhp9FcNgUk0YFY7DjGVUB/z4TiyDN4jCZP9vdg0FmQpTl0knqqVQ==", "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/a11y": "^4.41.0", - "@wordpress/base-styles": "^6.17.0", - "@wordpress/components": "^32.3.0", - "@wordpress/compose": "^7.41.0", - "@wordpress/data": "^10.41.0", - "@wordpress/deprecated": "^4.41.0", - "@wordpress/element": "^6.41.0", - "@wordpress/i18n": "^6.14.0", - "@wordpress/icons": "^11.8.0", - "@wordpress/private-apis": "^1.41.0", + "@wordpress/a11y": "^4.42.0", + "@wordpress/base-styles": "^6.18.0", + "@wordpress/components": "^32.4.0", + "@wordpress/compose": "^7.42.0", + "@wordpress/data": "^10.42.0", + "@wordpress/deprecated": "^4.42.0", + "@wordpress/element": "^6.42.0", + "@wordpress/i18n": "^6.15.0", + "@wordpress/icons": "^12.0.0", + "@wordpress/private-apis": "^1.42.0", "clsx": "^2.1.1" }, "engines": { @@ -9572,6 +9590,24 @@ "npm": ">=8.19.2" } }, + "node_modules/@wordpress/preferences/node_modules/@wordpress/icons": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@wordpress/icons/-/icons-12.0.0.tgz", + "integrity": "sha512-bDYsBGb1Ig/HWMt7aNrFWeABrD2wbReMazn9cZxUnXTf9ZFFrmG8PEdwmmJErDiEH9MvvAzLxadcNylWNNgeZA==", + "license": "GPL-2.0-or-later", + "dependencies": { + "@wordpress/element": "^6.42.0", + "@wordpress/primitives": "^4.42.0", + "change-case": "4.1.2" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + }, + "peerDependencies": { + "react": "^18.0.0" + } + }, "node_modules/@wordpress/prettier-config": { "version": "4.41.0", "resolved": "https://registry.npmjs.org/@wordpress/prettier-config/-/prettier-config-4.41.0.tgz", diff --git a/package.json b/package.json index 84e097110..8180b9e2b 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "@wordpress/notices": "^5.38.0", "@wordpress/patterns": "^2.38.0", "@wordpress/plugins": "^7.38.0", - "@wordpress/preferences": "^4.38.0", + "@wordpress/preferences": "^4.42.0", "@wordpress/preferences-persistence": "^2.41.0", "@wordpress/primitives": "^4.42.0", "@wordpress/priority-queue": "^3.38.0", From d72cc7510b269f7bee569d456796550f15ca51cd Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 31 Mar 2026 10:36:50 -0400 Subject: [PATCH 06/23] feat: AJAX uses token authentication (#181) * feat: Authorize AJAX with application passwords Include authorization header in AJAX requets, as we do not have cookies to send in the mobile app environment. * refactor: Rename AJAX and api-fetch configuration utilities * fix: Configure AJAX after the library loads If we configure AJAX before loading the library, the configuration is overridden. * test: Fix test imports and assertions * fix: Set the global WordPress admin AJAX URL This global is often used by WordPress Admin page scripts. * test: Assert AJAX configuration * feat: Allow configuring the Android asset loader domain Useful when needing to allow CORS for specific domains. * docs: Note AJAX support requirements * docs: Note AJAX CORS errors in troubleshooting documentation * fix: Add type checks before wrapping wp.ajax methods (#282) Address PR feedback about potential race condition. The code now checks if `window.wp.ajax.send` and `window.wp.ajax.post` are functions before wrapping them. This prevents TypeError when calling the wrapped function if the original method was undefined during configuration. Update tests to verify that missing methods remain undefined rather than being wrapped with an undefined reference. Co-authored-by: Claude * feat: conditionally reinstate VideoPress bridge When `videopress/video` is not in `allowed_block_types`, initialize the VideoPress AJAX bridge to handle `core/video` blocks extended to rely upon VideoPress upload services. AJAX auth is always initialized. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: filter lodash-js-after inline script from editor assets WordPress's `lodash-js-after` inline script calls `_.noConflict()` to restore `window._` to Underscore.js. Since GutenbergKit excludes core WordPress assets from the editor assets endpoint but doesn't load Underscore, this wipes `window._` to `undefined`. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: vendor and load wp-util.js GutenbergKit excludes core WordPress assets from the editor assets endpoint, so wp-util.js (which provides wp.ajax and wp.template) must be vendored and loaded directly. Load it via dynamic import at the end of initializeWordPressGlobals() after jQuery and lodash are on window, since its IIFE captures jQuery via closure at execution time. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: align wp.ajax wrapper signatures with wp-util The wp.ajax.send and wp.ajax.post wrappers accepted a single options argument, but wp-util's implementation accepts (action, options). Align the wrapper signatures so the action argument is forwarded correctly. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use home URL for iOS demo app site URL Use `homeUrlString()` instead of `siteUrlString()` from the REST API root response. The `url` field often returns `http://` for WordPress.com sites, while `home` returns the actual public-facing `https://` URL. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: alias wp.media.ajax and wp.media.post to wp.ajax WordPress core sets these aliases in media-models.js, which GutenbergKit doesn't load. Alias them after auth wrapping so media uploads use the authenticated AJAX methods. Co-Authored-By: Claude Opus 4.6 (1M context) * build: Use wp-util from production WordPress release Avoid including latest changes from the WordPress/wordpress-develop repository. * docs: Expand inline comments * refactor: scope AJAX auth to same-site requests via ajaxPrefilter Replace jQuery.ajaxSetup and wp.ajax.send/post wrappers with a single jQuery.ajaxPrefilter that only injects the Authorization header when the request URL starts with the configured siteURL. This prevents leaking credentials to cross-origin requests and avoids argument normalization issues with the previous wp.ajax wrapper approach. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: strip trailing slash from siteURL before building AJAX URLs Prevents double-slash in constructed URLs (e.g., `https://example.com//wp-admin/admin-ajax.php`) when siteURL is provided with a trailing slash. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: warn instead of logging success when jQuery is unavailable for AJAX auth The ajaxPrefilter silently no-ops via optional chaining when jQuery is missing, but the debug log still claims auth was configured. Guard with an early return and warning so the log accurately reflects what happened. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use origin-based matching for AJAX auth header injection Replace `startsWith(siteURL)` with `URL.origin` comparison so that scheme, host, and port must all match exactly. This prevents credential leakage to lookalike domains (e.g. `https://example.com.evil.com`). Co-Authored-By: Claude Opus 4.6 (1M context) * docs: separate AJAX config examples from Android requirement Move the iOS and Android code examples out of the Android-specific requirement so they are not visually nested under that bullet point. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: remove redundant AJAX setup from VideoPress bridge configureAjax() now initializes wp.ajax, wp.ajax.settings, and the AJAX URL before the VideoPress bridge runs, making the duplicate setup in initializeVideoPressAjaxBridge() unnecessary. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: add defensive guards to AJAX configuration - Wrap `new URL(siteURL)` in try/catch so a malformed siteURL logs a warning instead of throwing. - Guard `configureMediaAjax` against missing `wp.ajax.send`/`post` (e.g., if wp-util.js failed to load). Co-Authored-By: Claude Opus 4.6 (1M context) * fix: validate assetLoaderDomain in Android EditorConfiguration Throw IllegalArgumentException if the value contains a scheme, path, or is blank, so callers get a clear error instead of a malformed asset URL at runtime. Co-Authored-By: Claude Opus 4.6 (1M context) * docs: add vendor README documenting wp-util.js source Record the upstream commit hash and rationale for vendoring so future maintainers know where the file came from and when to update it. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: add https scheme to WP.com site URL in Android demo app Account.WpCom.username stores just the hostname (e.g., "dcpaid.wordpress.com") since it is extracted via URI.host during OAuth. ConfigurationItem was using this bare hostname as siteUrl, producing invalid AJAX endpoints. Prepend "https://" to match the self-hosted flow, which receives a full URL from the callback. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: extract duplicated AJAX URL into local variable Co-Authored-By: Claude Opus 4.6 (1M context) * docs: remove lint/format exclusion note from vendor README Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: derive Android WebView asset domain from site URL Derive the WebViewAssetLoader domain from the configured siteURL instead of defaulting to the synthetic appassets.androidplatform.net domain. This makes REST API and admin-ajax.php requests same-origin, eliminating CORS restrictions without requiring server-side headers. - Restrict shouldOverrideUrlLoading to /assets/ paths on the asset domain so arbitrary site pages don't load inside the WebView. - Reorder shouldInterceptRequest to check the cache interceptor before the asset loader, preventing cached JS/CSS from being short-circuited when both share the site domain. - Remove the now-unnecessary assetLoaderDomain configuration option from EditorConfiguration. - Update AJAX documentation to reflect the simplified setup. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude --- .eslintignore | 1 + .prettierignore | 1 + .../org/wordpress/gutenberg/GutenbergView.kt | 36 +- .../gutenberg/model/EditorConfiguration.kt | 2 +- .../wordpress/gutenberg/GutenbergViewTest.kt | 37 ++ .../example/gutenbergkit/ConfigurationItem.kt | 2 +- docs/code/troubleshooting.md | 8 + docs/integration.md | 36 ++ .../Sources/Views/SitePreparationView.swift | 2 +- src/utils/ajax.js | 123 +++++ src/utils/ajax.test.js | 517 ++++++++++++++++++ src/utils/editor-environment.js | 19 +- src/utils/editor-environment.test.js | 45 +- src/utils/editor-loader.js | 19 +- src/utils/videopress-bridge.js | 13 +- src/utils/wordpress-globals.js | 5 +- vendor/README.md | 10 + vendor/wp-util.js | 156 ++++++ 18 files changed, 1000 insertions(+), 32 deletions(-) create mode 100644 src/utils/ajax.js create mode 100644 src/utils/ajax.test.js create mode 100644 vendor/README.md create mode 100644 vendor/wp-util.js diff --git a/.eslintignore b/.eslintignore index 55e50dca1..9cd8ab950 100644 --- a/.eslintignore +++ b/.eslintignore @@ -2,3 +2,4 @@ android/ build/ dist/ ios/ +vendor/ diff --git a/.prettierignore b/.prettierignore index 6a48be6f6..f04cf3685 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,3 +2,4 @@ android ios package-lock.json .github/**/*.md +vendor diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index 88bd94fb4..7ecba6bf3 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -47,8 +47,8 @@ import org.wordpress.gutenberg.services.EditorService import java.util.Collections import java.util.Locale -private const val ASSET_URL = "https://appassets.androidplatform.net/assets/index.html" -private const val ASSET_URL_HTTP = "http://appassets.androidplatform.net/assets/index.html" +const val DEFAULT_ASSET_DOMAIN = "appassets.androidplatform.net" +const val ASSET_PATH_INDEX = "/assets/index.html" /** * A WebView-based Gutenberg block editor for Android. @@ -92,6 +92,7 @@ class GutenbergView : WebView { private var isEditorLoaded = false private var didFireEditorLoaded = false private lateinit var assetLoader: WebViewAssetLoader + private lateinit var assetDomain: String private val configuration: EditorConfiguration private lateinit var dependencies: EditorDependencies @@ -243,10 +244,18 @@ class GutenbergView : WebView { ): WebResourceResponse? { if (request.url == null) { return super.shouldInterceptRequest(view, request) - } else if (request.url.host?.contains("appassets.androidplatform.net") == true) { + } + + // Check the cache interceptor first — it handles JS/CSS + // assets from any allowed host, which may include the asset + // domain when it matches the site domain. + if (requestInterceptor.canIntercept(request)) { + val response = requestInterceptor.handleRequest(request) + if (response != null) return response + } + + if (request.url.host == assetDomain) { return assetLoader.shouldInterceptRequest(request.url) - } else if (requestInterceptor.canIntercept(request)) { - return requestInterceptor.handleRequest(request) } return super.shouldInterceptRequest(view, request) @@ -275,8 +284,10 @@ class GutenbergView : WebView { return false } - // Allow asset URLs - if (url.host == "appassets.androidplatform.net") { + // Allow asset URLs (restrict to the asset path prefix so that + // arbitrary site pages don't load inside the WebView when the + // asset domain matches the site domain) + if (url.host == assetDomain && url.path?.startsWith("/assets/") == true) { return false } @@ -394,6 +405,11 @@ class GutenbergView : WebView { private fun loadEditor(dependencies: EditorDependencies) { this.dependencies = dependencies + // Derive the asset loader domain from the site URL so that the editor + // document shares the site's origin, making REST API and AJAX requests + // same-origin and eliminating CORS restrictions. + assetDomain = Uri.parse(configuration.siteURL).host ?: DEFAULT_ASSET_DOMAIN + // Set up asset caching requestInterceptor = CachedAssetRequestInterceptor( dependencies.assetBundle, @@ -407,6 +423,7 @@ class GutenbergView : WebView { val siteUri = Uri.parse(configuration.siteURL) val isLocalHttpSite = siteUri.scheme == "http" && siteUri.host in LOCAL_HOSTS assetLoader = WebViewAssetLoader.Builder() + .setDomain(assetDomain) .setHttpAllowed(isLocalHttpSite) .addPathHandler("/assets/", AssetsPathHandler(this.context)) .build() @@ -416,7 +433,8 @@ class GutenbergView : WebView { initializeWebView() - val assetUrl = if (isLocalHttpSite) ASSET_URL_HTTP else ASSET_URL + val scheme = if (isLocalHttpSite) "http" else "https" + val assetUrl = "$scheme://$assetDomain$ASSET_PATH_INDEX" val editorUrl = BuildConfig.GUTENBERG_EDITOR_URL.ifEmpty { assetUrl } @@ -424,7 +442,7 @@ class GutenbergView : WebView { WebStorage.getInstance().deleteAllData() this.clearCache(true) // All cookies are third-party cookies because the root of this document - // lives under `appassets.androidplatform.net` + // lives under the configured asset domain (e.g., `https://appassets.androidplatform.net`) CookieManager.getInstance().setAcceptThirdPartyCookies(this, true) // Erase all local cookies before loading the URL – we don't want to persist diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt index 2e44cbaef..196111bb3 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt @@ -28,7 +28,7 @@ data class EditorConfiguration( val cachedAssetHosts: Set = emptySet(), val editorAssetsEndpoint: String? = null, val enableNetworkLogging: Boolean = false, - var enableOfflineMode: Boolean = false, + var enableOfflineMode: Boolean = false ): Parcelable { /** diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt index b4e15c0c4..64a85132d 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt @@ -5,6 +5,7 @@ import android.net.Uri import android.os.Looper import android.webkit.ValueCallback import android.webkit.WebChromeClient +import android.webkit.WebResourceRequest import android.webkit.WebView import kotlinx.coroutines.test.TestScope import org.junit.Before @@ -12,6 +13,7 @@ import org.junit.Test import org.junit.Rule import org.junit.runner.RunWith import org.mockito.Mock +import org.mockito.Mockito.mock import org.mockito.Mockito.`when` import org.mockito.MockitoAnnotations import org.robolectric.RobolectricTestRunner @@ -19,6 +21,7 @@ import org.robolectric.RuntimeEnvironment import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit @@ -171,4 +174,38 @@ class GutenbergViewTest { assertTrue("User agent should contain version number", userAgent.contains("GutenbergKit/${GutenbergKitVersion.VERSION}")) } + + @Test + fun `shouldOverrideUrlLoading allows asset path URLs on site domain`() { + val siteView = GutenbergView( + EditorConfiguration.builder("https://example.com", "https://example.com/wp-json/") + .build(), + EditorDependencies.empty, + testScope, + RuntimeEnvironment.getApplication() + ) + + val request = mock(WebResourceRequest::class.java) + `when`(request.url).thenReturn(Uri.parse("https://example.com/assets/index.html")) + + val result = siteView.webViewClient.shouldOverrideUrlLoading(siteView, request) + assertFalse("Asset path URLs on the site domain should load in the WebView", result) + } + + @Test + fun `shouldOverrideUrlLoading blocks non-asset URLs on site domain`() { + val siteView = GutenbergView( + EditorConfiguration.builder("https://example.com", "https://example.com/wp-json/") + .build(), + EditorDependencies.empty, + testScope, + RuntimeEnvironment.getApplication() + ) + + val request = mock(WebResourceRequest::class.java) + `when`(request.url).thenReturn(Uri.parse("https://example.com/some-page")) + + val result = siteView.webViewClient.shouldOverrideUrlLoading(siteView, request) + assertTrue("Non-asset URLs on the site domain should open externally", result) + } } diff --git a/android/app/src/main/java/com/example/gutenbergkit/ConfigurationItem.kt b/android/app/src/main/java/com/example/gutenbergkit/ConfigurationItem.kt index 2ed2d3c74..86b94b00f 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/ConfigurationItem.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/ConfigurationItem.kt @@ -31,7 +31,7 @@ sealed class ConfigurationItem { is Account.WpCom -> ConfiguredEditor( accountId = account.id, name = account.username, - siteUrl = account.username, + siteUrl = "https://${account.username}", siteApiRoot = account.siteApiRoot, authHeader = "Bearer ${account.token}" ) diff --git a/docs/code/troubleshooting.md b/docs/code/troubleshooting.md index 300ac055f..3ff4f70dc 100644 --- a/docs/code/troubleshooting.md +++ b/docs/code/troubleshooting.md @@ -28,3 +28,11 @@ The file does not exist at "[path]" which is in the optimize deps directory. The - Deleting the `node_modules/.vite` directory (or `node_modules` entirely) and restarting the development server via `make dev-server`. You may also need to clear your browser cache to ensure no stale files are used. + +## AJAX requests fail with CORS errors + +**Error:** `Access to XMLHttpRequest at 'https://example.com/wp-admin/admin-ajax.php' from origin 'http://localhost:5173' has been blocked by CORS policy` + +This error occurs when the editor makes AJAX requests (e.g., from blocks that use `admin-ajax.php`) while running on the development server. The browser blocks these cross-origin requests because the editor runs on `localhost` while AJAX targets your WordPress site. + +**Solution:** AJAX functionality requires a production bundle. Build the editor assets with `make build` and test AJAX features using the demo apps without using the `GUTENBERG_EDITOR_URL` environment variable. See the [AJAX Support section](../integration.md#ajax-support) in the Integration Guide for complete configuration details. diff --git a/docs/integration.md b/docs/integration.md index bd606add6..f445b704a 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -291,3 +291,39 @@ val configuration = EditorConfiguration.builder() .setEditorSettings(editorSettingsJSON) .build() ``` + +### AJAX Support + +Some Gutenberg blocks and features use WordPress AJAX (`admin-ajax.php`) for functionality like form submissions. GutenbergKit supports AJAX requests when properly configured. + +**Requirements:** + +1. **Production bundle required**: AJAX requests fail with CORS errors when using the development server because the editor runs on `localhost` while AJAX requests target your WordPress site. You must use a production bundle built with `make build`. + +2. **Configure `siteURL`**: The `siteURL` configuration option must be set to your WordPress site URL. This is used to construct the AJAX endpoint (`{siteURL}/wp-admin/admin-ajax.php`). On Android, the editor is served from the site's domain so that AJAX requests are same-origin. + +3. **Set authentication header**: The `authHeader` configuration must be set. GutenbergKit injects this header into all AJAX requests since the WebView lacks WordPress authentication cookies. + +**Configuration examples:** + +```swift +// iOS +let configuration = EditorConfigurationBuilder( + postType: "post", + siteURL: URL(string: "https://example.com")!, + siteApiRoot: URL(string: "https://example.com/wp-json")! +) + .setAuthHeader("Bearer your-token") + .build() +``` + +```kotlin +// Android +val configuration = EditorConfiguration.builder( + siteURL = "https://example.com", + siteApiRoot = "https://example.com/wp-json" +) + .setPostType("post") + .setAuthHeader("Bearer your-token") + .build() +``` diff --git a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift index b2aab0be4..68d6e0b8a 100644 --- a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift +++ b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift @@ -425,7 +425,7 @@ class SitePreparationViewModel { return EditorConfigurationBuilder( postType: selectedPostTypeDetails, - siteURL: URL(string: apiRoot.siteUrlString())!, + siteURL: URL(string: apiRoot.homeUrlString())!, siteApiRoot: siteApiRoot ) .setShouldUseThemeStyles(canUseEditorStyles) diff --git a/src/utils/ajax.js b/src/utils/ajax.js new file mode 100644 index 000000000..22c669113 --- /dev/null +++ b/src/utils/ajax.js @@ -0,0 +1,123 @@ +/** + * Internal dependencies + */ +import { getGBKit } from './bridge'; +import { warn, debug } from './logger'; + +/** + * Configure AJAX for use without authentication cookies. + * + * GutenbergKit runs in a WebView without WordPress session cookies, + * so AJAX requests need explicit URL and token-based authentication. + * Additionally, WordPress core media globals (`wp.media.ajax`, + * `wp.media.post`) are normally set by wp-includes/js/media-models.js, + * which GutenbergKit doesn't load — so we alias them here. + * + * @return {void} + */ +export function configureAjax() { + window.wp = window.wp || {}; + window.wp.ajax = window.wp.ajax || {}; + window.wp.ajax.settings = window.wp.ajax.settings || {}; + + const { siteURL: rawSiteURL, authHeader } = getGBKit(); + const siteURL = rawSiteURL?.replace( /\/+$/, '' ); + configureAjaxUrl( siteURL ); + configureAjaxAuth( siteURL, authHeader ); + configureMediaAjax(); +} + +function configureAjaxUrl( siteURL ) { + if ( ! siteURL ) { + warn( 'Unable to configure AJAX URL without siteURL' ); + return; + } + + const ajaxUrl = `${ siteURL }/wp-admin/admin-ajax.php`; + // Global used within WordPress admin pages + window.ajaxurl = ajaxUrl; + // Global used by WordPress' JavaScript API + window.wp.ajax.settings.url = ajaxUrl; + + debug( 'AJAX URL configured' ); +} + +function configureAjaxAuth( siteURL, authHeader ) { + if ( ! siteURL ) { + warn( 'Unable to configure AJAX auth without siteURL' ); + return; + } + + if ( ! authHeader ) { + warn( 'Unable to configure AJAX auth without authHeader' ); + return; + } + + if ( ! window.jQuery?.ajaxPrefilter ) { + warn( 'Unable to configure AJAX auth: jQuery not available' ); + return; + } + + let siteOrigin; + try { + siteOrigin = new URL( siteURL ).origin; + } catch { + warn( 'Unable to configure AJAX auth: invalid siteURL' ); + return; + } + + window.jQuery.ajaxPrefilter( function ( options ) { + if ( ! isSameOrigin( options.url, siteOrigin ) ) { + return; + } + + const originalBeforeSend = options.beforeSend; + options.beforeSend = function ( xhr ) { + xhr.setRequestHeader( 'Authorization', authHeader ); + if ( typeof originalBeforeSend === 'function' ) { + originalBeforeSend( xhr ); + } + }; + } ); + + debug( 'AJAX auth configured' ); +} + +/** + * Check whether a request URL shares the same origin as the site. + * + * Uses `URL.origin` so that scheme, host, and port must all match exactly, + * preventing credential leakage to lookalike domains (e.g. + * `https://example.com.evil.com`). + * + * @param {string} requestUrl The URL of the outgoing request. + * @param {string} siteOrigin The origin derived from `siteURL`. + * @return {boolean} Whether the request targets the same origin. + */ +function isSameOrigin( requestUrl, siteOrigin ) { + try { + return new URL( requestUrl ).origin === siteOrigin; + } catch { + return false; + } +} + +/** + * Alias `wp.media.ajax` and `wp.media.post` to the (now-authenticated) + * `wp.ajax.send` and `wp.ajax.post`. WordPress core normally sets these + * in `wp-includes/js/media-models.js`, which GutenbergKit doesn't load. + * + * @see https://github.com/WordPress/wordpress-develop/blob/117af7e/src/js/_enqueues/wp/media/models.js#L134 + */ +function configureMediaAjax() { + if ( ! window.wp.ajax.send || ! window.wp.ajax.post ) { + warn( + 'Unable to configure media AJAX: wp.ajax.send/post not available' + ); + return; + } + + window.wp.media = window.wp.media || {}; + window.wp.media.ajax = window.wp.ajax.send; + window.wp.media.post = window.wp.ajax.post; +} diff --git a/src/utils/ajax.test.js b/src/utils/ajax.test.js new file mode 100644 index 000000000..73d6abc04 --- /dev/null +++ b/src/utils/ajax.test.js @@ -0,0 +1,517 @@ +/** + * External dependencies + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +/** + * Internal dependencies + */ +import { configureAjax } from './ajax'; +import * as bridge from './bridge'; +import * as logger from './logger'; + +vi.mock( './bridge' ); +vi.mock( './logger' ); + +describe( 'configureAjax', () => { + let originalWindow; + let mockJQueryAjaxPrefilter; + + beforeEach( () => { + vi.clearAllMocks(); + + // Store original window state + originalWindow = { + wp: global.window.wp, + ajaxurl: global.window.ajaxurl, + jQuery: global.window.jQuery, + }; + + // Reset window.wp + global.window.wp = undefined; + global.window.ajaxurl = undefined; + + // Mock jQuery + mockJQueryAjaxPrefilter = vi.fn(); + global.window.jQuery = { + ajaxPrefilter: mockJQueryAjaxPrefilter, + }; + } ); + + afterEach( () => { + // Restore original window state + global.window.wp = originalWindow.wp; + global.window.ajaxurl = originalWindow.ajaxurl; + global.window.jQuery = originalWindow.jQuery; + } ); + + describe( 'URL configuration', () => { + it( 'should configure ajax URLs when siteURL is provided', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: null, + } ); + + configureAjax(); + + expect( global.window.ajaxurl ).toBe( + 'https://example.com/wp-admin/admin-ajax.php' + ); + expect( global.window.wp.ajax.settings.url ).toBe( + 'https://example.com/wp-admin/admin-ajax.php' + ); + expect( logger.debug ).toHaveBeenCalledWith( + 'AJAX URL configured' + ); + } ); + + it( 'should strip trailing slash from siteURL', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com/', + authHeader: null, + } ); + + configureAjax(); + + expect( global.window.ajaxurl ).toBe( + 'https://example.com/wp-admin/admin-ajax.php' + ); + expect( global.window.wp.ajax.settings.url ).toBe( + 'https://example.com/wp-admin/admin-ajax.php' + ); + } ); + + it( 'should log warning when siteURL is missing', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: null, + authHeader: 'Bearer token', + } ); + + configureAjax(); + + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure AJAX URL without siteURL' + ); + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure AJAX auth without siteURL' + ); + expect( global.window.ajaxurl ).toBeUndefined(); + } ); + + it( 'should handle undefined siteURL', () => { + bridge.getGBKit.mockReturnValue( { + authHeader: 'Bearer token', + } ); + + configureAjax(); + + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure AJAX URL without siteURL' + ); + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure AJAX auth without siteURL' + ); + expect( global.window.ajaxurl ).toBeUndefined(); + } ); + + it( 'should properly initialize window.wp.ajax hierarchy', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: null, + } ); + + // Ensure window.wp doesn't exist initially + expect( global.window.wp ).toBeUndefined(); + + configureAjax(); + + expect( global.window.wp ).toBeDefined(); + expect( global.window.wp.ajax ).toBeDefined(); + expect( global.window.wp.ajax.settings ).toBeDefined(); + } ); + } ); + + describe( 'Auth configuration', () => { + it( 'should register a jQuery ajaxPrefilter', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: 'Bearer test-token', + } ); + + configureAjax(); + + expect( mockJQueryAjaxPrefilter ).toHaveBeenCalledWith( + expect.any( Function ) + ); + expect( logger.debug ).toHaveBeenCalledWith( + 'AJAX auth configured' + ); + } ); + + it( 'should inject auth header for same-site requests', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: 'Bearer test-token', + } ); + + configureAjax(); + + const prefilter = mockJQueryAjaxPrefilter.mock.calls[ 0 ][ 0 ]; + const options = { + url: 'https://example.com/wp-admin/admin-ajax.php', + }; + prefilter( options ); + + const mockXhr = { setRequestHeader: vi.fn() }; + options.beforeSend( mockXhr ); + + expect( mockXhr.setRequestHeader ).toHaveBeenCalledWith( + 'Authorization', + 'Bearer test-token' + ); + } ); + + it( 'should not inject auth header for cross-origin requests', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: 'Bearer test-token', + } ); + + configureAjax(); + + const prefilter = mockJQueryAjaxPrefilter.mock.calls[ 0 ][ 0 ]; + const options = { url: 'https://evil.com/steal' }; + prefilter( options ); + + expect( options.beforeSend ).toBeUndefined(); + } ); + + it( 'should not inject auth header for lookalike subdomain prefixes', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: 'Bearer test-token', + } ); + + configureAjax(); + + const prefilter = mockJQueryAjaxPrefilter.mock.calls[ 0 ][ 0 ]; + const options = { url: 'https://example.com.evil.com/steal' }; + prefilter( options ); + + expect( options.beforeSend ).toBeUndefined(); + } ); + + it( 'should preserve original beforeSend', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: 'Bearer test-token', + } ); + + configureAjax(); + + const prefilter = mockJQueryAjaxPrefilter.mock.calls[ 0 ][ 0 ]; + const originalBeforeSend = vi.fn(); + const options = { + url: 'https://example.com/wp-admin/admin-ajax.php', + beforeSend: originalBeforeSend, + }; + prefilter( options ); + + const mockXhr = { setRequestHeader: vi.fn() }; + options.beforeSend( mockXhr ); + + expect( mockXhr.setRequestHeader ).toHaveBeenCalledWith( + 'Authorization', + 'Bearer test-token' + ); + expect( originalBeforeSend ).toHaveBeenCalledWith( mockXhr ); + } ); + + it( 'should log warning when authHeader is missing', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: null, + } ); + + configureAjax(); + + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure AJAX auth without authHeader' + ); + expect( mockJQueryAjaxPrefilter ).not.toHaveBeenCalled(); + } ); + + it( 'should handle undefined authHeader', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + } ); + + configureAjax(); + + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure AJAX auth without authHeader' + ); + expect( mockJQueryAjaxPrefilter ).not.toHaveBeenCalled(); + } ); + } ); + + describe( 'Integration tests', () => { + it( 'should configure both URL and auth when both are provided', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: 'Bearer full-token', + } ); + + configureAjax(); + + // Check URL configuration + expect( global.window.ajaxurl ).toBe( + 'https://example.com/wp-admin/admin-ajax.php' + ); + expect( global.window.wp.ajax.settings.url ).toBe( + 'https://example.com/wp-admin/admin-ajax.php' + ); + + // Check auth configuration + expect( mockJQueryAjaxPrefilter ).toHaveBeenCalledWith( + expect.any( Function ) + ); + + // Check debug logs + expect( logger.debug ).toHaveBeenCalledWith( + 'AJAX URL configured' + ); + expect( logger.debug ).toHaveBeenCalledWith( + 'AJAX auth configured' + ); + } ); + + it( 'should handle empty configuration object', () => { + bridge.getGBKit.mockReturnValue( {} ); + + configureAjax(); + + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure AJAX URL without siteURL' + ); + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure AJAX auth without siteURL' + ); + } ); + } ); + + describe( 'Edge cases', () => { + it( 'should warn when jQuery is missing', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: 'Bearer no-jquery', + } ); + + delete global.window.jQuery; + + expect( () => configureAjax() ).not.toThrow(); + expect( logger.debug ).toHaveBeenCalledWith( + 'AJAX URL configured' + ); + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure AJAX auth: jQuery not available' + ); + expect( logger.debug ).not.toHaveBeenCalledWith( + 'AJAX auth configured' + ); + } ); + + it( 'should warn when jQuery is undefined', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: 'Bearer undefined-jquery', + } ); + + global.window.jQuery = undefined; + + expect( () => configureAjax() ).not.toThrow(); + expect( logger.debug ).toHaveBeenCalledWith( + 'AJAX URL configured' + ); + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure AJAX auth: jQuery not available' + ); + expect( logger.debug ).not.toHaveBeenCalledWith( + 'AJAX auth configured' + ); + } ); + + it( 'should handle missing wp.ajax entirely', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: 'Bearer no-ajax', + } ); + + global.window.wp = {}; + + expect( () => configureAjax() ).not.toThrow(); + + // Should create ajax object + expect( global.window.wp.ajax ).toBeDefined(); + expect( global.window.wp.ajax.settings ).toBeDefined(); + } ); + + it( 'should work with window.wp already partially initialized', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: null, + } ); + + // Pre-existing wp object with other properties + global.window.wp = { + data: { someData: 'test' }, + }; + + configureAjax(); + + // Should preserve existing properties + expect( global.window.wp.data ).toEqual( { someData: 'test' } ); + + // Should add ajax properties + expect( global.window.wp.ajax ).toBeDefined(); + expect( global.window.wp.ajax.settings.url ).toBe( + 'https://example.com/wp-admin/admin-ajax.php' + ); + } ); + + it( 'should work when wp.ajax is partially initialized', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: null, + } ); + + // Pre-existing wp.ajax object without settings + global.window.wp = { + ajax: { + someMethod: vi.fn(), + }, + }; + + configureAjax(); + + // Should preserve existing methods + expect( global.window.wp.ajax.someMethod ).toBeDefined(); + + // Should add settings + expect( global.window.wp.ajax.settings ).toBeDefined(); + expect( global.window.wp.ajax.settings.url ).toBe( + 'https://example.com/wp-admin/admin-ajax.php' + ); + } ); + + it( 'should not modify options when URL is missing', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: 'Bearer test-token', + } ); + + configureAjax(); + + const prefilter = mockJQueryAjaxPrefilter.mock.calls[ 0 ][ 0 ]; + const options = {}; + prefilter( options ); + + expect( options.beforeSend ).toBeUndefined(); + } ); + } ); + + describe( 'Media AJAX configuration', () => { + it( 'should alias wp.media.ajax to wp.ajax.send', () => { + const mockSend = vi.fn(); + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: null, + } ); + + global.window.wp = { + ajax: { + send: mockSend, + post: vi.fn(), + settings: {}, + }, + }; + + configureAjax(); + + expect( global.window.wp.media.ajax ).toBe( mockSend ); + } ); + + it( 'should alias wp.media.post to wp.ajax.post', () => { + const mockPost = vi.fn(); + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: null, + } ); + + global.window.wp = { + ajax: { + send: vi.fn(), + post: mockPost, + settings: {}, + }, + }; + + configureAjax(); + + expect( global.window.wp.media.post ).toBe( mockPost ); + } ); + + it( 'should not initialize wp.media when wp.ajax.send is unavailable', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: null, + } ); + + global.window.wp = {}; + + configureAjax(); + + expect( global.window.wp.media ).toBeUndefined(); + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure media AJAX: wp.ajax.send/post not available' + ); + } ); + + it( 'should warn when wp.ajax.send or wp.ajax.post are not available', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: null, + } ); + + // wp.ajax exists but without send/post (e.g., wp-util.js failed to load) + global.window.wp = { + ajax: { + settings: {}, + }, + }; + + configureAjax(); + + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure media AJAX: wp.ajax.send/post not available' + ); + expect( global.window.wp.media ).toBeUndefined(); + } ); + } ); + + describe( 'Invalid siteURL handling', () => { + it( 'should warn when siteURL is not a valid URL for auth config', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'not-a-url', + authHeader: 'Bearer test-token', + } ); + + configureAjax(); + + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure AJAX auth: invalid siteURL' + ); + expect( mockJQueryAjaxPrefilter ).not.toHaveBeenCalled(); + } ); + } ); +} ); diff --git a/src/utils/editor-environment.js b/src/utils/editor-environment.js index 4470638a6..339f8b6a7 100644 --- a/src/utils/editor-environment.js +++ b/src/utils/editor-environment.js @@ -9,6 +9,7 @@ import { } from './bridge'; import { configureLocale } from './localization'; import { loadEditorAssets } from './editor-loader'; +import { configureAjax } from './ajax'; import { initializeVideoPressAjaxBridge } from './videopress-bridge'; import { initializeFetchInterceptor } from './fetch-interceptor'; import EditorLoadError from '../components/editor-load-error'; @@ -33,7 +34,6 @@ export async function setUpEditorEnvironment() { await configureLocale(); await initializeWordPressGlobals(); await configureApiFetch(); - initializeVideoPressAjaxBridge(); const pluginLoadResult = await loadPluginsIfEnabled(); await initializeEditor( pluginLoadResult ); } catch ( err ) { @@ -84,7 +84,7 @@ function setLogLevelFromGBKit() { async function initializeWordPressGlobals() { const { initializeWordPressGlobals: _initializeWordPressGlobals } = await import( './wordpress-globals' ); - _initializeWordPressGlobals(); + await _initializeWordPressGlobals(); } /** @@ -132,15 +132,26 @@ async function loadPluginsIfEnabled() { /** * Initialize the editor module. Lazy-loaded to ensure WordPress globals are - * before importing the editor module and referencing `window.wp` globals. + * available before importing the editor module and referencing `window.wp` + * globals. * * @param {Object} pluginLoadResult - Results from plugin loading * @return {Promise} Promise that resolves when the editor is initialized */ async function initializeEditor( pluginLoadResult = {} ) { const { initializeEditor: _initializeEditor } = await import( './editor' ); + const { allowedBlockTypes } = pluginLoadResult; + + configureAjax(); + + if ( ! allowedBlockTypes?.includes( 'videopress/video' ) ) { + // The VideoPress block isn't available, so initialize the bridge to handle + // any `core/video` blocks extended to rely upon VideoPress upload services. + initializeVideoPressAjaxBridge(); + } + _initializeEditor( { - allowedBlockTypes: pluginLoadResult.allowedBlockTypes, + allowedBlockTypes, pluginLoadFailed: pluginLoadResult.pluginLoadFailed, } ); } diff --git a/src/utils/editor-environment.test.js b/src/utils/editor-environment.test.js index 834fb13da..ea6c77008 100644 --- a/src/utils/editor-environment.test.js +++ b/src/utils/editor-environment.test.js @@ -16,6 +16,7 @@ import { import { loadEditorAssets } from './editor-loader.js'; import EditorLoadError from '../components/editor-load-error/index.jsx'; import { error } from './logger.js'; +import { configureAjax } from './ajax.js'; import { initializeVideoPressAjaxBridge } from './videopress-bridge.js'; import { initializeWordPressGlobals } from './wordpress-globals.js'; import { configureLocale } from './localization.js'; @@ -27,6 +28,7 @@ vi.mock( './bridge.js' ); vi.mock( './fetch-interceptor.js' ); vi.mock( './logger.js' ); vi.mock( './editor-styles.js' ); +vi.mock( './ajax.js' ); vi.mock( './videopress-bridge.js' ); vi.mock( './wordpress-globals.js', () => ( { @@ -63,6 +65,7 @@ describe( 'setUpEditorEnvironment', () => { initializeWordPressGlobals.mockImplementation( () => {} ); configureApiFetch.mockImplementation( () => {} ); initializeFetchInterceptor.mockImplementation( () => {} ); + configureAjax.mockImplementation( () => {} ); initializeVideoPressAjaxBridge.mockImplementation( () => {} ); initializeEditor.mockImplementation( () => {} ); EditorLoadError.mockReturnValue( '
Error
' ); @@ -96,8 +99,12 @@ describe( 'setUpEditorEnvironment', () => { callOrder.push( 'configureApiFetch' ); } ); + configureAjax.mockImplementation( () => { + callOrder.push( 'configureAjax' ); + } ); + initializeVideoPressAjaxBridge.mockImplementation( () => { - callOrder.push( 'initializeVideoPress' ); + callOrder.push( 'initializeVideoPressAjaxBridge' ); } ); initializeEditor.mockImplementation( () => { @@ -112,7 +119,8 @@ describe( 'setUpEditorEnvironment', () => { 'configureLocale', 'loadRemainingGlobals', 'configureApiFetch', - 'initializeVideoPress', + 'configureAjax', + 'initializeVideoPressAjaxBridge', 'initializeEditor', ] ); } ); @@ -224,6 +232,39 @@ describe( 'setUpEditorEnvironment', () => { expect( editorLoaded ).toHaveBeenCalledTimes( 1 ); } ); + it( 'initializes VideoPress bridge when videopress/video is not in allowed block types', async () => { + getGBKit.mockReturnValue( { plugins: true } ); + loadEditorAssets.mockResolvedValue( { + allowedBlockTypes: [ 'core/paragraph', 'core/heading' ], + } ); + + await setUpEditorEnvironment(); + + expect( configureAjax ).toHaveBeenCalledTimes( 1 ); + expect( initializeVideoPressAjaxBridge ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'skips VideoPress bridge when videopress/video is in allowed block types', async () => { + getGBKit.mockReturnValue( { plugins: true } ); + loadEditorAssets.mockResolvedValue( { + allowedBlockTypes: [ 'core/paragraph', 'videopress/video' ], + } ); + + await setUpEditorEnvironment(); + + expect( configureAjax ).toHaveBeenCalledTimes( 1 ); + expect( initializeVideoPressAjaxBridge ).not.toHaveBeenCalled(); + } ); + + it( 'initializes VideoPress bridge when allowed block types is undefined', async () => { + getGBKit.mockReturnValue( { plugins: false } ); + + await setUpEditorEnvironment(); + + expect( configureAjax ).toHaveBeenCalledTimes( 1 ); + expect( initializeVideoPressAjaxBridge ).toHaveBeenCalledTimes( 1 ); + } ); + it( 'returns a promise that resolves when initialization completes', async () => { const result = setUpEditorEnvironment(); diff --git a/src/utils/editor-loader.js b/src/utils/editor-loader.js index 3edfb8e17..18d4a6daf 100644 --- a/src/utils/editor-loader.js +++ b/src/utils/editor-loader.js @@ -61,6 +61,13 @@ async function processEditorAssets( assets ) { * Load the asset files for a block * * @param {string} html The HTML content to parse for assets. + * + * @todo Remove the core and Gutenberg asset filtering once the relevant Jetpack + * plugin release is available that excludes these assets from the editor assets + * endpoint response. See: https://github.com/Automattic/jetpack/pull/45715 + * + * @todo Replace the client `lodash-js-after` script filtering with more robust + * server-side filtering in the editor assets endpoint. */ async function loadAssets( html ) { const doc = new window.DOMParser().parseFromString( html, 'text/html' ); @@ -69,8 +76,6 @@ async function loadAssets( html ) { doc.querySelectorAll( 'link[rel="stylesheet"],script' ) ).filter( ( asset ) => { /** - * TODO: Remove this once the relevant Jetpack plugin release is available. - * * Exclude WordPress core and Gutenberg assets to avoid loading duplicate * assets, which causes editor loading failures. * @@ -87,6 +92,16 @@ async function loadAssets( html ) { return false; } + /** + * WordPress's lodash-js-after inline script calls _.noConflict() to + * restore window._ to Underscore.js. GutenbergKit doesn't load + * Underscore, so this wipes window._ to undefined. Ideally, this filtering + * occurs in the editor assets endpoint. + */ + if ( asset.id === 'lodash-js-after' ) { + return false; + } + return !! asset.id; } ); diff --git a/src/utils/videopress-bridge.js b/src/utils/videopress-bridge.js index 7bed2ee2b..23418c8ad 100644 --- a/src/utils/videopress-bridge.js +++ b/src/utils/videopress-bridge.js @@ -1,7 +1,6 @@ /** * Internal dependencies */ -import { getGBKit } from './bridge'; import { warn, debug, error } from './logger'; /** @@ -18,6 +17,8 @@ import { warn, debug, error } from './logger'; * This function overrides wp.media.ajax to intercept VideoPress-specific * AJAX requests and redirect them to the appropriate REST API endpoints. * + * @todo Remove this bridge once the `videopress/video` block type is allowed and stable. + * * @return {void} */ export function initializeVideoPressAjaxBridge() { @@ -27,16 +28,6 @@ export function initializeVideoPressAjaxBridge() { return; } - // Initialize wp.ajax if not already present - window.wp.ajax = window.wp.ajax || {}; - window.wp.ajax.settings = window.wp.ajax.settings || {}; - - // Set up AJAX settings with site URL - const { siteURL } = getGBKit(); - if ( siteURL ) { - window.wp.ajax.settings.url = `${ siteURL }/wp-admin/admin-ajax.php`; - } - // Store original wp.media.ajax function if it exists const originalMediaAjax = window.wp.media?.ajax; diff --git a/src/utils/wordpress-globals.js b/src/utils/wordpress-globals.js index 5bdb8e1d8..87f6e6a52 100644 --- a/src/utils/wordpress-globals.js +++ b/src/utils/wordpress-globals.js @@ -67,7 +67,7 @@ import * as wordcount from '@wordpress/wordcount'; * * @return {void} */ -export function initializeWordPressGlobals() { +export async function initializeWordPressGlobals() { window.jQuery = jquery; // Expose jQuery for plugins // Initialize the wp namespace if it doesn't exist @@ -139,4 +139,7 @@ export function initializeWordPressGlobals() { // React JSX runtime for plugin compatibility window.ReactJSXRuntime = ReactJSXRuntime; + + // Load wp-util after jQuery and lodash are on window + await import( '../../vendor/wp-util.js' ); } diff --git a/vendor/README.md b/vendor/README.md new file mode 100644 index 000000000..3ca63cd99 --- /dev/null +++ b/vendor/README.md @@ -0,0 +1,10 @@ +# Vendored Files + +This directory contains vendored third-party files that GutenbergKit loads directly, since it does not include the full set of WordPress core assets. + +## wp-util.js + +- **Source**: [`wp-includes/js/wp-util.js`](https://github.com/WordPress/wordpress-develop/blob/117af7e9a37c02ee17ac8f143cb46b6b0f4cde15/src/js/_enqueues/wp/util.js) +- **Commit**: `117af7e9a37c02ee17ac8f143cb46b6b0f4cde15` + +Provides `wp.ajax` (authenticated AJAX utilities) and `wp.template` (JavaScript templating). WordPress normally enqueues this as the `wp-util` script handle. GutenbergKit vendors it because the editor assets endpoint excludes core WordPress scripts, and the IIFE captures jQuery via closure at execution time — so it must be loaded after jQuery is on `window`. \ No newline at end of file diff --git a/vendor/wp-util.js b/vendor/wp-util.js new file mode 100644 index 000000000..f28653af7 --- /dev/null +++ b/vendor/wp-util.js @@ -0,0 +1,156 @@ +/** + * @output wp-includes/js/wp-util.js + */ + +/* global _wpUtilSettings */ + +/** @namespace wp */ +window.wp = window.wp || {}; + +(function ($) { + // Check for the utility settings. + var settings = typeof _wpUtilSettings === 'undefined' ? {} : _wpUtilSettings; + + /** + * wp.template( id ) + * + * Fetch a JavaScript template for an id, and return a templating function for it. + * + * @param {string} id A string that corresponds to a DOM element with an id prefixed with "tmpl-". + * For example, "attachment" maps to "tmpl-attachment". + * @return {function} A function that lazily-compiles the template requested. + */ + wp.template = _.memoize(function ( id ) { + var compiled, + /* + * Underscore's default ERB-style templates are incompatible with PHP + * when asp_tags is enabled, so WordPress uses Mustache-inspired templating syntax. + * + * @see trac ticket #22344. + */ + options = { + evaluate: /<#([\s\S]+?)#>/g, + interpolate: /\{\{\{([\s\S]+?)\}\}\}/g, + escape: /\{\{([^\}]+?)\}\}(?!\})/g, + variable: 'data' + }; + + return function ( data ) { + if ( ! document.getElementById( 'tmpl-' + id ) ) { + throw new Error( 'Template not found: ' + '#tmpl-' + id ); + } + compiled = compiled || _.template( $( '#tmpl-' + id ).html(), options ); + return compiled( data ); + }; + }); + + /* + * wp.ajax + * ------ + * + * Tools for sending ajax requests with JSON responses and built in error handling. + * Mirrors and wraps jQuery's ajax APIs. + */ + wp.ajax = { + settings: settings.ajax || {}, + + /** + * wp.ajax.post( [action], [data] ) + * + * Sends a POST request to WordPress. + * + * @param {(string|Object)} action The slug of the action to fire in WordPress or options passed + * to jQuery.ajax. + * @param {Object=} data Optional. The data to populate $_POST with. + * @return {$.promise} A jQuery promise that represents the request, + * decorated with an abort() method. + */ + post: function( action, data ) { + return wp.ajax.send({ + data: _.isObject( action ) ? action : _.extend( data || {}, { action: action }) + }); + }, + + /** + * wp.ajax.send( [action], [options] ) + * + * Sends a POST request to WordPress. + * + * @param {(string|Object)} action The slug of the action to fire in WordPress or options passed + * to jQuery.ajax. + * @param {Object=} options Optional. The options passed to jQuery.ajax. + * @return {$.promise} A jQuery promise that represents the request, + * decorated with an abort() method. + */ + send: function( action, options ) { + var promise, deferred; + if ( _.isObject( action ) ) { + options = action; + } else { + options = options || {}; + options.data = _.extend( options.data || {}, { action: action }); + } + + options = _.defaults( options || {}, { + type: 'POST', + url: wp.ajax.settings.url, + context: this + }); + + deferred = $.Deferred( function( deferred ) { + // Transfer success/error callbacks. + if ( options.success ) { + deferred.done( options.success ); + } + + if ( options.error ) { + deferred.fail( options.error ); + } + + delete options.success; + delete options.error; + + // Use with PHP's wp_send_json_success() and wp_send_json_error(). + deferred.jqXHR = $.ajax( options ).done( function( response ) { + // Treat a response of 1 as successful for backward compatibility with existing handlers. + if ( response === '1' || response === 1 ) { + response = { success: true }; + } + + if ( _.isObject( response ) && ! _.isUndefined( response.success ) ) { + + // When handling a media attachments request, get the total attachments from response headers. + var context = this; + deferred.done( function() { + if ( + action && + action.data && + 'query-attachments' === action.data.action && + deferred.jqXHR.hasOwnProperty( 'getResponseHeader' ) && + deferred.jqXHR.getResponseHeader( 'X-WP-Total' ) + ) { + context.totalAttachments = parseInt( deferred.jqXHR.getResponseHeader( 'X-WP-Total' ), 10 ); + } else { + context.totalAttachments = 0; + } + } ); + deferred[ response.success ? 'resolveWith' : 'rejectWith' ]( this, [response.data] ); + } else { + deferred.rejectWith( this, [response] ); + } + }).fail( function() { + deferred.rejectWith( this, arguments ); + }); + }); + + promise = deferred.promise(); + promise.abort = function() { + deferred.jqXHR.abort(); + return this; + }; + + return promise; + } + }; + +}(jQuery)); \ No newline at end of file From 590a121f8678a24a4abb970534cf2319fd73243d Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 1 Apr 2026 16:45:59 -0400 Subject: [PATCH 07/23] fix: improve iOS bridge stability (#410) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: consolidate @wordpress mocks in __mocks__ directory Add shared mock stubs for @wordpress packages that crash when imported in the test environment. Flatten existing directory-based mocks (i18n, components) to top-level files for consistency. Remove exports that have no effect on test outcomes — the files themselves are still needed to prevent Vitest from importing the real modules. Tests declare vi.mock('module') without a factory to use the shared stub, and override locally only when test-specific behavior is needed. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: remove unnecessary vi.mock() calls from existing tests Remove @wordpress/preferences (editor.test.jsx) and @wordpress/i18n (editor-load-notice, offline-indicator) mocks that have no effect on test outcomes. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: coordinate bridge readiness and editor visibility for onEditorLoaded Replace the IntersectionObserver in use-editor-visible with a new useEditorReady hook that coordinates two conditions before signaling the native host via onEditorLoaded: 1. All window.editor.* bridge methods are assigned (from useHostBridge) 2. The editor content is visible in the viewport (via IntersectionObserver on the VisualEditor/TextEditor root element) useEditorReady returns a callback ref (for DOM attachment observation), a standard ref (for imperative access in useHostBridge), and a markBridgeReady callback. The callback ref is forwarded to VisualEditor/TextEditor via forwardRef so the observer fires when the actual editor content is in the viewport — not just the wrapper div. The editorLoaded() call is deferred by one frame via requestAnimationFrame so the browser has painted the editor content before the native host starts the fade-in animation. Remove the now-unused useEditorVisible hook and add the missing window.editor.focus cleanup on unmount. The error path in editor-environment.js retains its own editorLoaded() call since useEditorReady won't run when initialization fails. Addresses CMM-2008 and CMM-2009. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: add isReady guards to iOS JS bridge calls Add isReady guards to undo(), redo(), dismissTopModal(), isCodeEditorEnabled, appendTextAtCursor(), getContent(), and getTitleAndContent(). Previously only setContent() was guarded. Reset isReady to false in controllerWebContentProcessDidTerminate so JS bridge calls are blocked until the editor re-emits onEditorLoaded after a WebView process crash and reload. Fire-and-forget methods silently return when not ready. Async throwing methods (getContent, getTitleAndContent) throw EditorNotReadyError so callers can handle the case explicitly. Addresses CMM-2008 and CMM-2009. Co-Authored-By: Claude Opus 4.6 (1M context) * test: add tests for bridge setup ordering guarantees Verify window.editor.* methods are assigned and markBridgeReady is called, and that all methods are cleaned up on unmount. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- __mocks__/@wordpress/a11y.js | 2 +- __mocks__/@wordpress/block-editor.js | 1 + __mocks__/@wordpress/block-library.js | 3 + __mocks__/@wordpress/blocks.js | 5 ++ .../{components/index.jsx => components.jsx} | 2 - __mocks__/@wordpress/core-data.js | 1 + __mocks__/@wordpress/data.js | 9 +++ __mocks__/@wordpress/editor.js | 1 + __mocks__/@wordpress/i18n.js | 1 + __mocks__/@wordpress/i18n/index.js | 3 - __mocks__/@wordpress/rich-text.js | 1 + .../Sources/EditorViewController.swift | 20 ++++- .../editor-load-notice/index.test.jsx | 1 - src/components/editor/index.jsx | 11 +-- .../editor/test/use-host-bridge.test.jsx | 78 +++++++++++++++++++ src/components/editor/use-editor-ready.js | 76 ++++++++++++++++++ src/components/editor/use-host-bridge.js | 9 ++- .../offline-indicator/index.test.jsx | 1 - src/components/text-editor/index.jsx | 10 ++- src/components/visual-editor/index.jsx | 10 +-- .../visual-editor/use-editor-visible.js | 34 -------- src/utils/editor.test.jsx | 11 +-- 22 files changed, 223 insertions(+), 67 deletions(-) create mode 100644 __mocks__/@wordpress/block-editor.js create mode 100644 __mocks__/@wordpress/block-library.js create mode 100644 __mocks__/@wordpress/blocks.js rename __mocks__/@wordpress/{components/index.jsx => components.jsx} (87%) create mode 100644 __mocks__/@wordpress/core-data.js create mode 100644 __mocks__/@wordpress/data.js create mode 100644 __mocks__/@wordpress/editor.js create mode 100644 __mocks__/@wordpress/i18n.js delete mode 100644 __mocks__/@wordpress/i18n/index.js create mode 100644 __mocks__/@wordpress/rich-text.js create mode 100644 src/components/editor/test/use-host-bridge.test.jsx create mode 100644 src/components/editor/use-editor-ready.js delete mode 100644 src/components/visual-editor/use-editor-visible.js diff --git a/__mocks__/@wordpress/a11y.js b/__mocks__/@wordpress/a11y.js index cdd9b9bfa..7f90f1824 100644 --- a/__mocks__/@wordpress/a11y.js +++ b/__mocks__/@wordpress/a11y.js @@ -1 +1 @@ -export const speak = () => {}; +// Intentionally empty — prevents the real module from loading. diff --git a/__mocks__/@wordpress/block-editor.js b/__mocks__/@wordpress/block-editor.js new file mode 100644 index 000000000..7f90f1824 --- /dev/null +++ b/__mocks__/@wordpress/block-editor.js @@ -0,0 +1 @@ +// Intentionally empty — prevents the real module from loading. diff --git a/__mocks__/@wordpress/block-library.js b/__mocks__/@wordpress/block-library.js new file mode 100644 index 000000000..8cc7b3f35 --- /dev/null +++ b/__mocks__/@wordpress/block-library.js @@ -0,0 +1,3 @@ +import { vi } from 'vitest'; + +export const registerCoreBlocks = vi.fn(); diff --git a/__mocks__/@wordpress/blocks.js b/__mocks__/@wordpress/blocks.js new file mode 100644 index 000000000..80f7e4680 --- /dev/null +++ b/__mocks__/@wordpress/blocks.js @@ -0,0 +1,5 @@ +import { vi } from 'vitest'; + +export const parse = vi.fn( () => [] ); +export const serialize = vi.fn( () => '' ); +export const getBlockType = vi.fn(); diff --git a/__mocks__/@wordpress/components/index.jsx b/__mocks__/@wordpress/components.jsx similarity index 87% rename from __mocks__/@wordpress/components/index.jsx rename to __mocks__/@wordpress/components.jsx index 87d5a3f8f..738fef66c 100644 --- a/__mocks__/@wordpress/components/index.jsx +++ b/__mocks__/@wordpress/components.jsx @@ -1,5 +1,3 @@ -export const Icon = () => null; - export const Notice = ( { children, onRemove } ) => (
{ children } diff --git a/__mocks__/@wordpress/core-data.js b/__mocks__/@wordpress/core-data.js new file mode 100644 index 000000000..7f90f1824 --- /dev/null +++ b/__mocks__/@wordpress/core-data.js @@ -0,0 +1 @@ +// Intentionally empty — prevents the real module from loading. diff --git a/__mocks__/@wordpress/data.js b/__mocks__/@wordpress/data.js new file mode 100644 index 000000000..bdfdf7493 --- /dev/null +++ b/__mocks__/@wordpress/data.js @@ -0,0 +1,9 @@ +import { vi } from 'vitest'; + +export const useDispatch = vi.fn( () => ( {} ) ); +export const useSelect = vi.fn( ( selector ) => { + if ( typeof selector === 'function' ) { + return selector( () => ( {} ) ); + } + return {}; +} ); diff --git a/__mocks__/@wordpress/editor.js b/__mocks__/@wordpress/editor.js new file mode 100644 index 000000000..e55210078 --- /dev/null +++ b/__mocks__/@wordpress/editor.js @@ -0,0 +1 @@ +export const store = { name: 'core/editor' }; diff --git a/__mocks__/@wordpress/i18n.js b/__mocks__/@wordpress/i18n.js new file mode 100644 index 000000000..7f90f1824 --- /dev/null +++ b/__mocks__/@wordpress/i18n.js @@ -0,0 +1 @@ +// Intentionally empty — prevents the real module from loading. diff --git a/__mocks__/@wordpress/i18n/index.js b/__mocks__/@wordpress/i18n/index.js deleted file mode 100644 index ec8afeec5..000000000 --- a/__mocks__/@wordpress/i18n/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import { vi } from 'vitest'; - -export const __ = vi.fn( ( text ) => text ); diff --git a/__mocks__/@wordpress/rich-text.js b/__mocks__/@wordpress/rich-text.js new file mode 100644 index 000000000..7f90f1824 --- /dev/null +++ b/__mocks__/@wordpress/rich-text.js @@ -0,0 +1 @@ +// Intentionally empty — prevents the real module from loading. diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 1993d818d..bfd11bc82 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -376,7 +376,8 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// Returns the current editor content. public func getContent() async throws -> String { - try await webView.evaluateJavaScript("editor.getContent();") as! String + guard isReady else { throw EditorNotReadyError() } + return try await webView.evaluateJavaScript("editor.getContent();") as! String } public struct EditorTitleAndContent: Decodable { @@ -387,6 +388,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// Returns the current editor title and content. public func getTitleAndContent() async throws -> EditorTitleAndContent { + guard isReady else { throw EditorNotReadyError() } let result = try await webView.evaluateJavaScript("editor.getTitleAndContent();") guard let dictionary = result as? [String: Any], let title = dictionary["title"] as? String, @@ -399,23 +401,26 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// Steps backwards in the editor history state public func undo() { + guard isReady else { return } evaluate("editor.undo();") } /// Steps forwards in the editor history state public func redo() { + guard isReady else { return } evaluate("editor.redo();") } /// Dismisses the topmost modal dialog or menu in the editor public func dismissTopModal() { + guard isReady else { return } evaluate("editor.dismissTopModal();") } /// Enables code editor. public var isCodeEditorEnabled: Bool = false { didSet { - guard isCodeEditorEnabled != oldValue else { return } + guard isCodeEditorEnabled != oldValue, isReady else { return } evaluate("editor.switchEditorMode('\(isCodeEditorEnabled ? "text" : "visual")');") } } @@ -573,6 +578,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// /// - parameter text: The text to append at the cursor position. public func appendTextAtCursor(_ text: String) { + guard isReady else { return } let escapedText = text.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? text evaluate("editor.appendTextAtCursor(decodeURIComponent('\(escapedText)'));") } @@ -679,6 +685,9 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro } fileprivate func controllerWebContentProcessDidTerminate(_ controller: GutenbergEditorController) { + // Reset readiness so JS bridge calls are blocked until the editor + // re-emits onEditorLoaded after the reload completes. + self.isReady = false webView.reload() } @@ -734,6 +743,13 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro } } +/// Error thrown when a JS bridge method is called before the editor is ready. +public struct EditorNotReadyError: LocalizedError { + public var errorDescription: String? { + "The editor is not ready. Wait for editorDidLoad before calling JS bridge methods." + } +} + @MainActor private protocol GutenbergEditorControllerDelegate: AnyObject { func controller(_ controller: GutenbergEditorController, didReceiveMessage message: EditorJSMessage) diff --git a/src/components/editor-load-notice/index.test.jsx b/src/components/editor-load-notice/index.test.jsx index 7a849177b..35e9bb7d4 100644 --- a/src/components/editor-load-notice/index.test.jsx +++ b/src/components/editor-load-notice/index.test.jsx @@ -9,7 +9,6 @@ import { render, screen, fireEvent, act } from '@testing-library/react'; */ import EditorLoadNotice from '.'; -vi.mock( '@wordpress/i18n' ); vi.mock( '@wordpress/components' ); describe( 'EditorLoadNotice', () => { diff --git a/src/components/editor/index.jsx b/src/components/editor/index.jsx index 0f62ab99d..fdcf4d86c 100644 --- a/src/components/editor/index.jsx +++ b/src/components/editor/index.jsx @@ -4,7 +4,6 @@ import { store as coreStore } from '@wordpress/core-data'; import { useSelect } from '@wordpress/data'; import { store as editorStore, EditorProvider } from '@wordpress/editor'; -import { useRef } from '@wordpress/element'; /** * Internal dependencies @@ -13,6 +12,7 @@ import VisualEditor from '../visual-editor'; import './style.scss'; import { useSyncHistoryControls } from './use-sync-history-controls'; import { useHostBridge } from './use-host-bridge'; +import { useEditorReady } from './use-editor-ready'; import { useHostExceptionLogging } from './use-host-exception-logging'; import { useEditorSetup } from './use-editor-setup'; import { useMediaUpload } from './use-media-upload'; @@ -37,8 +37,8 @@ import { usePlusAutocompleter } from './use-plus-autocompleter'; * @return {Element} The rendered App component. */ export default function Editor( { post, children, hideTitle } ) { - const editorRef = useRef( null ); - useHostBridge( post, editorRef ); + const [ callbackRef, editorRef, markBridgeReady ] = useEditorReady(); + useHostBridge( post, editorRef, markBridgeReady ); useSyncFeaturedImage(); useSyncHistoryControls(); useHostExceptionLogging(); @@ -84,18 +84,19 @@ export default function Editor( { post, children, hideTitle } ) { } return ( -
+
{ mode === 'visual' && isRichEditingEnabled && ( - + ) } { ( mode === 'text' || ! isRichEditingEnabled ) && ( { + let editorRef; + let markBridgeReady; + + beforeEach( () => { + vi.clearAllMocks(); + editorRef = { current: document.createElement( 'div' ) }; + markBridgeReady = vi.fn(); + // Reset window.editor to initial state (matches the module-level + // `window.editor = window.editor || {}` in use-host-bridge.js) + window.editor = {}; + } ); + + it( 'assigns window.editor methods and calls markBridgeReady', () => { + renderHook( () => + useHostBridge( defaultPost, editorRef, markBridgeReady ) + ); + + // Verify all bridge methods exist + expect( window.editor.setContent ).toBeTypeOf( 'function' ); + expect( window.editor.setTitle ).toBeTypeOf( 'function' ); + expect( window.editor.getContent ).toBeTypeOf( 'function' ); + expect( window.editor.getTitleAndContent ).toBeTypeOf( 'function' ); + expect( window.editor.undo ).toBeTypeOf( 'function' ); + expect( window.editor.redo ).toBeTypeOf( 'function' ); + expect( window.editor.switchEditorMode ).toBeTypeOf( 'function' ); + expect( window.editor.dismissTopModal ).toBeTypeOf( 'function' ); + expect( window.editor.focus ).toBeTypeOf( 'function' ); + expect( window.editor.appendTextAtCursor ).toBeTypeOf( 'function' ); + + expect( markBridgeReady ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'cleans up window.editor methods on unmount', () => { + const { unmount } = renderHook( () => + useHostBridge( defaultPost, editorRef, markBridgeReady ) + ); + + expect( window.editor.setContent ).toBeTypeOf( 'function' ); + + unmount(); + + expect( window.editor.setContent ).toBeUndefined(); + expect( window.editor.setTitle ).toBeUndefined(); + expect( window.editor.getContent ).toBeUndefined(); + expect( window.editor.getTitleAndContent ).toBeUndefined(); + expect( window.editor.undo ).toBeUndefined(); + expect( window.editor.redo ).toBeUndefined(); + expect( window.editor.switchEditorMode ).toBeUndefined(); + expect( window.editor.dismissTopModal ).toBeUndefined(); + expect( window.editor.focus ).toBeUndefined(); + expect( window.editor.appendTextAtCursor ).toBeUndefined(); + } ); +} ); diff --git a/src/components/editor/use-editor-ready.js b/src/components/editor/use-editor-ready.js new file mode 100644 index 000000000..305c95b64 --- /dev/null +++ b/src/components/editor/use-editor-ready.js @@ -0,0 +1,76 @@ +/** + * WordPress dependencies + */ +import { useRef, useCallback } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { editorLoaded } from '../../utils/bridge'; + +/** + * Coordinates two readiness conditions before signaling the native host: + * 1. The host bridge methods (window.editor.*) have been assigned + * 2. The editor element is visible in the viewport + * + * @return {Array} A [callbackRef, editorRef, markBridgeReady] tuple. + * - callbackRef: Attach to the editor root element's `ref` prop. + * - editorRef: Standard ref for imperative access to the DOM node. + * - markBridgeReady: Callback for useHostBridge to signal bridge readiness. + */ +export function useEditorReady() { + const editorRef = useRef( null ); + const bridgeReady = useRef( false ); + const editorVisible = useRef( false ); + const notified = useRef( false ); + const observerRef = useRef( null ); + + const tryNotify = useCallback( () => { + if ( + bridgeReady.current && + editorVisible.current && + ! notified.current + ) { + notified.current = true; + // Defer one frame so the browser has painted the editor content + // before the native host starts the fade-in animation. + requestAnimationFrame( editorLoaded ); + } + }, [] ); + + const markBridgeReady = useCallback( () => { + bridgeReady.current = true; + tryNotify(); + }, [ tryNotify ] ); + + const callbackRef = useCallback( + ( node ) => { + if ( observerRef.current ) { + observerRef.current.disconnect(); + observerRef.current = null; + } + + editorRef.current = node; + + if ( ! node ) { + return; + } + + const observer = new IntersectionObserver( ( entries ) => { + entries.forEach( ( entry ) => { + if ( entry.isIntersecting ) { + editorVisible.current = true; + tryNotify(); + observer.disconnect(); + } + } ); + } ); + + observer.observe( node ); + observerRef.current = observer; + }, + [ tryNotify ] + ); + + return [ callbackRef, editorRef, markBridgeReady ]; +} diff --git a/src/components/editor/use-host-bridge.js b/src/components/editor/use-host-bridge.js index a5e0a6d50..250933406 100644 --- a/src/components/editor/use-host-bridge.js +++ b/src/components/editor/use-host-bridge.js @@ -16,7 +16,7 @@ import { warn } from '../../utils/logger'; window.editor = window.editor || {}; -export function useHostBridge( post, editorRef ) { +export function useHostBridge( post, editorRef, markBridgeReady ) { const { editEntityRecord } = useDispatch( coreStore ); const { undo, redo, switchEditorMode } = useDispatch( editorStore ); const { getEditedPostAttribute, getEditedPostContent } = @@ -176,6 +176,11 @@ export function useHostBridge( post, editorRef ) { return true; }; + // Signal that all window.editor.* methods are assigned. The native + // host is notified only after this AND the editor element is visible + // (coordinated by useEditorReady). + markBridgeReady(); + return () => { delete window.editor.setContent; delete window.editor.setTitle; @@ -185,11 +190,13 @@ export function useHostBridge( post, editorRef ) { delete window.editor.redo; delete window.editor.switchEditorMode; delete window.editor.dismissTopModal; + delete window.editor.focus; delete window.editor.appendTextAtCursor; }; }, [ editorRef, editContent, + markBridgeReady, getEditedPostAttribute, getEditedPostContent, redo, diff --git a/src/components/offline-indicator/index.test.jsx b/src/components/offline-indicator/index.test.jsx index 010d910e2..3752c753d 100644 --- a/src/components/offline-indicator/index.test.jsx +++ b/src/components/offline-indicator/index.test.jsx @@ -9,7 +9,6 @@ import { render, screen, act } from '@testing-library/react'; */ import OfflineIndicator from '.'; -vi.mock( '@wordpress/i18n' ); vi.mock( '../../utils/bridge', () => ( { getGBKit: vi.fn( () => ( {} ) ), } ) ); diff --git a/src/components/text-editor/index.jsx b/src/components/text-editor/index.jsx index 4003de37f..475ce7dae 100644 --- a/src/components/text-editor/index.jsx +++ b/src/components/text-editor/index.jsx @@ -1,6 +1,7 @@ /** * WordPress dependencies */ +import { forwardRef } from '@wordpress/element'; import { PostTitleRaw, PostTextEditor } from '@wordpress/editor'; /** @@ -13,14 +14,17 @@ import './style.scss'; * * @param {Object} props Component props. * @param {boolean} props.hideTitle Whether to hide the title input. + * @param {Object} ref Forwarded ref. * * @return {Element} The rendered text editor component. */ -export default function TextEditor( { hideTitle } ) { +const TextEditor = forwardRef( function TextEditor( { hideTitle }, ref ) { return ( -
+
{ ! hideTitle && }
); -} +} ); + +export default TextEditor; diff --git a/src/components/visual-editor/index.jsx b/src/components/visual-editor/index.jsx index 2352c80b7..36a5ecc31 100644 --- a/src/components/visual-editor/index.jsx +++ b/src/components/visual-editor/index.jsx @@ -6,7 +6,7 @@ import clsx from 'clsx'; /** * WordPress dependencies */ -import { useRef } from '@wordpress/element'; +import { forwardRef, useRef } from '@wordpress/element'; import { BlockList, privateApis as blockEditorPrivateApis, @@ -30,7 +30,6 @@ import EditorToolbar from '../editor-toolbar'; import { useEditorStyles } from './use-editor-styles'; import { unlock } from '../../lock-unlock'; import DefaultBlockAppender from '../default-block-appender'; -import { useEditorVisible } from './use-editor-visible'; import defaultThemeStyles from './default-theme-styles.scss?inline'; import commonStyles from './wp-common-styles.scss?inline'; @@ -55,9 +54,8 @@ const alignCSS = `.is-root-container.alignwide { max-width: var(--wp--style--glo * * @return {Element} The rendered Editor component. */ -function VisualEditor( { hideTitle } ) { +const VisualEditor = forwardRef( function VisualEditor( { hideTitle }, ref ) { const editorPostTitleRef = useRef(); - const editorVisibleRef = useEditorVisible(); const { renderingMode, @@ -139,7 +137,7 @@ function VisualEditor( { hideTitle } ) { ); return ( -
+
{ themeSupportsLayout && ! themeHasDisabledLayoutStyles && @@ -174,6 +172,6 @@ function VisualEditor( { hideTitle } ) {
); -} +} ); export default VisualEditor; diff --git a/src/components/visual-editor/use-editor-visible.js b/src/components/visual-editor/use-editor-visible.js deleted file mode 100644 index 43c89b796..000000000 --- a/src/components/visual-editor/use-editor-visible.js +++ /dev/null @@ -1,34 +0,0 @@ -/** - * WordPress dependencies - */ -import { useEffect, useRef } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import { editorLoaded } from '../../utils/bridge'; - -/** - * Returns a ref for observing an element and triggering the editorLoaded event when it becomes visible. - * - * @return {Object} The ref. - */ -export const useEditorVisible = () => { - const ref = useRef( null ); - - useEffect( () => { - const observer = new IntersectionObserver( ( entries ) => { - entries.forEach( ( entry ) => { - if ( entry.isIntersecting ) { - editorLoaded(); - } - } ); - } ); - - observer.observe( ref.current ); - - return () => observer.disconnect(); - }, [ ref ] ); - - return ref; -}; diff --git a/src/utils/editor.test.jsx b/src/utils/editor.test.jsx index 6c7b52cb7..7e7470567 100644 --- a/src/utils/editor.test.jsx +++ b/src/utils/editor.test.jsx @@ -13,15 +13,10 @@ import { getGBKit, getPost } from './bridge'; import { getDefaultEditorSettings } from './editor-settings'; import { unregisterDisallowedBlocks } from './blocks'; -vi.mock( '@wordpress/blocks', () => ( {} ) ); -vi.mock( '@wordpress/editor', () => ( { - store: { name: 'core/editor' }, -} ) ); +vi.mock( '@wordpress/blocks' ); +vi.mock( '@wordpress/editor' ); vi.mock( import( '@wordpress/data' ), { spy: true } ); -vi.mock( '@wordpress/preferences' ); -vi.mock( '@wordpress/block-library', () => ( { - registerCoreBlocks: vi.fn(), -} ) ); +vi.mock( '@wordpress/block-library' ); vi.mock( './blocks' ); vi.mock( './bridge' ); vi.mock( './editor-settings' ); From 1503125fecb224545a420d775c57cf92f64a1b6a Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:45:34 -0600 Subject: [PATCH 08/23] ci: add CodeQL workflow with Swift analysis (#412) * ci: add CodeQL workflow for Swift analysis on macOS The default CodeQL setup only runs on Linux runners, which can't build this project's Swift package (requires Xcode/iOS SDK). This adds a dedicated workflow using macos-15 runners with path filtering so it only runs when Swift code changes. Co-Authored-By: Claude Opus 4.6 * ci: add workflow_dispatch trigger to CodeQL Swift workflow Allows manual triggering from the Actions tab for testing. Co-Authored-By: Claude Opus 4.6 * ci: trigger CodeQL Swift workflow with trivial whitespace change Remove trailing newline to trigger the path-filtered workflow on this PR. This can be reverted after verifying the workflow passes. Co-Authored-By: Claude Opus 4.6 * ci: replace Swift-only workflow with full CodeQL workflow The default CodeQL setup and custom workflows can't coexist, so this replaces the default setup with a single workflow covering all languages. Swift runs on macos-15 with Xcode 16.3 (for Swift 6.2); JS, Kotlin, and Actions run on ubuntu-latest with autobuild. Co-Authored-By: Claude Opus 4.6 * ci: fix Swift Xcode version and disable default CodeQL setup - Use Xcode 26.0.1 (Swift 6.2) instead of Xcode 16.3 (Swift 6.1) - Disabled default CodeQL setup via API since custom and default workflows cannot coexist - Reverted back to Swift-only + all-languages workflow Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .github/workflows/codeql.yml | 67 +++++++++++++++++++ ios/Sources/GutenbergKitHTTP/HTTPServer.swift | 2 +- 2 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..68d2c6929 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,67 @@ +name: 'CodeQL' + +on: + workflow_dispatch: + push: + branches: [trunk] + pull_request: + branches: [trunk] + schedule: + - cron: '0 6 * * 1' # Weekly on Monday at 6am UTC + +permissions: + security-events: write + contents: read + +jobs: + analyze-interpreted: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + timeout-minutes: 15 + + strategy: + fail-fast: false + matrix: + language: [actions, java-kotlin, javascript-typescript] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: '/language:${{ matrix.language }}' + + analyze-swift: + name: Analyze (swift) + runs-on: macos-15 + timeout-minutes: 30 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_26.0.1.app/Contents/Developer + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: swift + + - name: Build Swift package + run: swift build --target GutenbergKit --target GutenbergKitHTTP + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: '/language:swift' diff --git a/ios/Sources/GutenbergKitHTTP/HTTPServer.swift b/ios/Sources/GutenbergKitHTTP/HTTPServer.swift index 17cfeb1c7..485cc9b79 100644 --- a/ios/Sources/GutenbergKitHTTP/HTTPServer.swift +++ b/ios/Sources/GutenbergKitHTTP/HTTPServer.swift @@ -626,4 +626,4 @@ extension Logger { static let httpServer = Logger(subsystem: "com.gutenbergkit.http", category: "server") } -#endif // canImport(Network) +#endif // canImport(Network) \ No newline at end of file From a49e1bf51463752a4badd951b1eecb8b5a76b0f9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:33:37 -0400 Subject: [PATCH 09/23] build(deps): Bump lodash in the npm_and_yarn group across 1 directory (#414) Bumps the npm_and_yarn group with 1 update in the / directory: [lodash](https://github.com/lodash/lodash). Updates `lodash` from 4.17.23 to 4.18.1 - [Release notes](https://github.com/lodash/lodash/releases) - [Commits](https://github.com/lodash/lodash/compare/4.17.23...4.18.1) --- updated-dependencies: - dependency-name: lodash dependency-version: 4.18.1 dependency-type: direct:production dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 12b35d445..b6e866871 100644 --- a/package-lock.json +++ b/package-lock.json @@ -62,7 +62,7 @@ "@wordpress/wordcount": "^4.42.0", "clsx": "^2.1.1", "jquery": "^3.7.1", - "lodash": "^4.17.23", + "lodash": "^4.18.1", "moment": "^2.30.1", "react": "^18.3.1", "react-dom": "^18.3.1" @@ -17358,9 +17358,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.debounce": { diff --git a/package.json b/package.json index 8180b9e2b..74ea87f9f 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "@wordpress/wordcount": "^4.42.0", "clsx": "^2.1.1", "jquery": "^3.7.1", - "lodash": "^4.17.23", + "lodash": "^4.18.1", "moment": "^2.30.1", "react": "^18.3.1", "react-dom": "^18.3.1" From 09ae7b9016234d2bc6c547a0dfcebd3c0394940f Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:30:32 -0600 Subject: [PATCH 10/23] Move editor loading into the library (#326) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: Move editor loading UI into GutenbergView GutenbergView previously extended WebView directly and delegated all loading UI (progress bar, spinner, error states) to consumers via the EditorLoadingListener interface. This forced every app embedding the editor to implement its own loading UI boilerplate. This change makes GutenbergView extend FrameLayout instead, containing an internal WebView plus overlay views for loading states: - EditorProgressView (progress bar + label) during dependency fetching - ProgressBar (circular/indeterminate) during WebView initialization - EditorErrorView (new) for error states The view manages its own state transitions with 200ms fade animations, matching the iOS EditorViewController pattern. The EditorLoadingListener interface is removed entirely — consumers no longer need loading UI code. Changes: - GutenbergView: WebView -> FrameLayout with internal WebView child - New EditorErrorView for displaying load failures - Delete EditorLoadingListener (no longer needed) - Simplify demo EditorActivity by removing ~90 lines of loading UI - Update tests to use editorWebView accessor for WebView properties - Delete unused activity_editor.xml layout Co-Authored-By: Claude Opus 4.6 Cancel in-flight view animations in onDetachedFromWindow Prevents withEndAction callbacks from firing on detached views if the editor is closed mid-animation. Co-Authored-By: Claude Opus 4.6 Remove unused LoadingState enum from GutenbergView Co-Authored-By: Claude Opus 4.6 Remove unused ASSET_LOADING_TIMEOUT_MS constant from GutenbergView Co-Authored-By: Claude Opus 4.6 * Clear openMediaLibraryListener and logJsExceptionListener in onDetachedFromWindow These two listeners were not being nulled out during teardown, inconsistent with all other listener cleanup in the same method. Co-Authored-By: Claude Opus 4.6 * Update AGP * Add required constructor * Enforce valid postId using the type system * fix: use webView reference in dispatchConnectivityEvent After the FrameLayout refactor, `this.evaluateJavascript` no longer compiles since GutenbergView is no longer a WebView. Co-Authored-By: Claude Opus 4.6 * fix: update detekt baseline and tests for FrameLayout refactor The Detekt baseline referenced `GutenbergView : WebView` which no longer matches after the FrameLayout change. Two tests also referenced `webViewClient` directly on GutenbergView instead of via `editorWebView`. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- android/Gutenberg/detekt-baseline.xml | 2 +- .../gutenberg/EditorLoadingListener.kt | 62 ----- .../org/wordpress/gutenberg/GutenbergView.kt | 235 +++++++++++++----- .../gutenberg/model/EditorConfiguration.kt | 8 +- .../wordpress/gutenberg/model/GBKitGlobal.kt | 8 +- .../gutenberg/services/EditorService.kt | 6 +- .../gutenberg/views/EditorErrorView.kt | 84 +++++++ .../wordpress/gutenberg/GutenbergViewTest.kt | 14 +- .../gutenberg/model/EditorAssetBundleTest.kt | 6 + .../model/EditorConfigurationTest.kt | 34 +-- .../gutenberg/model/GBKitGlobalTest.kt | 15 +- .../gutenberg/services/EditorServiceTest.kt | 31 +-- .../example/gutenbergkit/EditorActivity.kt | 117 +-------- .../src/main/res/layout/activity_editor.xml | 19 -- android/gradle/libs.versions.toml | 2 +- 15 files changed, 320 insertions(+), 323 deletions(-) delete mode 100644 android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorLoadingListener.kt create mode 100644 android/Gutenberg/src/main/java/org/wordpress/gutenberg/views/EditorErrorView.kt delete mode 100644 android/app/src/main/res/layout/activity_editor.xml diff --git a/android/Gutenberg/detekt-baseline.xml b/android/Gutenberg/detekt-baseline.xml index 2c01c2b6e..3e5f564c1 100644 --- a/android/Gutenberg/detekt-baseline.xml +++ b/android/Gutenberg/detekt-baseline.xml @@ -72,7 +72,7 @@ TooGenericExceptionCaught:LocalEditorAssetManifest.kt$LocalEditorAssetManifest$e: Exception TooGenericExceptionThrown:EditorAssetsLibrary.kt$EditorAssetsLibrary$throw Exception("Failed to fetch asset: $httpURL (${connection.responseCode})") TooGenericExceptionThrown:EditorAssetsLibrary.kt$EditorAssetsLibrary$throw Exception("Failed to fetch manifest: ${connection.responseCode}") - TooManyFunctions:GutenbergView.kt$GutenbergView : WebView + TooManyFunctions:GutenbergView.kt$GutenbergView : FrameLayout UnusedParameter:EditorService.kt$EditorService.Companion$coroutineScope: CoroutineScope UnusedPrivateMember:GutenbergView.kt$GutenbergView.Companion$private fun cleanupWarmup() UnusedPrivateProperty:GutenbergView.kt$GutenbergView.Companion$private const val ASSET_LOADING_TIMEOUT_MS = 5000L diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorLoadingListener.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorLoadingListener.kt deleted file mode 100644 index 978cc5fc8..000000000 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorLoadingListener.kt +++ /dev/null @@ -1,62 +0,0 @@ -package org.wordpress.gutenberg - -import org.wordpress.gutenberg.model.EditorProgress - -/** - * Callback interface for monitoring editor loading state. - * - * Implement this interface to receive updates about the editor's loading progress, - * allowing you to display appropriate UI (progress bar, spinner, etc.) while the - * editor initializes. - * - * ## Loading Flow - * - * When dependencies are **not provided** to `GutenbergView.start()`: - * 1. `onDependencyLoadingStarted()` - Begin showing progress bar - * 2. `onDependencyLoadingProgress()` - Update progress bar (called multiple times) - * 3. `onDependencyLoadingFinished()` - Hide progress bar, show spinner - * 4. `onEditorReady()` - Hide spinner, editor is usable - * - * When dependencies **are provided** to `GutenbergView.start()`: - * 1. `onDependencyLoadingFinished()` - Show spinner (no progress phase) - * 2. `onEditorReady()` - Hide spinner, editor is usable - */ -interface EditorLoadingListener { - /** - * Called when dependency loading begins. - * - * This is the appropriate time to show a progress bar to the user. - * Only called when dependencies were not provided to `start()`. - */ - fun onDependencyLoadingStarted() - - /** - * Called periodically with progress updates during dependency loading. - * - * @param progress The current loading progress with completed/total counts. - */ - fun onDependencyLoadingProgress(progress: EditorProgress) - - /** - * Called when dependency loading completes. - * - * This is the appropriate time to hide the progress bar and show a spinner - * while the WebView loads and parses the editor JavaScript. - */ - fun onDependencyLoadingFinished() - - /** - * Called when the editor has fully loaded and is ready for use. - * - * This is the appropriate time to hide all loading indicators and reveal - * the editor. The editor APIs are safe to call after this callback. - */ - fun onEditorReady() - - /** - * Called if dependency loading fails. - * - * @param error The exception that caused the failure. - */ - fun onDependencyLoadingFailed(error: Throwable) -} diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index 7ecba6bf3..5184c26c0 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -12,8 +12,8 @@ import android.net.Uri import android.os.Bundle import android.os.Handler import android.os.Looper -import android.util.AttributeSet import android.util.Log +import android.view.Gravity import android.view.inputmethod.InputMethodManager import android.webkit.ConsoleMessage import android.webkit.CookieManager @@ -26,16 +26,12 @@ import android.webkit.WebResourceResponse import android.webkit.WebStorage import android.webkit.WebView import android.webkit.WebViewClient -import androidx.lifecycle.coroutineScope -import androidx.lifecycle.findViewTreeLifecycleOwner -import androidx.lifecycle.lifecycleScope +import android.widget.FrameLayout +import android.widget.ProgressBar import androidx.webkit.WebViewAssetLoader import androidx.webkit.WebViewAssetLoader.AssetsPathHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.json.JSONException @@ -44,6 +40,8 @@ import org.wordpress.gutenberg.model.EditorConfiguration import org.wordpress.gutenberg.model.EditorDependencies import org.wordpress.gutenberg.model.GBKitGlobal import org.wordpress.gutenberg.services.EditorService +import org.wordpress.gutenberg.views.EditorErrorView +import org.wordpress.gutenberg.views.EditorProgressView import java.util.Collections import java.util.Locale @@ -53,6 +51,10 @@ const val ASSET_PATH_INDEX = "/assets/index.html" /** * A WebView-based Gutenberg block editor for Android. * + * This view manages its own loading UI internally (progress bar during dependency + * fetching, spinner during WebView initialization, error state on failure). + * Consumers do not need to implement loading UI — it is handled automatically. + * * ## Creating a GutenbergView * * This view must be created programmatically - XML layout inflation is not supported. @@ -88,7 +90,8 @@ const val ASSET_PATH_INDEX = "/assets/index.html" * - If `dependencies` is provided, the editor loads immediately (fast path) * - If `dependencies` is null, dependencies are fetched asynchronously before loading */ -class GutenbergView : WebView { +class GutenbergView : FrameLayout { + private val webView: WebView private var isEditorLoaded = false private var didFireEditorLoaded = false private lateinit var assetLoader: WebViewAssetLoader @@ -116,7 +119,6 @@ class GutenbergView : WebView { private var autocompleterTriggeredListener: AutocompleterTriggeredListener? = null private var modalDialogStateListener: ModalDialogStateListener? = null private var networkRequestListener: NetworkRequestListener? = null - private var loadingListener: EditorLoadingListener? = null private var latestContentProvider: LatestContentProvider? = null /** @@ -127,12 +129,22 @@ class GutenbergView : WebView { private val coroutineScope: CoroutineScope + // Internal loading overlay views + private val progressView: EditorProgressView + private val spinnerView: ProgressBar + private val errorView: EditorErrorView + + /** + * Provides access to the internal WebView for tests and advanced use cases. + */ + val editorWebView: WebView get() = webView + var textEditorEnabled: Boolean = false set(value) { field = value val mode = if (value) "text" else "visual" handler.post { - this.evaluateJavascript("editor.switchEditorMode('$mode');", null) + webView.evaluateJavascript("editor.switchEditorMode('$mode');", null) } } @@ -180,8 +192,13 @@ class GutenbergView : WebView { editorDidBecomeAvailableListener = listener } - fun setEditorLoadingListener(listener: EditorLoadingListener?) { - loadingListener = listener + constructor(context: Context) : this( + configuration = EditorConfiguration.bundled(), + dependencies = null, + coroutineScope = CoroutineScope(Dispatchers.IO), + context = context + ) { + Log.e("GutenbergView", "Using the default constructor for `GutenbergView` – this is probably not what you want.") } /** @@ -199,37 +216,134 @@ class GutenbergView : WebView { this.configuration = configuration this.coroutineScope = coroutineScope + // Initialize the asset loader now that context is available + assetLoader = WebViewAssetLoader.Builder() + .addPathHandler("/assets/", AssetsPathHandler(context)) + .build() + + // Create the internal WebView as first child (behind overlays) + webView = WebView(context).apply { + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + alpha = 0f + } + addView(webView) + + // Create loading overlay views + progressView = EditorProgressView(context).apply { + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT, Gravity.CENTER) + loadingText = "Loading Editor..." + visibility = GONE + } + addView(progressView) + + spinnerView = ProgressBar(context).apply { + layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, Gravity.CENTER) + isIndeterminate = true + visibility = GONE + } + addView(spinnerView) + + errorView = EditorErrorView(context).apply { + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT, Gravity.CENTER) + visibility = GONE + } + addView(errorView) + if (dependencies != null) { this.dependencies = dependencies // FAST PATH: Dependencies were provided - load immediately + showSpinnerPhase() loadEditor(dependencies) } else { // ASYNC FLOW: No dependencies - fetch them asynchronously + showProgressPhase() prepareAndLoadEditor() } } + /** + * Transitions to the progress bar phase (dependency fetching). + */ + private fun showProgressPhase() { + handler.post { + progressView.visibility = VISIBLE + spinnerView.visibility = GONE + errorView.visibility = GONE + webView.alpha = 0f + } + } + + /** + * Transitions to the spinner phase (WebView initialization). + */ + private fun showSpinnerPhase() { + handler.post { + progressView.animate().alpha(0f).setDuration(200).withEndAction { + progressView.visibility = GONE + }.start() + spinnerView.alpha = 0f + spinnerView.visibility = VISIBLE + spinnerView.animate().alpha(1f).setDuration(200).start() + errorView.visibility = GONE + webView.alpha = 0f + } + } + + /** + * Transitions to the ready phase (editor visible). + */ + private fun showReadyPhase() { + handler.post { + spinnerView.animate().alpha(0f).setDuration(200).withEndAction { + spinnerView.visibility = GONE + }.start() + progressView.animate().alpha(0f).setDuration(200).withEndAction { + progressView.visibility = GONE + }.start() + errorView.visibility = GONE + webView.animate().alpha(1f).setDuration(200).start() + } + } + + /** + * Transitions to the error phase (loading failed). + */ + private fun showErrorPhase(error: Throwable) { + handler.post { + progressView.animate().alpha(0f).setDuration(200).withEndAction { + progressView.visibility = GONE + }.start() + spinnerView.animate().alpha(0f).setDuration(200).withEndAction { + spinnerView.visibility = GONE + }.start() + errorView.setError(error) + errorView.alpha = 0f + errorView.visibility = VISIBLE + errorView.animate().alpha(1f).setDuration(200).start() + webView.alpha = 0f + } + } + @SuppressLint("SetJavaScriptEnabled") // Without JavaScript we have no Gutenberg private fun initializeWebView() { - this.settings.javaScriptCanOpenWindowsAutomatically = true - this.settings.javaScriptEnabled = true - this.settings.domStorageEnabled = true - + webView.settings.javaScriptCanOpenWindowsAutomatically = true + webView.settings.javaScriptEnabled = true + webView.settings.domStorageEnabled = true + // Set custom user agent - val defaultUserAgent = this.settings.userAgentString - this.settings.userAgentString = "$defaultUserAgent GutenbergKit/${GutenbergKitVersion.VERSION}" - - this.addJavascriptInterface(this, "editorDelegate") - this.visibility = GONE + val defaultUserAgent = webView.settings.userAgentString + webView.settings.userAgentString = "$defaultUserAgent GutenbergKit/${GutenbergKitVersion.VERSION}" - this.webViewClient = object : WebViewClient() { + webView.addJavascriptInterface(this, "editorDelegate") + + webView.webViewClient = object : WebViewClient() { override fun onReceivedError( view: WebView?, request: WebResourceRequest?, error: WebResourceError? ) { - Log.e("GutenbergView", error.toString()) + Log.e("GutenbergView", "Received web error: $error") super.onReceivedError(view, request, error) } @@ -319,7 +433,7 @@ class GutenbergView : WebView { } } - this.webChromeClient = object : WebChromeClient() { + webView.webChromeClient = object : WebChromeClient() { override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { if (consoleMessage != null) { Log.i("GutenbergView", consoleMessage.message()) @@ -366,8 +480,6 @@ class GutenbergView : WebView { * This method is the entry point for the async flow when no dependencies were provided. */ private fun prepareAndLoadEditor() { - loadingListener?.onDependencyLoadingStarted() - Log.i("GutenbergView", "Fetching dependencies...") coroutineScope.launch { @@ -381,7 +493,7 @@ class GutenbergView : WebView { ) Log.i("GutenbergView", "Created editor service") val fetchedDependencies = editorService.prepare { progress -> - loadingListener?.onDependencyLoadingProgress(progress) + progressView.setProgress(progress) Log.i("GutenbergView", "Progress: $progress") } @@ -392,7 +504,7 @@ class GutenbergView : WebView { loadEditor(fetchedDependencies) } catch (e: Exception) { Log.e("GutenbergView", "Failed to load dependencies", e) - loadingListener?.onDependencyLoadingFailed(e) + showErrorPhase(e) } } } @@ -428,8 +540,8 @@ class GutenbergView : WebView { .addPathHandler("/assets/", AssetsPathHandler(this.context)) .build() - // Notify that dependency loading is complete (spinner phase begins) - loadingListener?.onDependencyLoadingFinished() + // Transition to spinner phase (WebView initialization) + showSpinnerPhase() initializeWebView() @@ -440,10 +552,10 @@ class GutenbergView : WebView { } WebStorage.getInstance().deleteAllData() - this.clearCache(true) + webView.clearCache(true) // All cookies are third-party cookies because the root of this document // lives under the configured asset domain (e.g., `https://appassets.androidplatform.net`) - CookieManager.getInstance().setAcceptThirdPartyCookies(this, true) + CookieManager.getInstance().setAcceptThirdPartyCookies(webView, true) // Erase all local cookies before loading the URL – we don't want to persist // anything between uses – otherwise we might send the wrong cookies @@ -452,7 +564,7 @@ class GutenbergView : WebView { for (cookie in configuration.cookies) { CookieManager.getInstance().setCookie(cookie.key, cookie.value) } - this.loadUrl(editorUrl) + webView.loadUrl(editorUrl) Log.i("GutenbergView", "Startup Complete") } @@ -466,7 +578,7 @@ class GutenbergView : WebView { localStorage.setItem('GBKit', JSON.stringify(window.GBKit)); """.trimIndent() - this.evaluateJavascript(gbKitConfig, null) + webView.evaluateJavascript(gbKitConfig, null) } @@ -476,7 +588,7 @@ class GutenbergView : WebView { localStorage.removeItem('GBKit'); """.trimIndent() - this.evaluateJavascript(jsCode, null) + webView.evaluateJavascript(jsCode, null) } fun setContent(newContent: String) { @@ -485,7 +597,7 @@ class GutenbergView : WebView { return } val encodedContent = newContent.encodeForEditor() - this.evaluateJavascript("editor.setContent('$encodedContent');", null) + webView.evaluateJavascript("editor.setContent('$encodedContent');", null) } fun setTitle(newTitle: String) { @@ -494,7 +606,7 @@ class GutenbergView : WebView { return } val encodedTitle = newTitle.encodeForEditor() - this.evaluateJavascript("editor.setTitle('$encodedTitle');", null) + webView.evaluateJavascript("editor.setTitle('$encodedTitle');", null) } interface TitleAndContentCallback { @@ -583,7 +695,7 @@ class GutenbergView : WebView { return } handler.post { - this.evaluateJavascript("editor.getTitleAndContent($completeComposition);") { result -> + webView.evaluateJavascript("editor.getTitleAndContent($completeComposition);") { result -> var lastUpdatedTitle: CharSequence? = null var lastUpdatedContent: CharSequence? = null var changed = false @@ -609,19 +721,19 @@ class GutenbergView : WebView { fun undo() { handler.post { - this.evaluateJavascript("editor.undo();", null) + webView.evaluateJavascript("editor.undo();", null) } } fun redo() { handler.post { - this.evaluateJavascript("editor.redo();", null) + webView.evaluateJavascript("editor.redo();", null) } } fun dismissTopModal() { handler.post { - this.evaluateJavascript("editor.dismissTopModal();", null) + webView.evaluateJavascript("editor.dismissTopModal();", null) } } @@ -632,7 +744,7 @@ class GutenbergView : WebView { } val encodedText = text.encodeForEditor() handler.post { - this.evaluateJavascript("editor.appendTextAtCursor(decodeURIComponent('$encodedText'));", null) + webView.evaluateJavascript("editor.appendTextAtCursor(decodeURIComponent('$encodedText'));", null) } } @@ -645,25 +757,19 @@ class GutenbergView : WebView { if (!isConnected) dispatchConnectivityEvent(false) } if(!didFireEditorLoaded) { - loadingListener?.onEditorReady() editorDidBecomeAvailableListener?.onEditorAvailable(this) this.didFireEditorLoaded = true - this.visibility = VISIBLE - this.alpha = 0f - this.animate() - .alpha(1f) - .setDuration(300) - .start() + showReadyPhase() if (configuration.content.isEmpty()) { // Focus the editor content - this.evaluateJavascript("editor.focus();", null) + webView.evaluateJavascript("editor.focus();", null) // Request focus on the WebView and show the soft keyboard handler.postDelayed({ - this.requestFocus() + webView.requestFocus() val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager - imm?.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) + imm?.showSoftInput(webView, InputMethodManager.SHOW_IMPLICIT) }, 100) } } @@ -750,7 +856,7 @@ class GutenbergView : WebView { } val escapedContextId = contextId.replace("'", "\\'") - this.evaluateJavascript("editor.setMediaUploadAttachment($media, '$escapedContextId');", null) + webView.evaluateJavascript("editor.setMediaUploadAttachment($media, '$escapedContextId');", null) currentMediaContextId = null } @@ -898,13 +1004,20 @@ class GutenbergView : WebView { super.onDetachedFromWindow() stopNetworkMonitoring() clearConfig() - this.stopLoading() + // Cancel in-flight animations to prevent withEndAction callbacks from + // firing on detached views. + progressView.animate().cancel() + spinnerView.animate().cancel() + errorView.animate().cancel() + webView.animate().cancel() + webView.stopLoading() FileCache.clearCache(context) contentChangeListener = null historyChangeListener = null featuredImageChangeListener = null + openMediaLibraryListener = null + logJsExceptionListener = null editorDidBecomeAvailableListener = null - loadingListener = null filePathCallback = null onFileChooserRequested = null autocompleterTriggeredListener = null @@ -913,7 +1026,7 @@ class GutenbergView : WebView { requestInterceptor = DefaultGutenbergRequestInterceptor() latestContentProvider = null handler.removeCallbacksAndMessages(null) - this.destroy() + webView.destroy() } // Network Monitoring @@ -958,7 +1071,7 @@ class GutenbergView : WebView { private fun dispatchConnectivityEvent(isConnected: Boolean) { val eventName = if (isConnected) "online" else "offline" - this.evaluateJavascript("window.dispatchEvent(new Event('$eventName'));", null) + webView.evaluateJavascript("window.dispatchEvent(new Event('$eventName'));", null) } companion object { @@ -976,10 +1089,10 @@ class GutenbergView : WebView { * Clean up warmup resources. */ private fun cleanupWarmup() { - warmupWebView?.let { webView -> - webView.stopLoading() - webView.clearConfig() - webView.destroy() + warmupWebView?.let { view -> + view.webView.stopLoading() + view.clearConfig() + view.webView.destroy() } warmupWebView = null warmupHandler = null diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt index 196111bb3..68dec2bca 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt @@ -10,7 +10,7 @@ import java.util.UUID data class EditorConfiguration( val title: String, val content: String, - val postId: Int?, + val postId: UInt?, val postType: String, val postStatus: String, val themeStyles: Boolean, @@ -57,7 +57,7 @@ data class EditorConfiguration( class Builder(private var siteURL: String, private var siteApiRoot: String, private var postType: String) { private var title: String = "" private var content: String = "" - private var postId: Int? = null + private var postId: UInt? = null private var postStatus: String = "draft" private var themeStyles: Boolean = false private var plugins: Boolean = false @@ -76,7 +76,7 @@ data class EditorConfiguration( fun setTitle(title: String) = apply { this.title = title } fun setContent(content: String) = apply { this.content = content } - fun setPostId(postId: Int?) = apply { this.postId = postId } + fun setPostId(postId: UInt?) = apply { this.postId = postId } fun setPostType(postType: String) = apply { this.postType = postType } fun setPostStatus(postStatus: String) = apply { this.postStatus = postStatus } fun setThemeStyles(themeStyles: Boolean) = apply { this.themeStyles = themeStyles } @@ -181,7 +181,7 @@ data class EditorConfiguration( override fun hashCode(): Int { var result = title.hashCode() result = 31 * result + content.hashCode() - result = 31 * result + (postId ?: 0) + result = 31 * result + (postId?.toInt() ?: 0) result = 31 * result + postType.hashCode() result = 31 * result + postStatus.hashCode() result = 31 * result + themeStyles.hashCode() diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt index eeb9f344b..fdc6568f5 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt @@ -71,7 +71,7 @@ data class GBKitGlobal( @Serializable data class Post( /** The post ID, or -1 for new posts. */ - val id: Int, + val id: Int, // TODO: Instead of the `-1` trick, this should just be `null` for new posts /** The post type (e.g., `post`, `page`). */ val type: String, /** The post status (e.g., `draft`, `publish`, `pending`). */ @@ -95,6 +95,8 @@ data class GBKitGlobal( configuration: EditorConfiguration, dependencies: EditorDependencies? ): GBKitGlobal { + val postId = (configuration.postId?.toInt() ?: -1).takeIf({ it != 0 }) + return GBKitGlobal( siteURL = configuration.siteURL.ifEmpty { null }, siteApiRoot = configuration.siteApiRoot.ifEmpty { null }, @@ -106,9 +108,9 @@ data class GBKitGlobal( hideTitle = configuration.hideTitle, locale = configuration.locale ?: "en", post = Post( - id = configuration.postId ?: -1, + id = postId ?: -1, type = configuration.postType, - status = configuration.postStatus ?: "draft", + status = configuration.postStatus, title = configuration.title.encodeForEditor(), content = configuration.content.encodeForEditor() ), diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/services/EditorService.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/services/EditorService.kt index 6daca23d6..507c36351 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/services/EditorService.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/services/EditorService.kt @@ -289,11 +289,11 @@ class EditorService( val postTypesDataDeferred = async { preparePostTypes() } val postId = configuration.postId - if (postId != null && postId > 0) { - val postDataDeferred = async { preparePost(postId) } + if (postId != null) { + val postDataDeferred = async { preparePost(postId.toInt()) } EditorPreloadList( - postID = postId, + postID = postId.toInt(), postData = postDataDeferred.await(), postType = configuration.postType, postTypeData = postTypeDataDeferred.await(), diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/views/EditorErrorView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/views/EditorErrorView.kt new file mode 100644 index 000000000..995845b76 --- /dev/null +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/views/EditorErrorView.kt @@ -0,0 +1,84 @@ +package org.wordpress.gutenberg.views + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.widget.TextViewCompat + +/** + * A view displaying an error state with an icon, title, and description. + * + * This view is used inside [org.wordpress.gutenberg.GutenbergView] to show + * an error when editor dependencies fail to load. + * + * ## Usage + * + * ```kotlin + * val errorView = EditorErrorView(context) + * errorView.setError(exception) + * ``` + */ +class EditorErrorView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr) { + + private val icon: ImageView + private val titleText: TextView + private val descriptionText: TextView + + init { + orientation = VERTICAL + gravity = Gravity.CENTER + + // Create error icon + icon = ImageView(context).apply { + layoutParams = LayoutParams(dpToPx(48), dpToPx(48)) + setImageResource(android.R.drawable.ic_dialog_alert) + } + + // Create title + titleText = TextView(context).apply { + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply { + topMargin = dpToPx(16) + marginStart = dpToPx(16) + marginEnd = dpToPx(16) + } + gravity = Gravity.CENTER + TextViewCompat.setTextAppearance(this, android.R.style.TextAppearance_Material_Subhead) + text = "Failed to load editor" + } + + // Create description + descriptionText = TextView(context).apply { + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply { + topMargin = dpToPx(8) + marginStart = dpToPx(16) + marginEnd = dpToPx(16) + } + gravity = Gravity.CENTER + TextViewCompat.setTextAppearance(this, android.R.style.TextAppearance_Material_Body1) + } + + addView(icon) + addView(titleText) + addView(descriptionText) + } + + /** + * Updates the error view with the given error. + * + * @param error The exception that caused the failure. + */ + fun setError(error: Throwable) { + descriptionText.text = error.message ?: "Unknown error" + } + + private fun dpToPx(dp: Int): Int { + return (dp * context.resources.displayMetrics.density).toInt() + } +} diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt index 64a85132d..783d6abff 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt @@ -68,7 +68,7 @@ class GutenbergViewTest { } // When - gutenbergView.webChromeClient?.onShowFileChooser( + gutenbergView.editorWebView.webChromeClient?.onShowFileChooser( mockWebView, mockFilePathCallback, mockFileChooserParams @@ -108,7 +108,7 @@ class GutenbergViewTest { // When `when`(mockFileChooserParams.mode).thenReturn(WebChromeClient.FileChooserParams.MODE_OPEN_MULTIPLE) - gutenbergView.webChromeClient?.onShowFileChooser( + gutenbergView.editorWebView.webChromeClient?.onShowFileChooser( mockWebView, mockFilePathCallback, mockFileChooserParams @@ -134,7 +134,7 @@ class GutenbergViewTest { @Test fun `onShowFileChooser stores file path callback`() { // When - gutenbergView.webChromeClient?.onShowFileChooser( + gutenbergView.editorWebView.webChromeClient?.onShowFileChooser( mockWebView, mockFilePathCallback, mockFileChooserParams @@ -148,7 +148,7 @@ class GutenbergViewTest { @Test fun `resetFilePathCallback clears the callback`() { // Given - gutenbergView.webChromeClient?.onShowFileChooser( + gutenbergView.editorWebView.webChromeClient?.onShowFileChooser( mockWebView, mockFilePathCallback, mockFileChooserParams @@ -168,7 +168,7 @@ class GutenbergViewTest { // that was already set up in the @Before method // Then - val userAgent = gutenbergView.settings.userAgentString + val userAgent = gutenbergView.editorWebView.settings.userAgentString assertTrue("User agent should contain GutenbergKit identifier", userAgent.contains("GutenbergKit/")) assertTrue("User agent should contain version number", @@ -188,7 +188,7 @@ class GutenbergViewTest { val request = mock(WebResourceRequest::class.java) `when`(request.url).thenReturn(Uri.parse("https://example.com/assets/index.html")) - val result = siteView.webViewClient.shouldOverrideUrlLoading(siteView, request) + val result = siteView.editorWebView.webViewClient.shouldOverrideUrlLoading(siteView.editorWebView, request) assertFalse("Asset path URLs on the site domain should load in the WebView", result) } @@ -205,7 +205,7 @@ class GutenbergViewTest { val request = mock(WebResourceRequest::class.java) `when`(request.url).thenReturn(Uri.parse("https://example.com/some-page")) - val result = siteView.webViewClient.shouldOverrideUrlLoading(siteView, request) + val result = siteView.editorWebView.webViewClient.shouldOverrideUrlLoading(siteView.editorWebView, request) assertTrue("Non-asset URLs on the site domain should open externally", result) } } diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorAssetBundleTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorAssetBundleTest.kt index f5e85d596..4d9dd9a22 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorAssetBundleTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorAssetBundleTest.kt @@ -230,6 +230,12 @@ class EditorAssetBundleTest { // MARK: - hasAssetData Tests + @Test + fun `hasAssetData returns false for empty bundle`() { + val url = "https://example.com/wp-content/plugins/script.js" + assertFalse(EditorAssetBundle.empty.hasAssetData(url)) + } + @Test fun `hasAssetData returns false for non-existent file`() { val bundle = makeBundle() diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt index e3f9cd0a6..c5e225e4b 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt @@ -72,16 +72,16 @@ class EditorConfigurationBuilderTest { @Test fun `setPostId updates postId`() { val config = builder() - .setPostId(123) + .setPostId(123u) .build() - assertEquals(123, config.postId) + assertEquals(123u, config.postId) } @Test fun `setPostId with null clears postId`() { val config = builder() - .setPostId(123) + .setPostId(123u) .setPostId(null) .build() @@ -265,7 +265,7 @@ class EditorConfigurationBuilderTest { val config = builder() .setTitle("Chained Title") .setContent("

Chained content

") - .setPostId(456) + .setPostId(456u) .setPlugins(true) .setThemeStyles(true) .setLocale("de_DE") @@ -274,7 +274,7 @@ class EditorConfigurationBuilderTest { assertEquals("Chained Title", config.title) assertEquals("

Chained content

", config.content) - assertEquals(456, config.postId) + assertEquals(456u, config.postId) assertTrue(config.plugins) assertTrue(config.themeStyles) assertEquals("de_DE", config.locale) @@ -300,7 +300,7 @@ class EditorConfigurationBuilderTest { val original = builder() .setTitle("Round Trip Title") .setContent("

Round trip content

") - .setPostId(999) + .setPostId(999u) .setPostType("page") .setPostStatus("draft") .setThemeStyles(true) @@ -330,7 +330,7 @@ class EditorConfigurationBuilderTest { fun `toBuilder allows modification of existing config`() { val original = builder() .setTitle("Original Title") - .setPostId(100) + .setPostId(100u) .build() val modified = original.toBuilder() @@ -339,7 +339,7 @@ class EditorConfigurationBuilderTest { assertEquals("Original Title", original.title) assertEquals("Modified Title", modified.title) - assertEquals(100, modified.postId) + assertEquals(100u, modified.postId) } @Test @@ -377,7 +377,7 @@ class EditorConfigurationBuilderTest { @Test fun `toBuilder preserves nullable values when set`() { val original = builder() - .setPostId(123) + .setPostId(123u) .setPostType("post") .setPostStatus("publish") .setEditorSettings("""{"test":true}""") @@ -386,7 +386,7 @@ class EditorConfigurationBuilderTest { val rebuilt = original.toBuilder().build() - assertEquals(123, rebuilt.postId) + assertEquals(123u, rebuilt.postId) assertEquals("post", rebuilt.postType) assertEquals("publish", rebuilt.postStatus) assertEquals("""{"test":true}""", rebuilt.editorSettings) @@ -532,11 +532,11 @@ class EditorConfigurationTest { @Test fun `Configurations with different postId are not equal`() { val config1 = builder() - .setPostId(1) + .setPostId(1u) .build() val config2 = builder() - .setPostId(2) + .setPostId(2u) .build() assertNotEquals(config1, config2) @@ -803,15 +803,15 @@ class EditorConfigurationTest { @Test fun `Configurations can be used in Set`() { val config1 = builder() - .setPostId(1) + .setPostId(1u) .build() val config2 = builder() - .setPostId(2) + .setPostId(2u) .build() val config3 = builder() - .setPostId(1) + .setPostId(1u) .build() val set = setOf(config1, config2, config3) @@ -844,7 +844,7 @@ class EditorConfigurationTest { val config = EditorConfiguration.builder("https://example.com", "https://example.com/wp-json", "post") .setTitle("Test Title") .setContent("Test Content") - .setPostId(123) + .setPostId(123u) .setPostType("post") .setPostStatus("publish") .setThemeStyles(true) @@ -867,7 +867,7 @@ class EditorConfigurationTest { assertEquals("Test Title", config.title) assertEquals("Test Content", config.content) - assertEquals(123, config.postId) + assertEquals(123u, config.postId) assertEquals("post", config.postType) assertEquals("publish", config.postStatus) assertTrue(config.themeStyles) diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/GBKitGlobalTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/GBKitGlobalTest.kt index 91c919dbf..a755ca100 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/GBKitGlobalTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/GBKitGlobalTest.kt @@ -36,7 +36,7 @@ class GBKitGlobalTest { } private fun makeConfiguration( - postId: Int? = null, + postId: UInt? = null, title: String? = null, content: String? = null, siteURL: String = TEST_SITE_URL, @@ -107,7 +107,7 @@ class GBKitGlobalTest { @Test fun `maps postID to post id`() { - val withPostID = makeConfiguration(postId = 42) + val withPostID = makeConfiguration(postId = 42u) val withoutPostID = makeConfiguration(postId = null) val globalWith = GBKitGlobal.fromConfiguration(withPostID, makeDependencies()) @@ -117,6 +117,13 @@ class GBKitGlobalTest { assertEquals(-1, globalWithout.post.id) } + @Test + fun `maps zero postID to negative one`() { + val configuration = makeConfiguration(postId = 0u) + val global = GBKitGlobal.fromConfiguration(configuration, makeDependencies()) + assertEquals(-1, global.post.id) + } + @Test fun `maps title with percent encoding`() { val configuration = makeConfiguration(title = "Hello World") @@ -149,7 +156,7 @@ class GBKitGlobalTest { @Test fun `toJsonString includes all required fields`() { - val configuration = makeConfiguration(postId = 123, title = "Test", content = "Content") + val configuration = makeConfiguration(postId = 123u, title = "Test", content = "Content") val global = GBKitGlobal.fromConfiguration(configuration, makeDependencies()) val jsonString = global.toJsonString() @@ -165,7 +172,7 @@ class GBKitGlobalTest { @Test fun `toJsonString round-trips through serialization`() { - val configuration = makeConfiguration(postId = 99, title = "Round Trip", content = "Test content") + val configuration = makeConfiguration(postId = 99u, title = "Round Trip", content = "Test content") val original = GBKitGlobal.fromConfiguration(configuration, makeDependencies()) val jsonString = original.toJsonString() diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/services/EditorServiceTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/services/EditorServiceTest.kt index a52aec4e2..81afaf959 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/services/EditorServiceTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/services/EditorServiceTest.kt @@ -130,38 +130,19 @@ class EditorServiceTest { // MARK: - preparePreloadList Tests (negative postID handling) @Test - fun `prepare does not fetch post when postID is negative`() = runBlocking { + fun `prepare does not fetch post when postID is null`() = runBlocking { val mockClient = EditorServiceMockHTTPClient() val configuration = testConfiguration.toBuilder() - .setPostId(-1) + .setPostId(null) .build() val service = makeService(configuration = configuration, httpClient = mockClient) service.prepare() - // Verify no request was made to /posts/-1 - val postRequests = mockClient.requestedURLs.filter { it.contains("/posts/-1") } + // Verify no request was made to any specific post endpoint + val postRequests = mockClient.requestedURLs.filter { it.matches(Regex(".*/posts/\\d+.*")) } assertEquals( - "Should not request /posts/-1 for negative post IDs", - emptyList(), - postRequests - ) - } - - @Test - fun `prepare does not fetch post when postID is zero`() = runBlocking { - val mockClient = EditorServiceMockHTTPClient() - val configuration = testConfiguration.toBuilder() - .setPostId(0) - .build() - - val service = makeService(configuration = configuration, httpClient = mockClient) - service.prepare() - - // Verify no request was made to /posts/0 - val postRequests = mockClient.requestedURLs.filter { it.contains("/posts/0") } - assertEquals( - "Should not request /posts/0 for zero post IDs", + "Should not request any post for null post IDs", emptyList(), postRequests ) @@ -171,7 +152,7 @@ class EditorServiceTest { fun `prepare fetches post when postID is positive`() = runBlocking { val mockClient = EditorServiceMockHTTPClient() val configuration = testConfiguration.toBuilder() - .setPostId(123) + .setPostId(123u) .build() val service = makeService(configuration = configuration, httpClient = mockClient) diff --git a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt index 78ed9dce4..9ff8ac71c 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -13,15 +13,10 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Redo @@ -38,14 +33,11 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.lifecycleScope import com.example.gutenbergkit.ui.theme.AppTheme @@ -53,11 +45,9 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.wordpress.gutenberg.model.EditorConfiguration import org.wordpress.gutenberg.GutenbergView -import org.wordpress.gutenberg.EditorLoadingListener import org.wordpress.gutenberg.RecordedNetworkRequest import org.wordpress.gutenberg.model.EditorDependencies import org.wordpress.gutenberg.model.EditorDependenciesSerializer -import org.wordpress.gutenberg.model.EditorProgress class EditorActivity : ComponentActivity() { @@ -126,20 +116,6 @@ class EditorActivity : ComponentActivity() { } } -/** - * Loading state for the editor. - */ -enum class EditorLoadingState { - /** Dependencies are being loaded from the network */ - LOADING_DEPENDENCIES, - /** Dependencies loaded, waiting for WebView to initialize */ - LOADING_EDITOR, - /** Editor is fully ready */ - READY, - /** Loading failed with an error */ - ERROR -} - @OptIn(ExperimentalMaterial3Api::class) @Composable fun EditorScreen( @@ -156,16 +132,6 @@ fun EditorScreen( var isCodeEditorEnabled by remember { mutableStateOf(false) } var gutenbergViewRef by remember { mutableStateOf(null) } - // Loading state - var loadingState by remember { - mutableStateOf( - if (dependencies != null) EditorLoadingState.LOADING_EDITOR - else EditorLoadingState.LOADING_DEPENDENCIES - ) - } - var loadingProgress by remember { mutableFloatStateOf(0f) } - var loadingError by remember { mutableStateOf(null) } - BackHandler(enabled = isModalDialogOpen) { gutenbergViewRef?.dismissTopModal() } @@ -293,7 +259,7 @@ fun EditorScreen( }) setNetworkRequestListener(object : GutenbergView.NetworkRequestListener { override fun onNetworkRequest(request: RecordedNetworkRequest) { - Log.d("EditorActivity", "🌐 Network Request: ${request.method} ${request.url}") + Log.d("EditorActivity", "Network Request: ${request.method} ${request.url}") Log.d("EditorActivity", " Status: ${request.status} ${request.statusText}, Duration: ${request.duration}ms") // Log request headers @@ -321,29 +287,6 @@ fun EditorScreen( } } }) - setEditorLoadingListener(object : EditorLoadingListener { - override fun onDependencyLoadingStarted() { - loadingState = EditorLoadingState.LOADING_DEPENDENCIES - loadingProgress = 0f - } - - override fun onDependencyLoadingProgress(progress: EditorProgress) { - loadingProgress = progress.fractionCompleted.toFloat() - } - - override fun onDependencyLoadingFinished() { - loadingState = EditorLoadingState.LOADING_EDITOR - } - - override fun onEditorReady() { - loadingState = EditorLoadingState.READY - } - - override fun onDependencyLoadingFailed(error: Throwable) { - loadingState = EditorLoadingState.ERROR - loadingError = error.message ?: "Unknown error" - } - }) // Demo app has no persistence layer, so return null. // In a real app, return the persisted title and content from autosave. setLatestContentProvider(object : GutenbergView.LatestContentProvider { @@ -358,63 +301,5 @@ fun EditorScreen( .fillMaxSize() .padding(innerPadding) ) - - // Loading overlay - when (loadingState) { - EditorLoadingState.LOADING_DEPENDENCIES -> { - Box( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - LinearProgressIndicator( - progress = { loadingProgress }, - modifier = Modifier.fillMaxWidth(0.6f) - ) - Text("Loading Editor...") - } - } - } - EditorLoadingState.LOADING_EDITOR -> { - Box( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - CircularProgressIndicator() - Text("Starting Editor...") - } - } - } - EditorLoadingState.ERROR -> { - Box( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text("Failed to load editor") - loadingError?.let { Text(it) } - } - } - } - EditorLoadingState.READY -> { - // Editor is ready, no overlay needed - } - } } } diff --git a/android/app/src/main/res/layout/activity_editor.xml b/android/app/src/main/res/layout/activity_editor.xml deleted file mode 100644 index c39ed4db4..000000000 --- a/android/app/src/main/res/layout/activity_editor.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index a84dd390e..ed8aedc4f 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "8.7.3" +agp = "8.10.1" kotlin = "2.1.21" kotlinx-serialization = "1.7.3" coreKtx = "1.13.1" From aefcc11d4e4745686c83efafc7d4063267fffb5a Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:02:23 -0600 Subject: [PATCH 11/23] fix: normalize namespace trailing slash in RESTAPIRepository (#421) * fix: normalize namespace trailing slash in RESTAPIRepository on both platforms Ensures namespaces like "sites/123" (without trailing slash) produce the same URLs as "sites/123/" so callers don't need to worry about the convention. Adds tests on both platforms to verify. Co-Authored-By: Claude Opus 4.6 * fix: use try? in namespace URL tests to avoid mock decoding failures The mock HTTP client returns empty data, causing JSON decoding errors that prevented the namespace URL assertions from being reached. Since these tests only verify URL construction, try? is appropriate. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: David Calhoun --- .../wordpress/gutenberg/RESTAPIRepository.kt | 4 ++- .../gutenberg/RESTAPIRepositoryTest.kt | 34 +++++++++++++++++-- .../Sources/RESTAPIRepository.swift | 4 ++- .../Services/RESTAPIRepositoryTests.swift | 32 +++++++++++++++++ ios/Tests/GutenbergKitTests/TestHelpers.swift | 8 +++-- 5 files changed, 75 insertions(+), 7 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt index 172e8a872..7a3eee0cc 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt @@ -24,7 +24,9 @@ class RESTAPIRepository( private val json = Json { ignoreUnknownKeys = true } private val apiRoot = configuration.siteApiRoot.trimEnd('/') - private val namespace = configuration.siteApiNamespace.firstOrNull() + private val namespace = configuration.siteApiNamespace.firstOrNull()?.let { + it.trimEnd('/') + "/" + } private val editorSettingsUrl = buildNamespacedUrl(EDITOR_SETTINGS_PATH) private val activeThemeUrl = buildNamespacedUrl(ACTIVE_THEME_PATH) private val siteSettingsUrl = buildNamespacedUrl(SITE_SETTINGS_PATH) diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt index c15b19acf..fa9a742a9 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt @@ -38,12 +38,15 @@ class RESTAPIRepositoryTest { private fun makeConfiguration( shouldUsePlugins: Boolean = true, - shouldUseThemeStyles: Boolean = true + shouldUseThemeStyles: Boolean = true, + siteApiRoot: String = TEST_API_ROOT, + siteApiNamespace: Array = arrayOf() ): EditorConfiguration { - return EditorConfiguration.builder(TEST_SITE_URL, TEST_API_ROOT, "post") + return EditorConfiguration.builder(TEST_SITE_URL, siteApiRoot, "post") .setPlugins(shouldUsePlugins) .setThemeStyles(shouldUseThemeStyles) .setAuthHeader("Bearer test-token") + .setSiteApiNamespace(siteApiNamespace) .build() } @@ -337,6 +340,33 @@ class RESTAPIRepositoryTest { assertEquals(expectedURLs, capturedURLs.toSet()) } + @Test + fun `namespace is inserted into URLs`() = runBlocking { + val capturedURLs = mutableListOf() + val capturingClient = createCapturingClient { capturedURLs.add(it) } + val configuration = makeConfiguration(siteApiNamespace = arrayOf("sites/123/")) + val repository = makeRepository(configuration = configuration, httpClient = capturingClient) + + repository.fetchPost(id = 1) + repository.fetchEditorSettings() + repository.fetchSettingsOptions() + + assertTrue(capturedURLs.any { it.contains("sites/123/posts/1") }) + assertTrue(capturedURLs.any { it.contains("sites/123/settings") }) + } + + @Test + fun `namespace without trailing slash is normalized`() = runBlocking { + val capturedURLs = mutableListOf() + val capturingClient = createCapturingClient { capturedURLs.add(it) } + val configuration = makeConfiguration(siteApiNamespace = arrayOf("sites/123")) + val repository = makeRepository(configuration = configuration, httpClient = capturingClient) + + repository.fetchPost(id = 1) + + assertTrue(capturedURLs.any { it.contains("sites/123/posts/1") }) + } + private fun createCapturingClient(onRequest: (String) -> Unit): EditorHTTPClientProtocol { return object : EditorHTTPClientProtocol { override suspend fun download(url: String, destination: File): EditorHTTPClientDownloadResponse { diff --git a/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift b/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift index efe3d9ad8..91b53fd56 100644 --- a/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift +++ b/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift @@ -61,10 +61,12 @@ public struct RESTAPIRepository: Sendable { /// Builds a URL by inserting the namespace after the version segment of the path. /// For example: `/wp/v2/posts` with namespace `sites/123/` becomes `/wp/v2/sites/123/posts` private static func buildNamespacedURL(apiRoot: URL, path: String, namespace: String?) -> URL { - guard let namespace = namespace else { + guard let rawNamespace = namespace else { return apiRoot.appending(rawPath: path) } + let namespace = rawNamespace.hasSuffix("/") ? rawNamespace : rawNamespace + "/" + // Parse the path to find where to insert the namespace // Path format is typically: /prefix/version/endpoint (e.g., /wp/v2/posts or /wp-block-editor/v1/settings) let components = path.split(separator: "/", omittingEmptySubsequences: true) diff --git a/ios/Tests/GutenbergKitTests/Services/RESTAPIRepositoryTests.swift b/ios/Tests/GutenbergKitTests/Services/RESTAPIRepositoryTests.swift index 030a668a3..c1f52a3f6 100644 --- a/ios/Tests/GutenbergKitTests/Services/RESTAPIRepositoryTests.swift +++ b/ios/Tests/GutenbergKitTests/Services/RESTAPIRepositoryTests.swift @@ -261,6 +261,37 @@ struct RESTAPIRepositoryTests: MakesTestFixtures { #expect(capturedURL?.absoluteString.contains("context=edit") == true) #expect(capturedURL?.absoluteString.contains("/posts/42") == true) } + + @Test("namespace is inserted into URLs") + func namespaceIsInsertedIntoURLs() async throws { + let mockClient = EditorAssetLibraryMockHTTPClient() + let configuration = makeConfiguration(siteApiNamespace: ["sites/123/"]) + let repository = makeRepository(configuration: configuration, httpClient: mockClient) + + // Using try? because the mock returns empty data that fails decoding. + // We only care about the URLs that were requested, not the responses. + _ = try? await repository.fetchPost(id: 1) + _ = try? await repository.fetchEditorSettings() + _ = try? await repository.fetchSettingsOptions() + + let urls = mockClient.requestedURLs.map(\.absoluteString) + #expect(urls.contains { $0.contains("sites/123/posts/1") }) + #expect(urls.contains { $0.contains("sites/123/settings") }) + } + + @Test("namespace without trailing slash is normalized") + func namespaceWithoutTrailingSlashIsNormalized() async throws { + let mockClient = EditorAssetLibraryMockHTTPClient() + let configuration = makeConfiguration(siteApiNamespace: ["sites/123"]) + let repository = makeRepository(configuration: configuration, httpClient: mockClient) + + // Using try? because the mock returns empty data that fails decoding. + // We only care about the URL that was requested, not the response. + _ = try? await repository.fetchPost(id: 1) + + let urls = mockClient.requestedURLs.map(\.absoluteString) + #expect(urls.contains { $0.contains("sites/123/posts/1") }) + } } // MARK: - URL Capturing Mock Client @@ -286,3 +317,4 @@ final class URLCapturingMockHTTPClient: EditorHTTPClientProtocol, @unchecked Sen ) } } + diff --git a/ios/Tests/GutenbergKitTests/TestHelpers.swift b/ios/Tests/GutenbergKitTests/TestHelpers.swift index 7ee0752fa..edb4240a3 100644 --- a/ios/Tests/GutenbergKitTests/TestHelpers.swift +++ b/ios/Tests/GutenbergKitTests/TestHelpers.swift @@ -17,7 +17,7 @@ protocol MakesTestFixtures { func makeConfiguration( postID: Int?, title: String?, content: String?, siteURL: URL, postType: PostTypeDetails, - shouldUsePlugins: Bool, shouldUseThemeStyles: Bool + shouldUsePlugins: Bool, shouldUseThemeStyles: Bool, siteApiNamespace: [String] ) -> EditorConfiguration func makeConfigurationBuilder(postType: PostTypeDetails) -> EditorConfigurationBuilder func makeService(for configuration: EditorConfiguration?) -> EditorService @@ -40,12 +40,14 @@ extension MakesTestFixtures { siteURL: URL = Self.testSiteURL, postType: PostTypeDetails = .post, shouldUsePlugins: Bool = true, - shouldUseThemeStyles: Bool = true + shouldUseThemeStyles: Bool = true, + siteApiNamespace: [String] = [] ) -> EditorConfiguration { var builder = EditorConfigurationBuilder( postType: postType, siteURL: siteURL, - siteApiRoot: Self.testApiRoot + siteApiRoot: Self.testApiRoot, + siteApiNamespace: siteApiNamespace ) .apply(title, { $0.setTitle($1) }) .apply(content, { $0.setContent($1) }) From 3d1bb09276cc2bb9f5fa623fb94058821c4e7586 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:02:48 -0600 Subject: [PATCH 12/23] fix: coerce post ID 0 to nil on both platforms and JS bridge (#420) * fix: coerce post ID 0 to nil on both platforms and JS bridge WordPress uses 0 as a sentinel for "no post" in several contexts; passing it through causes incorrect API requests and editor state. This treats post ID 0 as nil/absent across Android, iOS, and the JS bridge layer, with unit tests on both platforms. Co-Authored-By: Claude Opus 4.6 * test: add JS unit tests for post ID 0 coercion Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: David Calhoun --- .../gutenberg/model/EditorConfiguration.kt | 4 +- .../model/EditorConfigurationTest.kt | 9 ++++ .../Sources/Model/EditorConfiguration.swift | 2 +- .../Model/EditorConfigurationTests.swift | 9 ++++ src/utils/bridge.js | 4 +- src/utils/bridge.test.js | 43 +++++++++++++++++++ 6 files changed, 66 insertions(+), 5 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt index 68dec2bca..05fe263ed 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt @@ -76,7 +76,7 @@ data class EditorConfiguration( fun setTitle(title: String) = apply { this.title = title } fun setContent(content: String) = apply { this.content = content } - fun setPostId(postId: UInt?) = apply { this.postId = postId } + fun setPostId(postId: UInt?) = apply { this.postId = postId?.takeIf { it != 0u } } fun setPostType(postType: String) = apply { this.postType = postType } fun setPostStatus(postStatus: String) = apply { this.postStatus = postStatus } fun setThemeStyles(themeStyles: Boolean) = apply { this.themeStyles = themeStyles } @@ -99,7 +99,7 @@ data class EditorConfiguration( fun build(): EditorConfiguration = EditorConfiguration( title = title, content = content, - postId = postId, + postId = postId?.takeIf { it != 0u }, postType = postType, postStatus = postStatus, themeStyles = themeStyles, diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt index c5e225e4b..c0052980b 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt @@ -78,6 +78,15 @@ class EditorConfigurationBuilderTest { assertEquals(123u, config.postId) } + @Test + fun `setPostId with zero results in null`() { + val config = builder() + .setPostId(0u) + .build() + + assertNull(config.postId) + } + @Test fun `setPostId with null clears postId`() { val config = builder() diff --git a/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift b/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift index 92d8bfa37..69661facd 100644 --- a/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift +++ b/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift @@ -93,7 +93,7 @@ public struct EditorConfiguration: Sendable, Hashable, Equatable { ) { self.title = title self.content = content - self.postID = postID + self.postID = postID == 0 ? nil : postID self.postType = postType self.postStatus = postStatus self.shouldUseThemeStyles = shouldUseThemeStyles diff --git a/ios/Tests/GutenbergKitTests/Model/EditorConfigurationTests.swift b/ios/Tests/GutenbergKitTests/Model/EditorConfigurationTests.swift index 800ae3f2f..a69dc8770 100644 --- a/ios/Tests/GutenbergKitTests/Model/EditorConfigurationTests.swift +++ b/ios/Tests/GutenbergKitTests/Model/EditorConfigurationTests.swift @@ -65,6 +65,15 @@ struct EditorConfigurationBuilderTests: MakesTestFixtures { #expect(config.postID == 123) } + @Test("setPostID with zero results in nil") + func setPostIDWithZeroResultsInNil() { + let config = makeConfigurationBuilder() + .setPostID(0) + .build() + + #expect(config.postID == nil) + } + @Test("setPostID with nil clears postID") func setPostIDWithNilClearsPostID() { let config = makeConfigurationBuilder() diff --git a/src/utils/bridge.js b/src/utils/bridge.js index f389b5e37..7b4933a62 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -300,7 +300,7 @@ export async function getPost() { if ( hostContent ) { debug( 'Using content from native host' ); return { - id: post?.id ?? -1, + id: post?.id || -1, type: post?.type || 'post', restBase: post?.restBase || 'posts', restNamespace: post?.restNamespace || 'wp/v2', @@ -313,7 +313,7 @@ export async function getPost() { if ( post ) { debug( 'Native bridge unavailable, using GBKit initial content' ); return { - id: post.id, + id: post.id || -1, type: post.type || 'post', restBase: post.restBase || 'posts', restNamespace: post.restNamespace || 'wp/v2', diff --git a/src/utils/bridge.test.js b/src/utils/bridge.test.js index 2616efffe..91fa98df4 100644 --- a/src/utils/bridge.test.js +++ b/src/utils/bridge.test.js @@ -282,6 +282,33 @@ describe( 'getPost', () => { expect( result.title.raw ).toBe( 'Updated Title' ); expect( result.content.raw ).toBe( 'Updated Content' ); } ); + + it( 'should coerce post ID 0 to -1 with host content', async () => { + const hostContent = { + title: 'Title', + content: 'Content', + }; + window.webkit = { + messageHandlers: { + requestLatestContent: { + postMessage: vi.fn().mockResolvedValue( hostContent ), + }, + }, + }; + window.GBKit = { + post: { + id: 0, + type: 'post', + status: 'draft', + title: 'Title', + content: 'Content', + }, + }; + + const result = await getPost(); + + expect( result.id ).toBe( -1 ); + } ); } ); describe( 'fallback to GBKit', () => { @@ -340,6 +367,22 @@ describe( 'getPost', () => { restNamespace: 'wp/v2', } ); } ); + + it( 'should coerce post ID 0 to -1 in GBKit fallback', async () => { + window.GBKit = { + post: { + id: 0, + type: 'post', + status: 'draft', + title: 'Title', + content: 'Content', + }, + }; + + const result = await getPost(); + + expect( result.id ).toBe( -1 ); + } ); } ); describe( 'default empty post', () => { From f390848952e98d241075c4e65f1e0f6791a695fa Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 3 Apr 2026 15:50:55 -0400 Subject: [PATCH 13/23] chore(release): 0.15.0 --- .../gutenberg/GutenbergKitVersion.kt | 2 +- .../Sources/GutenbergKitVersion.swift | 2 +- .../Gutenberg/assets/api-fetch-CbwaILdl.js | 1 + .../Gutenberg/assets/api-fetch-nKsASx9c.js | 1 - .../Gutenberg/assets/ar-BRzDowKl.js | 12 + .../Gutenberg/assets/ar-vbe2qB2J.js | 12 - .../Gutenberg/assets/bg-CbMk8K49.js | 1 + .../Gutenberg/assets/bg-DpHoJ9lA.js | 1 - .../Gutenberg/assets/bo-BZT3W9Mq.js | 1 - .../Gutenberg/assets/bo-Cr0MYuBk.js | 1 + .../Gutenberg/assets/build-module-D5GXFIpQ.js | 94 ++ .../Gutenberg/assets/ca-B-E99v0t.js | 12 - .../Gutenberg/assets/ca-B8ABFyJm.js | 12 + .../Gutenberg/assets/cs-CDrVDSpU.js | 12 - .../Gutenberg/assets/cs-CMBu-Anf.js | 12 + .../Gutenberg/assets/cy-B_1pk10W.js | 12 + .../Gutenberg/assets/cy-Y92uX5SZ.js | 12 - .../Gutenberg/assets/da-1jKU7M1G.js | 1 - .../Gutenberg/assets/da-BbhLx5lx.js | 1 + .../Gutenberg/assets/de-CGlepWRy.js | 12 - .../Gutenberg/assets/de-DSldaQ19.js | 12 + .../Gutenberg/assets/editor-CZ39QhNu.js | 5 + .../Gutenberg/assets/editor-DBczX4c0.css | 1 + .../Gutenberg/assets/editor-WKB0ycg2.css | 1 - .../Gutenberg/assets/editor-WTlclV7y.js | 5 - .../Gutenberg/assets/el-DGG3BdvZ.js | 2 - .../Gutenberg/assets/el-DGUV2o7t.js | 2 + .../Gutenberg/assets/en-au-CXSeBEEX.js | 12 + .../Gutenberg/assets/en-au-DafKva9-.js | 12 - .../Gutenberg/assets/en-ca-B0X2iS_0.js | 12 + .../Gutenberg/assets/en-ca-DMyUdabF.js | 12 - .../Gutenberg/assets/en-gb-C1scU5K9.js | 12 + .../Gutenberg/assets/en-gb-DmO7M5uL.js | 12 - .../Gutenberg/assets/en-nz-4qSPpwVd.js | 8 - .../Gutenberg/assets/en-nz-TmujFKxG.js | 8 + .../Gutenberg/assets/en-za-BP2Euw8N.js | 8 + .../Gutenberg/assets/en-za-DEN86H2e.js | 8 - .../Gutenberg/assets/es-D2poV6oP.js | 12 - .../Gutenberg/assets/es-DKX4DMth.js | 12 + .../Gutenberg/assets/es-ar-CENgQNsN.js | 12 - .../Gutenberg/assets/es-ar-CFpPnbEZ.js | 12 + .../Gutenberg/assets/es-cl-CcAH889Y.js | 12 + .../Gutenberg/assets/es-cl-CzanCUJv.js | 12 - .../Gutenberg/assets/es-cr-CV8ehVn3.js | 1 - .../Gutenberg/assets/es-cr-CWkboHmD.js | 1 + .../Gutenberg/assets/fa-BygSCXDV.js | 15 + .../Gutenberg/assets/fa-tDgbOSC6.js | 15 - .../Gutenberg/assets/fr-B-F5iWko.js | 16 + .../Gutenberg/assets/fr-C-cf6WRC.js | 16 - .../Gutenberg/assets/gl-B2C_7IGZ.js | 13 - .../Gutenberg/assets/gl-C2KSsBu_.js | 13 + .../Gutenberg/assets/he-Bx9Llndx.js | 12 - .../Gutenberg/assets/he-Cy_tFnrL.js | 12 + .../Gutenberg/assets/hr-2lPi5GVU.js | 8 - .../Gutenberg/assets/hr-D6KUgJs9.js | 8 + .../Gutenberg/assets/hu-BZh5DWnC.js | 1 - .../Gutenberg/assets/hu-CRdQv_u3.js | 1 + .../Gutenberg/assets/id-D2c1_SmH.js | 12 - .../Gutenberg/assets/id-DVHmPr8r.js | 12 + .../Gutenberg/assets/index-CkyGEgCT.css | 1 - .../Gutenberg/assets/index-Ct2z7L49.js | 94 -- .../Gutenberg/assets/index-DIUPWyT0.js | 22 - .../Gutenberg/assets/index-DiCwSwMw.js | 22 + .../Gutenberg/assets/index-DmTXC1Bm.css | 1 + .../Gutenberg/assets/is-Bg-7e28n.js | 1 + .../Gutenberg/assets/is-LDNX5Zul.js | 1 - .../Gutenberg/assets/it-C4a5U-9-.js | 12 + .../Gutenberg/assets/it-D0L_ELwt.js | 12 - .../Gutenberg/assets/ja-BxsKFhc_.js | 13 + .../Gutenberg/assets/ja-WHACik0L.js | 13 - .../Gutenberg/assets/ka-Co4-Ax5a.js | 1 + .../Gutenberg/assets/ka-Do0FZ3cZ.js | 1 - .../Gutenberg/assets/ko-AomNiHLY.js | 45 - .../Gutenberg/assets/ko-DPa8rVw9.js | 45 + .../Gutenberg/assets/nb-DqvQisRV.js | 10 + .../Gutenberg/assets/nb-F5O_h0Eo.js | 10 - .../Gutenberg/assets/nl-BnbF5dUh.js | 12 - .../Gutenberg/assets/nl-CVRKlcdK.js | 12 + .../Gutenberg/assets/nl-be-nTaSug3Y.js | 12 + .../Gutenberg/assets/nl-be-uvnU5AbZ.js | 12 - .../Gutenberg/assets/pl-BeIWKeYU.js | 14 + .../Gutenberg/assets/pl-DFnL5syL.js | 14 - .../Gutenberg/assets/pt-BRRMTnMv.js | 12 + .../Gutenberg/assets/pt-br-DrdKMjE8.js | 13 - .../Gutenberg/assets/pt-br-UCkBcRdR.js | 13 + .../Gutenberg/assets/pt-vaZ1N79V.js | 12 - .../Gutenberg/assets/ro-CPQI8Q7h.js | 12 + .../Gutenberg/assets/ro-CQNJ3pUA.js | 12 - .../Gutenberg/assets/ru-BJQv-x3_.js | 12 + .../Gutenberg/assets/ru-CMvYnpXk.js | 12 - .../Gutenberg/assets/sk-CCTXKm6u.js | 1 + .../Gutenberg/assets/sk-tdtlw8Hy.js | 1 - .../Gutenberg/assets/sq-B38DpqOk.js | 13 + .../Gutenberg/assets/sq-CPV0tHku.js | 13 - .../Gutenberg/assets/sr-C191_auF.js | 1 + .../Gutenberg/assets/sr-CLsZ1wfl.js | 1 - .../Gutenberg/assets/sv-CK60gzLz.js | 12 - .../Gutenberg/assets/sv-CwxgK7UX.js | 12 + .../Gutenberg/assets/th-gDYb02cr.js | 8 - .../Gutenberg/assets/th-s7PafNAC.js | 8 + .../Gutenberg/assets/tr-BLyw6lDY.js | 12 - .../Gutenberg/assets/tr-ZnYw7SJ_.js | 12 + .../Gutenberg/assets/uk-8ZQKfN69.js | 12 - .../Gutenberg/assets/uk-BLjDJmWi.js | 12 + .../Gutenberg/assets/ur-DZoS6L9G.js | 1 - .../Gutenberg/assets/ur-kQvbpE46.js | 1 + .../assets/use-block-types-state-Cnzp36m_.js | 385 ++++++ .../Gutenberg/assets/utils-DJM2fdUS.js | 88 -- .../Gutenberg/assets/vi-CKW0RNWU.js | 12 - .../Gutenberg/assets/vi-DvfD4O4_.js | 12 + ...er-CJOEmnUt.js => vips-worker-Blyh5eE-.js} | 4 +- .../assets/wordpress-globals-BDEVKpit.js | 1154 ----------------- .../assets/wordpress-globals-lBX-K6Ks.js | 859 ++++++++++++ .../Gutenberg/assets/wp-util-eBFziq6S.js | 1 + .../Gutenberg/assets/zh-cn-CNipg5Y_.js | 13 - .../Gutenberg/assets/zh-cn-COesGBje.js | 13 + .../Gutenberg/assets/zh-tw-BRBRISvP.js | 12 + .../Gutenberg/assets/zh-tw-xOy6N8V8.js | 12 - .../Gutenberg/index.html | 4 +- package-lock.json | 4 +- package.json | 2 +- 121 files changed, 1875 insertions(+), 1872 deletions(-) create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/api-fetch-CbwaILdl.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/api-fetch-nKsASx9c.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/ar-BRzDowKl.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/ar-vbe2qB2J.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/bg-CbMk8K49.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/bg-DpHoJ9lA.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/bo-BZT3W9Mq.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/bo-Cr0MYuBk.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/build-module-D5GXFIpQ.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/ca-B-E99v0t.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/ca-B8ABFyJm.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/cs-CDrVDSpU.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/cs-CMBu-Anf.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/cy-B_1pk10W.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/cy-Y92uX5SZ.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/da-1jKU7M1G.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/da-BbhLx5lx.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/de-CGlepWRy.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/de-DSldaQ19.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/editor-CZ39QhNu.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/editor-DBczX4c0.css delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/editor-WKB0ycg2.css delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/editor-WTlclV7y.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/el-DGG3BdvZ.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/el-DGUV2o7t.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/en-au-CXSeBEEX.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/en-au-DafKva9-.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/en-ca-B0X2iS_0.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/en-ca-DMyUdabF.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/en-gb-C1scU5K9.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/en-gb-DmO7M5uL.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/en-nz-4qSPpwVd.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/en-nz-TmujFKxG.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/en-za-BP2Euw8N.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/en-za-DEN86H2e.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/es-D2poV6oP.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/es-DKX4DMth.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/es-ar-CENgQNsN.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/es-ar-CFpPnbEZ.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/es-cl-CcAH889Y.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/es-cl-CzanCUJv.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/es-cr-CV8ehVn3.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/es-cr-CWkboHmD.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/fa-BygSCXDV.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/fa-tDgbOSC6.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/fr-B-F5iWko.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/fr-C-cf6WRC.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/gl-B2C_7IGZ.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/gl-C2KSsBu_.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/he-Bx9Llndx.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/he-Cy_tFnrL.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/hr-2lPi5GVU.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/hr-D6KUgJs9.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/hu-BZh5DWnC.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/hu-CRdQv_u3.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/id-D2c1_SmH.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/id-DVHmPr8r.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/index-CkyGEgCT.css delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/index-Ct2z7L49.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/index-DIUPWyT0.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/index-DiCwSwMw.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/index-DmTXC1Bm.css create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/is-Bg-7e28n.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/is-LDNX5Zul.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/it-C4a5U-9-.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/it-D0L_ELwt.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/ja-BxsKFhc_.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/ja-WHACik0L.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/ka-Co4-Ax5a.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/ka-Do0FZ3cZ.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/ko-AomNiHLY.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/ko-DPa8rVw9.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/nb-DqvQisRV.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/nb-F5O_h0Eo.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/nl-BnbF5dUh.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/nl-CVRKlcdK.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/nl-be-nTaSug3Y.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/nl-be-uvnU5AbZ.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/pl-BeIWKeYU.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/pl-DFnL5syL.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/pt-BRRMTnMv.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/pt-br-DrdKMjE8.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/pt-br-UCkBcRdR.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/pt-vaZ1N79V.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/ro-CPQI8Q7h.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/ro-CQNJ3pUA.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/ru-BJQv-x3_.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/ru-CMvYnpXk.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/sk-CCTXKm6u.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/sk-tdtlw8Hy.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/sq-B38DpqOk.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/sq-CPV0tHku.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/sr-C191_auF.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/sr-CLsZ1wfl.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/sv-CK60gzLz.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/sv-CwxgK7UX.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/th-gDYb02cr.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/th-s7PafNAC.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/tr-BLyw6lDY.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/tr-ZnYw7SJ_.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/uk-8ZQKfN69.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/uk-BLjDJmWi.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/ur-DZoS6L9G.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/ur-kQvbpE46.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/use-block-types-state-Cnzp36m_.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/utils-DJM2fdUS.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/vi-CKW0RNWU.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/vi-DvfD4O4_.js rename ios/Sources/GutenbergKitResources/Gutenberg/assets/{vips-worker-CJOEmnUt.js => vips-worker-Blyh5eE-.js} (99%) delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/wordpress-globals-BDEVKpit.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/wordpress-globals-lBX-K6Ks.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/wp-util-eBFziq6S.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/zh-cn-CNipg5Y_.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/zh-cn-COesGBje.js create mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/zh-tw-BRBRISvP.js delete mode 100644 ios/Sources/GutenbergKitResources/Gutenberg/assets/zh-tw-xOy6N8V8.js diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergKitVersion.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergKitVersion.kt index a671b0842..0d877fad4 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergKitVersion.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergKitVersion.kt @@ -10,5 +10,5 @@ object GutenbergKitVersion { /** * The current version of GutenbergKit. */ - const val VERSION = "0.14.0" + const val VERSION = "0.15.0" } diff --git a/ios/Sources/GutenbergKit/Sources/GutenbergKitVersion.swift b/ios/Sources/GutenbergKit/Sources/GutenbergKitVersion.swift index 03b83c560..943a82903 100644 --- a/ios/Sources/GutenbergKit/Sources/GutenbergKitVersion.swift +++ b/ios/Sources/GutenbergKit/Sources/GutenbergKitVersion.swift @@ -4,5 +4,5 @@ /// GutenbergKit version information. public enum GutenbergKitVersion { /// The current version of GutenbergKit. - public static let version = "0.14.0" + public static let version = "0.15.0" } diff --git a/ios/Sources/GutenbergKitResources/Gutenberg/assets/api-fetch-CbwaILdl.js b/ios/Sources/GutenbergKitResources/Gutenberg/assets/api-fetch-CbwaILdl.js new file mode 100644 index 000000000..060118bcd --- /dev/null +++ b/ios/Sources/GutenbergKitResources/Gutenberg/assets/api-fetch-CbwaILdl.js @@ -0,0 +1 @@ +import{r as e}from"./index-DiCwSwMw.js";var t=window.wp.apiFetch,{getQueryArg:n}=window.wp.url;function r(){let{siteApiRoot:n=``,preloadData:r=null}=e();t.use(t.createRootURLMiddleware(n)),t.use(i),t.use(a),t.use(o),t.use(s),t.use(c),t.use(l),t.use(t.createPreloadingMiddleware(r??u))}function i(e,t){return e.mode=`cors`,e.headers&&delete e.headers[`x-wp-api-fetch-from-editor`],t(e)}function a(t,n){let{siteApiNamespace:r,namespaceExcludedPaths:i}=e(),a=RegExp(`(${r.join(`|`)})`),o=t.path&&!i.some(e=>t.path.startsWith(e)),s=a.test(t.path)||/\/sites\/[^/]+\//.test(t.path);return o&&!s&&(t.path=t.path.replace(/^(?\/?(?:[\w.-]+\/){2})/,`$${r[0]}`)),n(t)}function o(t,n){let{authHeader:r}=e();return t.headers=t.headers||{},r&&(t.headers.Authorization=r,t.credentials=`omit`),n(t)}function s(t,n){let{post:r}=e(),{id:i,restNamespace:a,restBase:o}=r??{};if(i===void 0||!a||!o)return n(t);let s=`/${a}/${o}/${i}`;return t.path===s||t.path?.startsWith(`${s}?`)?Promise.resolve([]):n(t)}function c(e,t){return e.path&&e.path.startsWith(`/wp/v2/media`)&&e.method===`POST`&&e.body instanceof FormData&&e.body.get(`post`)===`-1`&&e.body.delete(`post`),t(e)}function l(e,t){if(e.path&&e.path.indexOf(`oembed`)!==-1){let r=n(e.path,`url`),i=t(e,t);function a(){let e=document.createElement(`a`);return e.href=r,e.innerText=r,{html:e.outerHTML,type:`rich`,provider_name:`Embed`}}return new Promise(e=>{i.then(t=>{if(t.html){let e=document.implementation.createHTMLDocument(``);e.body.innerHTML=t.html;let n=[`[class="embed-youtube"]`,`[class="embed-vimeo"]`,`[class="embed-dailymotion"]`,`[class="embed-ted"]`].join(`,`),r=e.querySelector(n);t.html=r?r.innerHTML:t.html}e(t)}).catch(()=>{e(a())})})}return t(e,t)}var u={"/wp/v2/types?context=view":{body:{post:{description:``,hierarchical:!1,has_archive:!1,name:`Posts`,slug:`post`,taxonomies:[`category`,`post_tag`],rest_base:`posts`,rest_namespace:`wp/v2`,template:[],template_lock:!1,_links:{}},page:{description:``,hierarchical:!0,has_archive:!1,name:`Pages`,slug:`page`,taxonomies:[],rest_base:`pages`,rest_namespace:`wp/v2`,template:[],template_lock:!1,_links:{}}}},"/wp/v2/types/post?context=edit":{body:{name:`Posts`,slug:`post`,supports:{title:!0,editor:!0,author:!0,thumbnail:!0,excerpt:!0,trackbacks:!0,"custom-fields":!0,comments:!0,revisions:!0,"post-formats":!0,autosave:!0},taxonomies:[`category`,`post_tag`],rest_base:`posts`,rest_namespace:`wp/v2`,template:[],template_lock:!1}}};export{r as configureApiFetch}; \ No newline at end of file diff --git a/ios/Sources/GutenbergKitResources/Gutenberg/assets/api-fetch-nKsASx9c.js b/ios/Sources/GutenbergKitResources/Gutenberg/assets/api-fetch-nKsASx9c.js deleted file mode 100644 index 52bb4a4fb..000000000 --- a/ios/Sources/GutenbergKitResources/Gutenberg/assets/api-fetch-nKsASx9c.js +++ /dev/null @@ -1 +0,0 @@ -import{m as o}from"./index-DIUPWyT0.js";const c=window.wp.apiFetch,{getQueryArg:m}=window.wp.url;function P(){const{siteApiRoot:e="",preloadData:t=null}=o();c.use(c.createRootURLMiddleware(e)),c.use(p),c.use(h),c.use(f),c.use(w),c.use(b),c.use(g),c.use(c.createPreloadingMiddleware(t??_))}function p(e,t){return e.mode="cors",delete e.headers["x-wp-api-fetch-from-editor"],t(e)}function h(e,t){const{siteApiNamespace:a,namespaceExcludedPaths:i}=o(),n=new RegExp(`(${a.join("|")})`);return e.path&&!i.some(s=>e.path.startsWith(s))&&!n.test(e.path)&&(e.path=e.path.replace(/^(?\/?(?:[\w.-]+\/){2})/,`$${a[0]}`)),t(e)}function f(e,t){const{authHeader:a}=o();return e.headers=e.headers||{},a&&(e.headers.Authorization=a,e.credentials="omit"),t(e)}function w(e,t){const{post:a}=o(),{id:i,restNamespace:n,restBase:r}=a??{};if(i===void 0||!n||!r)return t(e);const s=`/${n}/${r}/${i}`;return e.path===s||e.path?.startsWith(`${s}?`)?Promise.resolve([]):t(e)}function b(e,t){return e.path&&e.path.startsWith("/wp/v2/media")&&e.method==="POST"&&e.body instanceof FormData&&e.body.get("post")==="-1"&&e.body.delete("post"),t(e)}function g(e,t){if(e.path&&e.path.indexOf("oembed")!==-1){let n=function(){const r=document.createElement("a");return r.href=a,r.innerText=a,{html:r.outerHTML,type:"rich",provider_name:"Embed"}};const a=m(e.path,"url"),i=t(e,t);return new Promise(r=>{i.then(s=>{if(s.html){const l=document.implementation.createHTMLDocument("");l.body.innerHTML=s.html;const d=['[class="embed-youtube"]','[class="embed-vimeo"]','[class="embed-dailymotion"]','[class="embed-ted"]'].join(","),u=l.querySelector(d);s.html=u?u.innerHTML:s.html}r(s)}).catch(()=>{r(n())})})}return t(e,t)}const _={"/wp/v2/types?context=view":{body:{post:{description:"",hierarchical:!1,has_archive:!1,name:"Posts",slug:"post",taxonomies:["category","post_tag"],rest_base:"posts",rest_namespace:"wp/v2",template:[],template_lock:!1,_links:{}},page:{description:"",hierarchical:!0,has_archive:!1,name:"Pages",slug:"page",taxonomies:[],rest_base:"pages",rest_namespace:"wp/v2",template:[],template_lock:!1,_links:{}}}},"/wp/v2/types/post?context=edit":{body:{name:"Posts",slug:"post",supports:{title:!0,editor:!0,author:!0,thumbnail:!0,excerpt:!0,trackbacks:!0,"custom-fields":!0,comments:!0,revisions:!0,"post-formats":!0,autosave:!0},taxonomies:["category","post_tag"],rest_base:"posts",rest_namespace:"wp/v2",template:[],template_lock:!1}}};export{P as configureApiFetch}; diff --git a/ios/Sources/GutenbergKitResources/Gutenberg/assets/ar-BRzDowKl.js b/ios/Sources/GutenbergKitResources/Gutenberg/assets/ar-BRzDowKl.js new file mode 100644 index 000000000..ccffade18 --- /dev/null +++ b/ios/Sources/GutenbergKitResources/Gutenberg/assets/ar-BRzDowKl.js @@ -0,0 +1,12 @@ +var e=[],t=[],n=[],r=[],i=[],a=[],o=[],s=[],c=[],l=[],u=[],d=[],f=[],p=[],m=[],h=[],g=[],_=[],v=[],y=[],b=[],x=[],S=[],C=[],w=[],T=[],E=[],D=[],O=[],k=[],A=[],j=[],M=[],N=[],P=[],F=[],I=[],L=[],R=[],z=[],B=[],V=[],H=[],U=[],W=[],G=[],K=[],q=[],J=[`قطع`],Y=[`تجاهل`],X=[],Z=[],Q=[],$=[],ee=[`تعليقات`],te=[],ne=[`عرض`],re=[],ie=[],ae=[],oe=[`الصفحة الرئيسية`],se=[],ce=[],le=[],ue=[],de=[],fe=[],pe=[],me=[],he=[],ge=[],_e=[],ve=[],ye=[],be=[],xe=[],Se=[`صفوف`],Ce=[],we=[],Te=[],Ee=[],De=[],Oe=[],ke=[],Ae=[],je=[],Me=[],Ne=[],Pe=[],Fe=[],Ie=[],Le=[],Re=[],ze=[],Be=[],Ve=[`البريد الإلكتروني`],He=[`المصدر`],Ue=[`الخطوط`],We=[],Ge=[],Ke=[],qe=[],Je=[],Ye=[`فصل`],Xe=[`كلمة المرور`],Ze=[`هامش`],Qe=[`الأعداد`],$e=[],et=[],tt=[],nt=[],rt=[`بإنتظار المراجعة`],it=[`اقتراحات`],at=[`اللغة`],ot=[],st=[`تفعيل`],ct=[`الدقة`],lt=[`إدراج`],ut=[`Openverse`],dt=[`الظل`],ft=[`وسط`],pt=[`الموضع`],mt=[`مثتبة`],ht=[`التلده`],gt=[`CSS`],_t=[`مقاطع فيديو`],vt=[],yt=[`إنقاص`],bt=[`زيادة`],xt=[`كلمات توضيحية`],St=[`نمط`],Ct=[`مقبض`],wt=[`XXL`],Tt=[`الخط`],Et=[`مقيده`],Dt=[`ع6`],Ot=[`ع5`],kt=[`ع4`],At=[`ع3`],jt=[`ع2`],Mt=[`ع1`],Nt=[`الفئات`],Pt=[`تمرير المؤشر`],Ft=[`ملخص`],It=[`إلغاء تعيين`],Lt=[`الآن`],Rt=[`الآباء`],zt=[`اللاحقة`],Bt=[`البادئة`],Vt=[`يقول`],Ht=[`استجابة`],Ut=[`الردود`],Wt=[`كُدس`],Gt=[`أسبوع`],Kt=[`غير صالح`],qt=[`قفل`],Jt=[`الغاء القفل`],Yt=[`معاينة`],Xt=[`تمّ التنفيذ`],Zt=[`أيقونة`],Qt=[`حذف`],$t=[`إجراءات`],en=[`إعادة تسمية`],tn=[`Aa`],nn=[`الأنماط`],rn=[`قوائم`],an=[`رد`],on=[`العناصر`],sn=[`القوائم الفرعية`],cn=[`دائمًا`],ln=[`العرض`],un=[`إشارة مرجعية`],dn=[`تمييز`],fn=[`طبق الألوان`],pn=[`الألوان`],mn=[`السهم`],hn=[`صف`],gn=[`ضبط`],_n=[`انسياب`],vn=[`الثني`],yn=[`النشر`],bn=[`النمط`],xn=[`نصف القطر`],Sn=[`الهامش`],Cn=[`التراكُب اللوني (Duotone)`],wn=[`الشعار`],Tn=[`التمييز`],En=[`الظِلال`],Dn=[`التخطيط`],On=[`منقط`],kn=[`متقطع`],An=[`تخصيص`],jn=[`إطار`],Mn=[`شبكة`],Nn=[`المنطقة`],Pn=[`إضافة إقتباس/زيادة المسافة البادئة`],Fn=[`إزالة إقتباس/إنقاص المسافة البادئة`],In=[`مرتب`],Ln=[`غير مرتب`],Rn=[`سحب`],zn=[`محاذاة`],Bn=[],Vn=[`الكتابة بأحرف كبيرة`],Hn=[`أحرف صغيرة`],Un=[`أحرف كبيرة`],Wn=[`عمودي`],Gn=[`أفقي`],Kn=[`القوالب`],qn=[`الكلمة المفتاحية`],Jn=[`عوامل التصفية`],Yn=[`زخرفة`],Xn=[`فقط`],Zn=[`استثناء`],Qn=[`تضمين`],$n=[`المظهر`],er=[`التفضيلات`],tr=[`النوع`],nr=[`التسمية`],rr=[`فصول`],ir=[`أوصاف`],ar=[`كلمات توضيحية`],or=[`ترجمات`],sr=[`الوسوم`],cr=[`التفاصيل`],lr=[`شعاعي`],ur=[`خطي`],dr=[`غير معروف`],fr=[`أحرف`],pr=[`الوصف`],mr=[`الأساس`],hr=[`كاتب`],gr=[`الأصل`],_r=[`الاسم`],vr=[`صورة`],yr=[`منظر أفقي`],br=[`مختلط`],xr=[`يمين`],Sr=[`يسار`],Cr=[`أسفل`],wr=[`أعلى`],Tr=[`الحشو`],Er=[`مسافة التباعد`],Dr=[`الإتجاه`],Or=[`قص`],kr=[`تدوير`],Ar=[`تكبير`],jr=[`تصميم`],Mr=[`نصّ`],Nr=[`الإشعارات`],Pr=[`صفحة`,`صفحة واحدة`,`صفحتان`,`صفحات`,`صفحة`,`صفحة`],Fr=[`إزاحة`],Ir=[`مقالات`],Lr=[`صفحات`],Rr=[`غير مصنف`],zr=[`أبيض`],Br=[`أسود`],Vr=[`مُحدَّد`],Hr=[`أحرف علوية`],Ur=[`أحرف سفلية`],Wr=[`الأنماط`],Gr=[`الخطوط`],Kr=[`المحتوى `],qr=[`القائمة`],Jr=[`الاتصال`],Yr=[`حول`],Xr=[`الرئيسية`],Zr=[`المستخدم`],Qr=[`الموقع`],$r=[`إنشاء`],ei=[`سطح المكتب`],ti=[`الجوال`],ni=[`الأجهزة اللوحية`],ri=[`استطلاع رأي`],ii=[`اجتماعي`],ai=[`لون كامل`],oi=[`النوع`],si=[`زاوية`],ci=[`اختيار`],li=[`قالب`],ui=[`فارغ`],di=[`الأزرار`],fi=[`الخلفية`],pi=[`مساعدة`],mi=[`بدون عنوان`],hi=[`التالي`],gi=[`السابق`],_i=[`إنهاء`],vi=[`استبدال`],yi=[`أداة الإدراج`],bi=[`بودكاست`],xi=[`التنقّل`],Si=[`القالب`],Ci=[`التدرّج`],wi=[`أزرق منتصف الليل`],Ti=[`النسخة`],Ei=[`الأبعاد`],Di=[`القوالب`],Oi=[`أضف`],ki=[`اللون`],Ai=[`مُخصص`],ji=[`مسودة`],Mi=[`تخطي`],Ni=[`الروابط`],Pi=[`القائمة`],Fi=[`تذييل`],Ii=[`مجموعة`],Li=[`فئة`],Ri=[`افتراضي`],zi=[`بحث`],Bi=[`التقويم`],Vi=[`رجوع`],Hi=[`كتاب إلكتروني`],Ui=[`تحته خط`],Wi=[`صورة مصغرة`],Gi=[`تعليقات توضيحية`],Ki=[`وسائط`],qi=[`وسائط`],Ji=[`الأنماط`],Yi=[`عام`],Xi=[`الخيارات`],Zi=[`دقائق`],Qi=[`ساعات`],$i=[`الوقت`],ea=[`السنة`],ta=[`اليوم`],na=[`ديسمبر`],ra=[`نوفمبر`],ia=[`أكتوبر`],aa=[`سبتمبر`],oa=[`أغسطس`],sa=[`يوليو`],ca=[`يونيو`],la=[`مايو`],ua=[`أبريل`],da=[`مارس`],fa=[`فبراير`],pa=[`يناير`],ma=[`الشهر`],ha=[`الوقت`],ga=[`غلاف`],_a=[`ضخم`],va=[`متوسط`],ya=[`عادي`],ba=[`العناصر`],xa=[`الصورة الرمزية Avatar`],Sa=[`عرض`],Ca=[`HTML`],wa=[`غِشاء`],Ta=[`فاصلة علوية مائلة Backtick`],Ea=[`فترة`],Da=[`فاصلة`],Oa=[`الحالي`],ka=[`العنوان`],Aa=[`إنشاء`],ja=[`معارض`],Ma=[`XL`],Na=[`L`],Pa=[`M`],Fa=[`S`],Ia=[`صغير`],La=[`تم التجاهل`],Ra=[`تلقائي`],za=[`تحميل مسبق`],Ba=[`الدعم`],Va=[`الأرشيف`],Ha=[`كبير`],Ua=[`ملف`],Wa=[`عمود`],Ga=[`حلقة`],Ka=[`تشغيل تلقائي`],qa=[`حفظ تلقائي`],Ja=[`عنوان فرعي`],Ya=[`موافق`],Xa=[`إزالة الربط`],Za=[`تعدد الصفحات`],Qa=[`الارتفاع`],$a=[`العرض`],eo=[`متقدم`],to=[`مجدول`],no=[`الإضافات`],ro=[`فقرات`],io=[`عناوين`],ao=[`كلمات`],oo=[`عام`],so=[`خاص`],co=[`عنصر`],lo=[`وسم`],uo=[`فوراً`],fo=[`جاري الحفظ`],po=[`منشور`],mo=[`جدولة`],ho=[`تحديث`],go=[`نسخ`],_o=[`محادثة`],vo=[`الحالة`],yo=[`قياسي`],bo=[`الجانب`],xo=[`ترتيب`],So=[`تم الحفظ`],Co=[`التضمينات`],wo=[`مكوّنات`],To=[`تراجع`],Eo=[`إعادة`],Do=[`تكرار`],Oo=[`إزالة`],ko=[`الظهور`],Ao=[`المكوّن`],jo=[`أدوات`],Mo=[`المُحرر`],No=[`الإعدادات`],Po=[`إعادة تعيين`],Fo=[`إيقاف`],Io=[],Lo=[`مساءً`],Ro=[`صباحًا`],zo=[`رابط الـ`],Bo=[`إرسال`],Vo=[`إغلاق`],Ho=[`رابط`],Uo=[`نصّ مشطوب`],Wo=[`مائل`],Go=[`عريض`],Ko=[`تصنيف`],qo=[`تحديد`],Jo=[`فيديو`],Yo=[`جدول`],Xo=[`كود قصير`],Zo=[`فاصل`],Qo=[`اقتباس`],$o=[`فقرة`],es=[`قائمة`],ts=[`صورة`],ns=[`الحجم`],rs=[`صورة`],is=[`معاينة`],as=[`عنوان`],os=[`صور`],ss=[`بدون`],cs=[`معرض`],ls=[`المزيد`],us=[`تقليدي`],ds=[`فيديو`],fs=[`صوتيات`],ps=[`موسيقى`],ms=[`صورة`],hs=[`مدونة`],gs=[`المقالة`],_s=[`أعمدة`],vs=[`التجارب`],ys=[`كود`],bs=[`تصنيفات`],xs=[`زر`],Ss=[`تطبيق`],Cs=[`إلغاء`],ws=[`تحرير`],Ts=[`صوت`],Es=[`رفع`],Ds=[`مسح`],Os=[`ودجات`],ks=[`الكُتّاب`],As=[`الاسم اللطيف`],js=[`التعليق`],Ms=[`مناقشة`],Ns=[`المقتطف`],Ps=[`نشر`],Fs=[`البيانات الوصفية`],Is=[`حفظ`],Ls=[`المراجعات`],Rs=[`وثائق المساعدة`],zs=[`Gutenberg`],Bs=[`عرض توضيحي`],Vs={100:[`100`],"block descriptionDisplay the tab buttons for a tabbed interface.":[],"block titleTabs Menu":[],"block descriptionA single tab button in the tabs menu. Used as a template for styling all tab buttons.":[],"block titleTab Menu Item":[],"block descriptionContainer for tab panel content in a tabbed interface.":[],"block titleTab Panels":[],"Uploading %s file":[],"Uploaded %s file":[],"Open classic revisions screen":[],"Created %s.":[],"Failed to load media file.":[],"No media file available.":[],"View file":[],Exit:e,Revision:t,"Only one revision found.":[],"No revisions found.":[],"Revision restored.":[],"Media updated.":[],"Tab menu item":[],"Hover Text":[],"Hover Background":[],"Active Text":[],"Active Background":[],"Move tab down":[],"Move tab up":[],"Move tab left":[],"Move tab right":[],"Add tabs to display menu":[],"Remove Tab":[],"Remove the current tab":[],"Add a new tab":[],Click:n,"Submenu Visibility":[],"Navigation Overlay template part preview":[],"This overlay is empty.":[],"This overlay template part no longer exists.":[],"%s (missing)":[],"The selected overlay template part is missing or has been deleted. Reset to default overlay or create a new overlay.":[],"Add your own CSS to customize the appearance of the %s block. You do not need to include a CSS selector, just add the property and value, e.g. color: red;.":[],"%d field needs attention":[],"The custom CSS is invalid. Do not use <> markup.":[],"Parent block is hidden on %s":[],"Block is hidden on %s":[],"%1$d of %2$d Item":[],"Enables editing media items (attachments) directly in the block editor with a dedicated media preview and metadata panel.":[],"Media Editor":[],"Block pattern descriptionA navigation overlay with vertically and horizontally centered navigation":[],"Overlay with centered navigation":[],"Get started today!":[],"Find out how we can help your business.":[],"Block pattern descriptionA navigation overlay with vertically and horizontally centered navigation, site info, and a CTA":[],"Overlay with site info and CTA":[],"Block pattern descriptionA navigation overlay with orange background site title and tagline":[],"Overlay with orange background":[],"Block pattern descriptionA navigation overlay with black background and big white text":[],"Overlay with black background":[],'The CSS must not contain "%s".':[],'The CSS must not end in "%s".':[],"block keywordoverlay":[],"block keywordclose":[],"block descriptionA customizable button to close overlays.":[],"block titleNavigation Overlay Close":[],"block descriptionDisplay a breadcrumb trail showing the path to the current page.":[],"Date modified":[],"Date added":[],"Attached to":[],"Search for a post or page to attach this media to.":[],"Search for a post or page to attach this media to or .":[],"(Unattached)":[],"Choose file":[],"Choose files":[],"There is %d event":[],"Exclude: %s":[],Both:r,"Display Mode":[],"Submenu background":[],"Submenu text":[],Deleted:i,"No link selected":[],"External link":[],"Create new overlay template":[],"Select an overlay for navigation.":[],"An error occurred while creating the overlay.":[],'One response to "%s"':[],"Use the classic editor to add content.":[],"Search for and add a link to the navigation item.":[],"Select a link":[],"No items yet.":[],"The text may be too small to read. Consider using a larger container or less text.":[],"Parent block is hidden":[],"Block is hidden":[],Dimension:a,"Set custom value":[],"Use preset":[],Variation:o,"Go to parent block":[],'Go to "%s" block':[],"Block will be hidden according to the selected viewports. It will be included in the published markup on the frontend. You can configure it again by selecting it in the List View (%s).":[],"Block will be hidden in the editor, and omitted from the published markup on the frontend. You can configure it again by selecting it in the List View (%s).":[],"Selected blocks have different visibility settings. The checkboxes show an indeterminate state when settings differ.":[],"Hide on %s":[],"Omit from published content":[],"Select the viewport size for which you want to hide the block.":[],"Select the viewport sizes for which you want to hide the blocks. Changes will apply to all selected blocks.":[],"Hide block":[],"Hide blocks":[],"Block visibility settings updated. You can access them via the List View (%s).":[],"Redirects the default site editor (Appearance > Design) to use the extensible site editor page.":[],"Extensible Site Editor":[],"Enables editable block inspector fields that are generated using a dataform.":[],"Block fields: Show dataform driven inspector fields on blocks that support them":[],"Block pattern descriptionA simple pattern with a navigation block and a navigation overlay close button.":[],"Block pattern categoryDisplay your website navigation.":[],"Block pattern categoryNavigation":[],"Navigation Overlay":[],"Post Type: “%s”":[],"Search results for: “%s”":[],"Responses to “%s”":[],"Response to “%s”":[],"%1$s response to “%2$s”":[],"One response to “%s”":[],"File type":[],Application:s,"image dimensions%1$s × %2$s":[],"File size":[],"unit symbolKB":[],"unit symbolMB":[],"unit symbolGB":[],"unit symbolTB":[],"unit symbolPB":[],"unit symbolEB":[],"unit symbolZB":[],"unit symbolYB":[],"unit symbolB":[],"file size%1$s %2$s":[],"File name":[],"Updating failed because you were offline. Please verify your connection and try again.":[],"Scheduling failed because you were offline. Please verify your connection and try again.":[],"Publishing failed because you were offline. Please verify your connection and try again.":[],"Font Collections":[],"Configure overlay visibility":[],"Overlay Visibility":[],"Edit overlay":[],"Edit overlay: %s":[],"No overlays found.":[],"Overlay template":[],"None (default)":[],"Error: %s":[],"Error parsing mathematical expression: %s":[],"This block contains CSS or JavaScript that will be removed when you save because you do not have permission to use unfiltered HTML.":[],"Show current breadcrumb":[],"Show home breadcrumb":[],"Value is too long.":[],"Value is too short.":[],"Value is above the maximum.":[],"Value is below the minimum.":[],"Max. columns":[],"Columns will wrap to fewer per row when they can no longer maintain the minimum width.":[],"Min. column width":[],"Includes all":[],"Is none of":[],Includes:c,"Close navigation panel":[],"Open navigation panel":[],"Custom overlay area for navigation overlays.":[],'[%1$s] Note: "%2$s"':[],"You can see all notes on this post here:":[],"resolved/reopened":[],"Email: %s":[],"Author: %1$s (IP address: %2$s, %3$s)":[],'New note on your post "%s"':[],"Email me whenever anyone posts a note":[],"Comments Page %s":[],"block descriptionThis block is deprecated. Please use the Quote block instead.":[],"block titlePullquote (deprecated)":[],"Add new reply":[],Placeholder:l,Citation:u,"It appears you are trying to use the deprecated Classic block. You can leave this block intact, or remove it entirely. Alternatively, if you have unsaved changes, you can save them and refresh to use the Classic block.":[],"Button Text":[],Filename:d,"Embed video from URL":[],"Add a background video to the cover block that will autoplay in a loop.":[],"Enter YouTube, Vimeo, or other video URL":[],"Video URL":[],"Add video":[],"This URL is not supported. Please enter a valid video link from a supported provider.":[],"Please enter a URL.":[],"Choose a media item…":[],"Choose a file…":[],"Choose a video…":[],"Show / Hide":[],"Value does not match the required pattern.":[],"Justified text can reduce readability. For better accessibility, use left-aligned text instead.":[],"Edit section":[],"Exit section":[],"Editing a section in the EditorEdit section":[],"A block pattern.":[],"Reusable design elements for your site. Create once, use everywhere.":[],Registered:f,"Enter menu name":[],"Unable to create navigation menu: %s":[],"Navigation menu created successfully.":[],Activity:p,"%s: ":[],"Row %d":[],"Insert right":[],"Insert left":[],"Executing ability…":[],"Workflow suggestions":[],"Workflow palette":[],"Open the workflow palette.":[],"Run abilities and workflows":[],"Empty.":[],"Enables custom mobile overlay design and content control for Navigation blocks, allowing you to create flexible, professional menu experiences.":[],"Customizable Navigation Overlays":[],"Enables the Workflow Palette for running workflows composed of abilities, from a unified interface.":[],"Workflow Palette":[],"Script modules to load into the import map.":[],"block descriptionDisplay content in a tabbed interface to help users navigate detailed content with ease.":[],"block titleTabs":[],"block descriptionContent for a tab in a tabbed interface.":[],"block titleTab":[],"Disconnect pattern":[],"Upload media":[],"Pick from starter content when creating a new page.":[],"All notes":[],"Unresolved notes":[],"Convert to blocks to add notes.":[],"Notes are disabled in distraction free mode.":[],"Always show starter patterns for new pages":[],"templateInactive":[],"templateActive":[],"templateActive when used":[],"More details":[],"Validating…":[],"Unknown error when running custom validation asynchronously.":[],"Validation could not be processed.":[],Valid:m,"Unknown error when running elements validation asynchronously.":[],"Could not validate elements.":[],"Tab Contents":[],"The tabs title is used by screen readers to describe the purpose and content of the tabs.":[],"Tabs Title":[],"Type / to add a block to tab":[],"Tab %d…":[],"Tab %d":[],"If toggled, this tab will be selected when the page loads.":[],"Default Tab":[],"Tab Label":[],"Add Tab":[],"Synced %s is missing. Please update or remove this link.":[],"Edit code":[],"Add custom HTML code and preview how it looks.":[],"Update and close":[],"Continue editing":[],"You have unsaved changes. What would you like to do?":[],"Unsaved changes":[],"Write JavaScript…":[],"Write CSS…":[],"Enable/disable fullscreen":[],JavaScript:h,"Edit HTML":[],"Introduce new sections and organize content to help visitors (and search engines) understand the structure of your content.":[],"Embed an X post.":[],"If this breadcrumbs block appears in a template or template part that’s shown on the homepage, enable this option to display the breadcrumb trail. Otherwise, this setting has no effect.":[],"Show on homepage":[],"Finish editing a design.":[],"The page you're looking for does not exist":[],"Route not found":[],"Warning: when you deactivate this experiment, it is best to delete all created templates except for the active ones.":[],"Allows multiple templates of the same type to be created, of which one can be active at a time.":[],"Template Activation":[],"Inline styles for editor assets.":[],"Inline scripts for editor assets.":[],"Editor styles data.":[],"Editor scripts data.":[],"Limit result set to attachments of a particular MIME type or MIME types.":[],"Limit result set to attachments of a particular media type or media types.":[],"Page %s":[],"Page not found":[],"block descriptionDisplay a custom date.":[],"block descriptionDisplays a foldable layout that groups content in collapsible sections.":[],"block descriptionContains the hidden or revealed content beneath the heading.":[],"block descriptionWraps the heading and panel in one unit.":[],"block descriptionDisplays a heading that toggles the accordion panel.":[],"Media items":[],"Search media":[],"Select Media":[],"Are you sure you want to delete this note? This will also delete all of this note's replies.":[],"Revisions (%d)":[],"paging%1$d of %2$d":[],"%d item":[],"Color Variations":[],"Shadow Type":[],"Font family to uninstall is not defined.":[],"Registered Templates":[],"Failed to create page. Please try again.":[],"%s page created successfully.":[],"Full content":[],"No content":[],"Display content":[],"The exact type of breadcrumbs shown will vary automatically depending on the page in which this block is displayed. In the specific case of a hierarchical post type with taxonomies, the breadcrumbs can either reflect its post hierarchy (default) or the hierarchy of its assigned taxonomy terms.":[],"Prefer taxonomy terms":[],"The text will resize to fit its container, resetting other font size settings.":[],"Enables a new media modal experience powered by Data Views for improved media library management.":[],"Data Views: new media modal":[],"block keywordterm title":[],"block descriptionDisplays the name of a taxonomy term.":[],"block titleTerm Name":[],"block descriptionDisplays the post count of a taxonomy term.":[],"block titleTerm Count":[],"block keywordmathematics":[],"block keywordlatex":[],"block keywordformula":[],"block descriptionDisplay mathematical notation using LaTeX.":[],"block titleMath":[],"block titleBreadcrumbs":[],"Overrides currently don't support image links. Remove the link first before enabling overrides.":[],Math:g,"CSS classes":[],"Close Notes":[],Notes:_,"View notes":[],"New note":[],"Add note":[],Reopened:v,"Marked as resolved":[],"Edit note %1$s by %2$s":[],"Reopen noteReopen":[],"Back to block":[],"Note: %s":[],"Note deleted.":[],"Note reopened.":[],"Note added.":[],"Reply added.":[],Note:y,"You are about to duplicate a bundled template. Changes will not be live until you activate the new template.":[],'Do you want to activate this "%s" template?':[],"template typeCustom":[],"Created templates":[],"Reset view":[],"Unknown error when running custom validation.":[],"No elements found":[],"Term template block display settingGrid view":[],"Term template block display settingList view":[],"Display the terms' names and number of posts assigned to each term.":[],"Name & Count":[],"Display the terms' names.":[],"When specific terms are selected, only those are displayed.":[],"When specific terms are selected, the order is based on their selection order.":[],"Selected terms":[],"Show nested terms":[],"Display terms based on specific criteria.":[],"Display terms based on the current taxonomy archive. For hierarchical taxonomies, shows children of the current term. For non-hierarchical taxonomies, shows all terms.":[],"Make term name a link":[],"Change bracket type":[],"Angle brackets":[],"Curly brackets":[],"Square brackets":[],"Round brackets":[],"No brackets":[],"e.g., x^2, \\frac{a}{b}":[],"LaTeX math syntax":[],"Set a consistent aspect ratio for all images in the gallery.":[],"All gallery images updated to aspect ratio: %s":[],"Comments block: You’re currently using the legacy version of the block. The following is just a placeholder - the final styling will likely look different. For a better representation and more customization options, switch the block to its editable mode.":[],Ancestor:b,"Source not registered":[],"Not connected":[],"No sources available":[],"Text will resize to fit its container.":[],"Fit text":[],"Allowed Blocks":[],"Specify which blocks are allowed inside this container.":[],"Select which blocks can be added inside this container.":[],"Manage allowed blocks":[],"Block hidden. You can access it via the List View (%s).":[],"Blocks hidden. You can access them via the List View (%s).":[],"Show or hide the selected block(s).":[],"Type of the comment.":[],"Creating comment failed.":[],"Comment field exceeds maximum length allowed.":[],"Creating a comment requires valid author name and email values.":[],"Invalid comment content.":[],"Cannot create a comment with that type.":[],"Sorry, you are not allowed to read this comment.":[],"Query parameter not permitted: %s":[],"Sorry, you are not allowed to read comments without a post.":[],"Sorry, this post type does not support notes.":[],"Note resolution status":[],Breadcrumbs:x,"block descriptionShow minutes required to finish reading the post. Can also show a word count.":[],"Reply to note %1$s by %2$s":[],"Reopen & Reply":[],"Original block deleted.":[],"Original block deleted. Note: %s":[],"Note date full date formatF j, Y g:i\xA0a":[],"Don't allow link notifications from other blogs (pingbacks and trackbacks) on new articles.":[],"Don't allow":[],"Allow link notifications from other blogs (pingbacks and trackbacks) on new articles.":[],Allow:S,"Trackbacks & Pingbacks":[],"Template activation failed.":[],"Template activated.":[],"Activating template…":[],"Template Type":[],"Compatible Theme":[],Active:C,"Active templates":[],Deactivate:w,"Value must be a number.":[],"You can add custom CSS to further customize the appearance and layout of your site.":[],"Show the number of words in the post.":[],"Word Count":[],"Show minutes required to finish reading the post.":[],"Time to Read":[],"Display as range":[],"Turns reading time range display on or offDisplay as range":[],item:T,term:E,tag:D,category:O,"Suspendisse commodo lacus, interdum et.":[],"Lorem ipsum dolor sit amet, consectetur.":[],Visible:k,"Unsync and edit":[],"Synced with the selected %s.":[],"%s character":[],"Range of minutes to read%1$s–%2$s minutes":[],"block keywordtags":[],"block keywordtaxonomy":[],"block keywordterms":[],"block titleTerms Query":[],"block descriptionContains the block elements used to render a taxonomy term, like the name, description, and more.":[],"block titleTerm Template":[],Count:A,"Parent ID":[],"Term ID":[],"An error occurred while performing an update.":[],"+%s":[],"100+":[],"%s more reply":[],"Show password":[],"Hide password":[],"Date time":[],"Value must be a valid color.":[],"Open custom CSS":[],"Go to: Patterns":[],"Go to: Templates":[],"Go to: Navigation":[],"Go to: Styles":[],"Go to: Template parts":[],"Go to: %s":[],"No terms found.":[],"Term Name":[],"Limit the number of terms you want to show. To show all terms, use 0 (zero).":[],"Max terms":[],"Count, low to high":[],"Count, high to low":[],"Name: Z → A":[],"Name: A → Z":[],"If unchecked, the page will be created as a draft.":[],"Publish immediately":[],"Create a new page to add to your Navigation.":[],"Create page":[],"Edit contents":[],"The Link Relation attribute defines the relationship between a linked resource and the current document.":[],"Link relation":[],"Blog home":[],Attachment:j,Post:M,"block bindings sourceTerm Data":[],"Choose pattern":[],"Could not get a valid response from the server.":[],"Unable to connect. Please check your Internet connection.":[],"block titleAccordion":[],"block titleAccordion Panel":[],"block titleAccordion Heading":[],"block titleAccordion Item":[],"Automatically load more content as you scroll, instead of showing pagination links.":[],"Enable infinite scroll":[],"Play inline enabled because of Autoplay.":[],"Display the post type label based on the queried object.":[],"Post Type Label":[],"Show post type label":[],"Post Type: Name":[],"Accordion title":[],"Accordion content will be displayed by default.":[],"Icon Position":[],"Display a plus icon next to the accordion header.":[],"Automatically close accordions when a new one is opened.":[],"Auto-close":[],'Post Type: "%s"':[],"Add Category":[],"Add Term":[],"Add Tag":[],To:N,From:P,"Year to date":[],"Last year":[],"Month to date":[],"Last 30 days":[],"Last 7 days":[],"Past month":[],"Past week":[],Yesterday:F,Today:I,"Every value must be a string.":[],"Value must be an array.":[],"Value must be true, false, or undefined":[],"Value must be an integer.":[],"Value must be one of the elements.":[],"Value must be a valid email address.":[],"Add page":[],Optional:L,"social link block variation nameSoundCloud":[],"Display a post's publish date.":[],"Publish Date":[],'"Read more" text':[],"Poster image preview":[],"Edit or replace the poster image.":[],"Set poster image":[],"social link block variation nameYouTube":[],"social link block variation nameYelp":[],"social link block variation nameX":[],"social link block variation nameWhatsApp":[],"social link block variation nameWordPress":[],"social link block variation nameVK":[],"social link block variation nameVimeo":[],"social link block variation nameTwitter":[],"social link block variation nameTwitch":[],"social link block variation nameTumblr":[],"social link block variation nameTikTok":[],"social link block variation nameThreads":[],"social link block variation nameTelegram":[],"social link block variation nameSpotify":[],"social link block variation nameSnapchat":[],"social link block variation nameSkype":[],"social link block variation nameShare Icon":[],"social link block variation nameReddit":[],"social link block variation namePocket":[],"social link block variation namePinterest":[],"social link block variation namePatreon":[],"social link block variation nameMedium":[],"social link block variation nameMeetup":[],"social link block variation nameMastodon":[],"social link block variation nameMail":[],"social link block variation nameLinkedIn":[],"social link block variation nameLast.fm":[],"social link block variation nameInstagram":[],"social link block variation nameGravatar":[],"social link block variation nameGitHub":[],"social link block variation nameGoogle":[],"social link block variation nameGoodreads":[],"social link block variation nameFoursquare":[],"social link block variation nameFlickr":[],"social link block variation nameRSS Feed":[],"social link block variation nameFacebook":[],"social link block variation nameEtsy":[],"social link block variation nameDropbox":[],"social link block variation nameDribbble":[],"social link block variation nameDiscord":[],"social link block variation nameDeviantArt":[],"social link block variation nameCodePen":[],"social link block variation nameLink":[],"social link block variation nameBluesky":[],"social link block variation nameBehance":[],"social link block variation nameBandcamp":[],"social link block variation nameAmazon":[],"social link block variation name500px":[],"block descriptionDescribe in a few words what this site is about. This is important for search results, sharing on social media, and gives overall clarity to visitors.":[],"There is no poster image currently selected.":[],"The current poster image url is %s.":[],"Comments pagination":[],"paging
Page
%1$s
of %2$d
":[],"%1$s is in the past: %2$s":[],"%1$s between (inc): %2$s and %3$s":[],"%1$s is on or after: %2$s":[],"%1$s is on or before: %2$s":[],"%1$s is after: %2$s":[],"%1$s is before: %2$s":[],"%1$s starts with: %2$s":[],"%1$s doesn't contain: %2$s":[],"%1$s contains: %2$s":[],"%1$s is greater than or equal to: %2$s":[],"%1$s is less than or equal to: %2$s":[],"%1$s is greater than: %2$s":[],"%1$s is less than: %2$s":[],"Max.":[],"Min.":[],"The max. value must be greater than the min. value.":[],Unit:R,"Years ago":[],"Months ago":[],"Weeks ago":[],"Days ago":[],Years:z,Months:B,Weeks:V,Days:H,False:U,True:W,Over:G,"In the past":[],"Not on":[],"Between (inc)":[],"Starts with":[],"Doesn't contain":[],"After (inc)":[],"Before (inc)":[],After:K,Before:q,"Greater than or equal":[],"Less than or equal":[],"Greater than":[],"Less than":[],"%s, selected":[],"Go to the Previous Month":[],"Go to the Next Month":[],"Today, %s":[],"Date range calendar":[],"Date calendar":[],"Interactivity API: Full-page client-side navigation":[],"Set as default track":[],"Icon size":[],"Only select
if the separator conveys important information and should be announced by screen readers.":[],"Sort and filter":[],"Write summary. Press Enter to expand or collapse the details.":[],"Default ()":[],"The ":[],"Custom HTML Preview":[],"Multiple blocks selected":[],'Block name changed to: "%s".':[],'Block name reset to: "%s".':[],"https://wordpress.org/patterns/":[],"Patterns are available from the WordPress.org Pattern Directory, bundled in the active theme, or created by users on this site. Only patterns created on this site can be synced.":[],Source:He,"Theme & Plugins":[],"Pattern Directory":[],"Jump to footnote reference %1$d":[],"Mark as nofollow":[],"Empty template part":[],"Choose a template":[],"Manage fonts":[`إدارة الخطوط`],Fonts:Ue,"Install Fonts":[],Install:We,"No fonts found. Try with a different search term.":[],"Font name…":[],"Select font variants to install.":[],"Allow access to Google Fonts":[],"You can alternatively upload files directly on the Upload tab.":[],"To install fonts from Google you must give permission to connect directly to Google servers. The fonts you install will be downloaded from Google and stored on your site. Your site will then use these locally-hosted fonts.":[],"Choose font variants. Keep in mind that too many variants could make your site slower.":[],"Upload font":[],"%1$d/%2$d variants active":[],"font styleNormal":[],"font weightExtra-bold":[],"font weightSemi-bold":[],"font weightNormal":[],"font weightExtra-light":[],"Add your own CSS to customize the appearance of the %s block. You do not need to include a CSS selector, just add the property and value.":[],'Imported "%s" from JSON.':[],"Import pattern from JSON":[],"A list of all patterns from all sources.":[],"An error occurred while reverting the template part.":[],Notice:Ge,"Error notice":[],"Information notice":[],"Warning notice":[],"Footnotes are not supported here. Add this block to post or page content.":[],"Comments form disabled in editor.":[],"Block: Paragraph":[],"Image settingsSettings":[],"Drop to upload":[],"Background image":[],"Only images can be used as a background image.":[],"No results found":[],"%d category button displayed.":[],"All patterns":[],"Display a list of assigned terms from the taxonomy: %s":[],"Specify how many links can appear before and after the current page number. Links to the first, current and last page are always visible.":[],"Number of links":[],Ungroup:Ke,"Page Loaded.":[],"Loading page, please wait.":[],"block titleDate":[`التاريخ`],"block titleContent":[`المحتوى`],"block titleAuthor":[`الكاتب`],"block keywordtoggle":[],"Default styles":[],"Reset the styles to the theme defaults":[],"Changes will apply to new posts only. Individual posts may override these settings.":[],"Breadcrumbs visible.":[],"Breadcrumbs hidden.":[],"Editor preferences":[],"The
element should be used for the primary content of your document only.":[],"Modified Date":[],"Overlay menu controls":[],'Navigation Menu: "%s"':[],"Enter fullscreen":[],"Exit fullscreen":[`الخروج من الشاشة الكاملة`],"Select text across multiple blocks.":[],"Font family uninstalled successfully.":[],"Changes saved by %1$s on %2$s":[],"Unsaved changes by %s":[],"Preview in a new tab":[],"Disable pre-publish checks":[],"Show block breadcrumbs":[],"Hide block breadcrumbs":[],"Post overviewOutline":[],"Post overviewList View":[],"You can enable the visual editor in your profile settings.":[],"Submit Search":[],"block keywordreusable":[],"Pattern imported successfully!":[],"Invalid pattern JSON file":[],"Last page":[`آخر صفحة`],"paging%1$s of %2$s":[],"Previous page":[`الصفحة السابقة`],"First page":[`الصفحة الأولى`],"%s item":[`لا توجد عناصر (%s)`,`عنصر واحد (%s)`,`عنصران (%s)`,`%s عناصر`,`%s عنصر`,`%s عنصر`],"Use left and right arrow keys to resize the canvas. Hold shift to resize in larger increments.":[],"An error occurred while moving the item to the trash.":[],'"%s" moved to the trash.':[],"Go to the Dashboard":[],"%s name":[],"%s: Name":[],'The current menu options offer reduced accessibility for users and are not recommended. Enabling either "Open on Click" or "Show arrow" offers enhanced accessibility by allowing keyboard users to browse submenus selectively.':[],"Footnotes found in blocks within this document will be displayed here.":[],Footnotes:qe,"Open command palette":[],"Note that the same template can be used by multiple pages, so any changes made here may affect other pages on the site. To switch back to editing the page content click the ‘Back’ button in the toolbar.":[],"Editing a template":[],"It’s now possible to edit page content in the site editor. To customise other parts of the page like the header and footer switch to editing the template using the settings sidebar.":[],Continue:Je,"Editing a page":[],"This pattern cannot be edited.":[],"Are you sure you want to delete this reply?":[],"Command palette":[],"Open the command palette.":[],Detach:Ye,"Edit Page List":[],"It appears you are trying to use the deprecated Classic block. You can leave this block intact, convert its content to a Custom HTML block, or remove it entirely. Alternatively, if you have unsaved changes, you can save them and refresh to use the Classic block.":[],"Name for applying graphical effectsFilters":[`عوامل التصفية`],"Hide block tools":[],"My patterns":[`أنماطي`],"Disables the TinyMCE and Classic block.":[],"`experimental-link-color` is no longer supported. Use `link-color` instead.":[],"Sync status":[],"block titlePattern Placeholder":[],"block keywordreferences":[`المراجع`],"block titleFootnotes":[],"Unsynced pattern created: %s":[],"Synced pattern created: %s":[],"Untitled pattern block":[],"External media":[],"Select image block.":[],"Patterns that can be changed freely without affecting the site.":[],"Patterns that are kept in sync across the site.":[],"Empty pattern":[],"An error occurred while deleting the items.":[],"Learn about styles":[],"Open style revisions":[],"Change publish date":[],Password:Xe,"An error occurred while duplicating the page.":[],"Publish automatically on a chosen date.":[],"Waiting for review before publishing.":[],"Not ready to publish.":[`غير جاهز للنشر.`],"Unable to duplicate Navigation Menu (%s).":[],"Duplicated Navigation Menu":[],"Unable to rename Navigation Menu (%s).":[],"Renamed Navigation Menu":[],"Unable to delete Navigation Menu (%s).":[],"Are you sure you want to delete this Navigation Menu?":[],"Navigation title":[],"Go to %s":[`الانتقال إلى %s`],"Set the default number of posts to display on blog pages, including categories and tags. Some templates may override this setting.":[],"Set the Posts Page title. Appears in search results, and when the page is shared on social media.":[],"Blog title":[`عنوان المدوّنة`],"Select what the new template should apply to:":[],"E.g. %s":[],"Manage what patterns are available when editing the site.":[],"My pattern":[`نمطي`],"Create pattern":[`إنشاء نمط`],"An error occurred while renaming the pattern.":[],"Hide & Reload Page":[],"Show & Reload Page":[],"Manage patterns":[],"Initial %d result loaded. Type to filter all available results. Use up and down arrow keys to navigate.":[],Footnote:Ze,"Lowercase Roman numerals":[],"Uppercase Roman numerals":[],"Lowercase letters":[],"Uppercase letters":[],Numbers:Qe,"Image is contained without distortion.":[],"Image covers the space evenly.":[],"Image size option for resolution controlFull Size":[`الحجم الكامل`],"Image size option for resolution controlLarge":[`كبير`],"Image size option for resolution controlMedium":[`متوسط`],"Image size option for resolution controlThumbnail":[],Scale:$e,"Scale down the content to fit the space if it is too big. Content that is too small will have additional padding.":[],"Scale option for dimensions controlScale down":[],"Do not adjust the sizing of the content. Content that is too large will be clipped, and content that is too small will have additional padding.":[],"Scale option for dimensions controlNone":[`لا شيء`],"Fill the space by clipping what doesn't fit.":[],"Scale option for dimensions controlCover":[`غلاف`],"Fit the content to the space without clipping.":[],"Scale option for dimensions controlContain":[],"Fill the space by stretching the content.":[],"Scale option for dimensions controlFill":[],"Aspect ratio option for dimensions controlCustom":[],"Aspect ratio option for dimensions controlOriginal":[],"Additional link settingsAdvanced":[`إعدادات متقدمة`],"Change level":[`تغيير المستوى`],"Position: %s":[],"The block will stick to the scrollable area of the parent %s block.":[],'"%s" in theme.json settings.color.duotone is not a hex or rgb string.':[],"An error occurred while creating the item.":[],"block titleTitle":[`العنوان`],"block titleExcerpt":[`المقتطف`],"View site (opens in a new tab)":[],"Last edited %s.":[],Page:et,Unknown:tt,Parent:nt,Pending:rt,"Create draft":[],"No title":[`بدون عنوان`],"Review %d change…":[`مراجعة %d تغيير`,`مراجعة %d تغيير`,`مراجعة %d تغييرين`,`مراجعة %d تغييرات`,`مراجعة %d تغييراً`,`مراجعة %d تغير`],"Focal point top position":[],"Focal point left position":[],"Show label text":[],"No excerpt found":[],"Excerpt text":[],"The content is currently protected and does not have the available excerpt.":[],"This block will display the excerpt.":[],Suggestions:it,"Horizontal & vertical":[],"Expand search field":[],"Right to left":[`من اليمين إلى اليسار`],"Left to right":[`من اليسار إلى اليمين`],"Text direction":[],'A valid language attribute, like "en" or "fr".':[],Language:at,"Reset template part: %s":[],"Document not found":[`لم يتم العثور على المستند`],"Navigation Menu missing.":[],"Navigation Menus are a curated collection of blocks that allow visitors to get around your site.":[],"Manage your Navigation Menus.":[],"%d pattern found":[],Library:ot,"Examples of blocks":[],"The relationship of the linked URL as space-separated link types.":[],"Rel attribute":[],'The duotone id "%s" is not registered in theme.json settings':[],"block descriptionHide and show additional content.":[],"block descriptionAdd an image or video with a text overlay.":[],"Save panel":[],"Close Styles":[],"Discard unsaved changes":[],Activate:st,"Activate & Save":[`تفعيل وحفظ`],"Write summary…":[`اكتب ملخصًا…`],"Type / to add a hidden block":[],"Add an image or video with a text overlay.":[],"%d Block":[],"Add after":[],"Add before":[],"Site Preview":[`معاينة الموقع`],"block descriptionDisplay an image to represent this site. Update this block and the changes apply everywhere.":[],"Add media":[`أضف ملفات وسائط`],"Show block tools":[],"block keywordlist":[],"block keyworddisclosure":[],"block titleDetails":[`التفاصيل`],"https://wordpress.org/documentation/article/page-post-settings-sidebar/#permalink":[],"https://wordpress.org/documentation/article/page-post-settings-sidebar/#excerpt":[],"https://wordpress.org/documentation/article/embeds/":[],"Open by default":[],"https://wordpress.org/documentation/article/customize-date-and-time-format/":[],"https://wordpress.org/documentation/article/page-jumps/":[],"%s minute":[],"Manage the fonts and typography used on captions.":[],"Display a post's last updated date.":[],"Post Modified Date":[],"Arrange blocks in a grid.":[],"Leave empty if decorative.":[`اترك ذلك الحقل فارغًا إذا كان للزخرفة.`],"Alternative text":[`نص بديل`],Resolution:ct,"Name for the value of the CSS position propertyFixed":[`ثابت`],"Name for the value of the CSS position propertySticky":[`مثبّت`],"Minimum column width":[],"captionWork/ %2$s":[],"Examples of blocks in the %s category":[],"Create new templates, or reset any customizations made to the templates supplied by your theme.":[`قم بإنشاء قوالب جديدة أو إعادة تعيين أي تخصيصات تم إجراؤها على القوالب التي يوفرها قالبك.`],"A custom template can be manually applied to any post or page.":[],"Customize the appearance of your website using the block editor.":[],"https://wordpress.org/documentation/article/wordpress-block-editor/":[],"Post meta":[],"Select the size of the source images.":[],"Reply to A WordPress Commenter":[],"Commenter Avatar":[],"block titleTime to Read":[`مدة القراءة`],"Example:":[],"Image inserted.":[`تم إدراج الصورة.`],"Image uploaded and inserted.":[`تم رفع وإدراج الصورة.`],Insert:lt,"External images can be removed by the external provider without warning and could even have legal compliance issues related to privacy legislation.":[],"This image cannot be uploaded to your Media Library, but it can still be inserted as an external image.":[],"Insert external image":[`إدراج صورة خارجية`],"Fallback content":[],"Scrollable section":[],"Aspect ratio":[`نسبة البعدين`],"Max number of words":[`الحد الأقصى لعدد الكلمات`],"Choose or create a Navigation Menu":[`اختيار قائمة التنقل أو إنشاؤها`],"Add submenu link":[`إضافة رابط القائمة الفرعية`],"Search Openverse":[`البحث في Openverse`],Openverse:ut,"Search audio":[`بحث عن ملف صوتي`],"Search videos":[`بحث عن مقاطع الفيديو`],"Search images":[`بحث عن صور`],'caption"%1$s"/ %2$s':[`"%1$s"/ %2$s`],"captionWork by %2$s/ %3$s":[`هذا العمل بواسطة%2$s/ %3$s`],'caption"%1$s" by %2$s/ %3$s':[`"%1$s" بواسطة %2$s/ %3$s`],"Learn more about CSS":[`معرفة المزيد حول CSS`],"There is an error with your CSS structure.":[`هناك خطأ في بنية CSS الخاصة بك.`],Shadow:dt,"Border & Shadow":[`الحدود والظل`],Center:ft,'Page List: "%s" page has no children.':[`قائمة الصفحات: لا تحتوي صفحة «%s» على أطفال.`],"You have not yet created any menus. Displaying a list of your Pages":[`لم تقم بعد بإنشاء أي قوائم. عرض قائمة بصفحاتك`],"Untitled menu":[`قائمة بدون عنوان`],"Structure for Navigation Menu: %s":[],"(no title %s)":[`(لا يوجد عنوان %s)`],"Align text":[`محاذاة النص`],"Append to %1$s block at position %2$d, Level %3$d":[`إلحاق المكوّن %1$s في الموضع %2$d، المستوى %3$d`],"%s block inserted":[`تم إدراج المكوّن %s`],"Report %s":[`الإبلاغ عن الـ %s`],"Copy styles":[`نسخ الأنماط`],"Stretch items":[`تمديد العناصر`],"Block vertical alignment settingSpace between":[`المسافة البينية`],"Block vertical alignment settingStretch to fill":[`التمدد لملء الفراغات`],"Untitled post %d":[`منشور بدون عنوان %d`],"Printing since 1440. This is the development plugin for the block editor, site editor, and other future WordPress core functionality.":[`بدأت الخطوط منذ عام 1440. هذه هي الإضافة لمحرر المكونات ومحرر الموقع ووظائف ووردبريس الأساسية المستقبلية الأخرى الخاصة بالتطوير.`],"Style Variations":[`تنوعات الأنماط`],"Apply globally":[`التطبيق عالمياً`],"%s styles applied.":[`%s الأنماط المطبقة.`],"Currently selected position: %s":[`المنصب المحدد حالياً: %s`],Position:pt,"The block will not move when the page is scrolled.":[`لن يتم تحريك المكوّن عند تمرير الصفحة.`],"The block will stick to the top of the window instead of scrolling.":[`سيلتصق المكوّن بأعلى النافذة بدلا من التمرير.`],Sticky:mt,"Paste styles":[`لصق الأنماط`],"Pasted styles to %d blocks.":[`تم لصق الأنماط لـ %d من المكوّنات.`],"Pasted styles to %s.":[`تم لصق الأنماط لـ %s.`],"Unable to paste styles. Block styles couldn't be found within the copied content.":[`غير قادر على لصق الأنماط. تعذر العثور على أنماط المكوّن داخل المحتوى المنسوخ.`],"Unable to paste styles. Please allow browser clipboard permissions before continuing.":[`غير قادر على لصق الأنماط. يرجى السماح بأذونات حافظة المتصفح قبل المتابعة.`],"Unable to paste styles. This feature is only available on secure (https) sites in supporting browsers.":[`غير قادر على لصق الأنماط. لا تتوفر هذه الميزة إلا على المواقع الآمنة (https) في المتصفحات الداعمة.`],Tilde:ht,"Template part":[],"Apply this block’s typography, spacing, dimensions, and color styles to all %s blocks.":[`قم بتطبيق أسلوب الطباعة والتباعد والأبعاد والألوان لهذا المكوّن على جميع مكوّنات %s.`],"Import widget area":[`استيراد منطقة الودجة`],"Unable to import the following widgets: %s.":[`غير قادر على استيراد الودجات التالية: %s.`],"Widget area: %s":[`منطقة الودجة: %s`],"Select widget area":[`تحديد منطقة الودجة`],"Your %1$s file uses a dynamic value (%2$s) for the path at %3$s. However, the value at %3$s is also a dynamic value (pointing to %4$s) and pointing to another dynamic value is not supported. Please update %3$s to point directly to %4$s.":[`يستخدم ملف ⁦%1$s⁩ الخاص بك قيمة ديناميكية (⁦%2$s⁩) للمسار في ⁦%3$s⁩. ومع ذلك، فإن القيمة في ⁦%3$s⁩ هي أيضًا قيمة ديناميكية (تشير إلى ⁦%4$s⁩) وتشير إلى قيمة ديناميكية أخرى غير مدعومة. يرجى تحديث ⁦%3$s⁩ للإشارة مباشرة إلى ⁦%4$s⁩.`],"Clear Unknown Formatting":[`تنظيف التنسيقات غير المعروفة`],CSS:gt,"Open %s styles in Styles panel":[],"Style Book":[],"Additional CSS":[`تنسيقات (CSS) إضافية`],"Open code editor":[],"Specify a fixed height.":[],"Block inspector tab display overrides.":[],"block keywordpage":[`صفحة`],"block descriptionDisplays a page inside a list of all pages.":[],"block titlePage List Item":[],"Show details":[`إظهار التفاصيل`],"Choose a page to show only its subpages.":[`اختر صفحة لعرض صفحاتها الفرعية.`],"Parent Page":[],"Media List":[`قائمة الوسائط`],Videos:_t,Fixed:vt,"Fit contents.":[],"Specify a fixed width.":[],"Stretch to fill available space.":[],"Randomize colors":[],"Document Overview":[`نظرة عامة على المستند`],"Convert the current paragraph or heading to a heading of level 1 to 6.":[],"Convert the current heading to a paragraph.":[],"Transform paragraph to heading.":[`تحويل الفقرة إلى عنوان.`],"Transform heading to paragraph.":[],"Extra Extra Large":[`كبير جدًا جدًا`],"Group blocks together. Select a layout:":[],"Color randomizer":[],"Indicates whether the current theme supports block-based templates.":[],"untitled post %s":[`مقالة بدون عنوان %s`],": %s":[`: %s`],"Time to read:":[`مدة القراءة:`],"Words:":[`الكلمات:`],"Characters:":[`الأحرف:`],"Navigate the structure of your document and address issues like empty or incorrect heading levels.":[],Decrement:yt,Increment:bt,"Remove caption":[`إزالة التسمية`],"Close List View":[],"Choose a variation to change the look of the site.":[`اختر شكلًا لتغيير مظهر الموقع.`],"Write with calmness":[`اكتب بهدوء`],"Distraction free":[`بدون تشتيت الانتباه`],"Reduce visual distractions by hiding the toolbar and other elements to focus on writing.":[`قلل المشتتات المرئية عن طريق إخفاء شريط الأدوات والعناصر الأخرى للتركيز على الكتابة.`],Caption:xt,Pattern:St,"Raw size value must be a string, integer or a float.":[],"Link author name to author page":[`ربط اسم الكاتب إلى صفحة الكاتب`],"Not available for aligned text.":[],"There’s no content to show here yet.":[],"block titleComments Previous Page":[`تعليقات الصفحة السابقة`],"block titleComments Next Page":[`التعليقات الصفحة التالية`],"Arrow option for Next/Previous linkChevron":[`شارة رتبة`],"Arrow option for Next/Previous linkArrow":[`سهم`],"Arrow option for Next/Previous linkNone":[`بدون`],"A decorative arrow for the next and previous link.":[`سهم زخرفي للرابط التالي والسابق.`],"Format tools":[`أدوات الصيغة`],"Displays an archive with the latest posts of type: %s.":[],"Archive: %s":[`الأرشيف :%s`],"Archive: %1$s (%2$s)":[`أرشيف: %1$s (%2$s)`],handle:Ct,"Import Classic Menus":[`استيراد القوائم الكلاسيكية`],"You are currently in zoom-out mode.":[`أنت حالياً في وضع التصغير.`],"$store must be an instance of WP_Style_Engine_CSS_Rules_Store_Gutenberg":[],'"%s" successfully created.':[`تم إنشاء "%s" بنجاح.`],XXL:wt,"View next month":[`عرض الشهر المقبل`],"View previous month":[`عرض الشهر الماضي`],"Archive type: Name":[`نوع الأرشيف: الاسم`],"Show archive type in title":[`إظهار نوع الأرشيف في العنوان`],"The Queen of Hearts.":[`ملكة القلوب.`],"The Mad Hatter.":[`جنون حتر.`],"The Cheshire Cat.":[`قطة شيشاير.`],"The White Rabbit.":[`الأرنب الأبيض.`],"Alice.":[`اليس.`],"Gather blocks in a container.":[`جمع كتل في حاوية.`],"Inner blocks use content width":[`تستخدم المكوّنات الداخلية عرض المحتوى`],Font:Tt,Constrained:Et,"Spacing control":[],"Custom (%s)":[`مخصص (%s)`],"All sides":[`جميع الجوانب`],"Disables custom spacing sizes.":[`تعطيل أحجام التباعد المخصصة.`],"All Authors":[`كافة الكتّاب`],"No authors found.":[`لم يتم العثور على كتّاب.`],"Search Authors":[`البحث في الكتّاب`],"Author: %s":[`الكاتب: %s`],"Create template part":[],"Manage the fonts and typography used on headings.":[`التحكم في الخطوط وأسلوب الطباعة المُستخدمة على العناوين`],H6:Dt,H5:Ot,H4:kt,H3:At,H2:jt,H1:Mt,"Select heading level":[`تحديد مستوى العنوان`],"View site":[`عرض الموقع`],"Display the search results title based on the queried object.":[`عرض عنوان نتائج البحث استنادا إلى الكائن الذي تم الاستعلام عنه.`],"Search Results Title":[`عنوان نتائج البحث`],"Search results for: “search term”":[`نتائج البحث عن: «مصطلح البحث»`],"Show search term in title":[`عرض مصطلح البحث في العنوان`],Taxonomies:Nt,"Show label":[`إظهار التسمية`],"View options":[`عرض الخيارات`],"Disables output of layout styles.":[`يقوم بتعطيل ناتج أنماط التخطيط.`],"The template prefix for the created template. This is used to extract the main template type, e.g. in `taxonomy-books` extracts the `taxonomy`":[],"Indicates if a template is custom or part of the template hierarchy":[`يشير إلى ما إذا كان المنوال مخصصًا أو جزءًا منوال التسلسل الهرمي`],"The slug of the template to get the fallback for":[],'Search results for: "%s"':[`نتائج البحث عن: "%s"`],"Move %1$d blocks from position %2$d left by one place":[`حرّك %1$d مكوّنات من الموضع %2$d إلى اليسار بمقدار موضع واحد`],"Move %1$d blocks from position %2$d down by one place":[`حرّك %1$d مكوّنات من الموضع %2$d إلى الأسفل بمقدار موضع واحد`],"Suggestions list":[`قائمة الاقتراحات`],"Set the width of the main content area.":[`ضبط عرض منطقة المحتوى الرئيسية.`],"Border color and style picker":[`ملتقط لون ونمط الحدود`],"Switch to editable mode":[`التبديل إلى الوضع القابل للتحرير`],"Blocks cannot be moved right as they are already are at the rightmost position":[`لا يمكن تحريك المكوّنات إلى اليمين لأنها موجودة بالفعل في أقصى الموضع الأيمن`],"Blocks cannot be moved left as they are already are at the leftmost position":[`لا يمكن نقل المكوّنات إلى اليسار لأنها موجودة بالفعل في أقصى اليسار`],"All blocks are selected, and cannot be moved":[`تم تحديد جميع المكوّنات، ولا يمكن نقلها`],"Whether the V2 of the list block that uses inner blocks should be enabled.":[],"Post Comments Form block: Comments are not enabled for this item.":[`مكوّن نموذج تعليقات المقال: التعليقات غير مُفعّلة لهذا العنصر.`],"Time to read":[`مدة القراءة`],"%s minute":[],"< 1 minute":[`< 1 دقيقة`],"Apply suggested format: %s":[`تطبيق التنسيق المقترح: %s`],"Custom template":[`قالب مخصّص`],"Displays taxonomy: %s.":[`عرض الفئة: %s.`],Hover:Pt,'Describe the template, e.g. "Post with sidebar". A custom template can be manually applied to any post or page.':[`وصف القالب، مثلا. "مقالة مع شريط جانبي". يمكن تطبيق قالب مُخصص يدويًا على أي مقالة أو صفحة.`],"Change date: %s":[`تاريخ التغيير: %s`],"short date format without the yearM j":[`M j`],"Apply to all blocks inside":[`تنطبق على جميع المكوّنات داخل`],"Active theme spacing scale.":[`مقياس تباعد القوالب النشطة.`],"Active theme spacing sizes.":[`أحجام تباعد القوالب النشطة.`],"%sX-Large":[`%s كبيرا جداً`],"%sX-Small":[`%s صغير جداً`],"Some of the theme.json settings.spacing.spacingScale values are invalid":[],"post schedule date format without yearF j g:i\xA0a":[],"Tomorrow at %s":[`غدا في تمام الساعة %s`],"post schedule time formatg:i\xA0a":[`g:i\xA0a`],"Today at %s":[`اليوم في تمام الساعة %s`],"post schedule full date formatF j, Y g:i\xA0a":[],"Displays a single item: %s.":[`عرض عنصر مفرد: %s.`],"Single item: %s":[`عنصر فردي: %s`],"This template will be used only for the specific item chosen.":[`سيتم تطبيق هذا القالب للعناصر المختارة حصراً.`],"For a specific item":[`لعنصر معين`],"For all items":[`لجميع العناصر`],"Select whether to create a single template for all items or a specific one.":[`حدد ما إذا كنت تريد إنشاء منوال واحد لكل العناصر أو منوال معين.`],"Manage the fonts and typography used on buttons.":[`التحكم في الخطوط وأسلوب الطباعة المُستخدمة على الأزرار`],Summary:Ft,"Edit template":[`تحرير المنوال`],"Templates define the way content is displayed when viewing your site.":[`تحدد المناويل طريقة عرض المحتوى عند عرض موقعك.`],"Make the selected text inline code.":[`اجعل النص المحدد رمزا مضمنا.`],"Strikethrough the selected text.":[],Unset:It,"action that affects the current postEnable comments":[`تفعيل التعليقات`],"Embed a podcast player from Pocket Casts.":[`تضمين مشغل بودكاست من Pocket Casts.`],"66 / 33":[`66 / 33`],"33 / 66":[`33 / 66`],"Nested blocks will fill the width of this container.":[],"Nested blocks use content width with options for full and wide widths.":[],"Copy all blocks":[`نسخ المكوّنات`],"Overlay opacity":[`عتامة الغِشاء`],"Get started here":[`إبدأ من هنا`],"Interested in creating your own block?":[`هل أنت مهتم بإنشاء المكوّن الخاصة بك؟`],Now:Lt,"Always open List View":[],"Opens the List View panel by default.":[],"Start adding Heading blocks to create a table of contents. Headings with HTML anchors will be linked here.":[`ابدأ في إضافة مكوِّنات العناوين لإنشاء جدول محتويات. سيتم هنا ربط العناوين التي تحتوي على نقاط ارتساء HTML.`],"Only including headings from the current page (if the post is paginated).":[],"Only include current page":[`قم بتضمين الصفحة الحالية فقط`],"Convert to static list":[`تحويل إلى قائمة ثابتة`],Parents:Rt,"Commenter avatars come from Gravatar.":[`الصورة الرمزية للمُعلِق تأتي من Gravatar.`],"Links are disabled in the editor.":[`الروابط مُعطلة في المحرر.`],"%s response":[],'%1$s response to "%2$s"':[],"block titleComments":[`التعليقات`],"Control how this post is viewed.":[`التحكم في كيفية عرض هذه المقالة`],"All options reset":[`إعادة تعيين جميع الخيارات`],"All options are currently hidden":[`جميع الخيارات مخفية حاليًا`],"%s is now visible":[`%s مرئي الآن`],"%s hidden and reset to default":[`%s مخفي واعادة تعيينه للافتراضي`],"%s reset to default":[`%s إعادة التعيين إلى الوضع المبدئي`],Suffix:zt,Prefix:Bt,"If there are any Custom Post Types registered at your site, the Content block can display the contents of those entries as well.":[],"That might be a simple arrangement like consecutive paragraphs in a blog post, or a more elaborate composition that includes image galleries, videos, tables, columns, and any other block types.":[`قد يكون هذا ترتيبًا بسيطًا مثل فقرات متتالية في مقال، أو تكوين أكثر تفصيلاً يتضمن معارض الصور ومقاطع الفيديو والجداول والأعمدة وأي أنواع مكوِّنات أخرى.`],"This is the Content block, it will display all the blocks in any single post or page.":[],"Post Comments Form block: Comments are not enabled.":[`مكوّن نموذج تعليقات المقال: التعليقات غير مُفعّلة.`],"To get started with moderating, editing, and deleting comments, please visit the Comments screen in the dashboard.":[`للبدء في إدارة التعليقات وتحريرها وحذفها، يرجى زيارة شاشة التعليقات في لوحة التحكم.`],"Hi, this is a comment.":[`مرحباً، هذا تعليق.`],"January 1, 2000 at 00:00 am":[`1 يناير، 2000 الساعة 00:00 ص`],says:Vt,"A WordPress Commenter":[`مُعلِق ووردبريس`],"Leave a Reply":[`اترك تعليقاً`],'Response to "%s"':[],Response:Ht,"Your comment is awaiting moderation. This is a preview; your comment will be visible after it has been approved.":[`تعليقك في انتظار المراجعة. هذه معاينة؛ سيكون تعليقك مرئيًا بعد الموافقة عليه.`],"Your comment is awaiting moderation.":[`تعليقك في انتظار المراجعة.`],"block descriptionDisplays a title with the number of comments.":[],"block titleComments Title":[`عنوان التعليقات`],"These changes will affect your whole site.":[`ستؤثّر هذه التغييرات على موقعك بأكمله.`],'Responses to "%s"':[],"One response to %s":[`رد واحد على %s`],"Show comments count":[`إظهار عدد التعليقات`],"Show post title":[`عرض عنوان المشاركه`],"Comments Pagination block: paging comments is disabled in the Discussion Settings":[`كتلة ترقيم صفحات التعليقات: تم تعطيل ترحيل صفحات التعليقات في إعدادات المناقشة`],Responses:Ut,"One response":[`رد واحد`],"block descriptionGather blocks in a layout container.":[`تجميع المكوّنات في حاوية تخطيط.`],"block descriptionAn advanced block that allows displaying post comments using different visual configurations.":[`مكوّن متقدم يسمح لك باستعراض تعليقات المقالة باستخدام إعدادات مرئية مختلفة.`],"block descriptionDisplays the date on which the comment was posted.":[`يعرض التاريخ الذي تم نشر التعليق فيه.`],"block descriptionDisplays the name of the author of the comment.":[`إظهار اسم كاتب التعليق.`],"block descriptionThis block is deprecated. Please use the Avatar block instead.":[],"block titleComment Author Avatar (deprecated)":[],"This Navigation Menu is empty.":[],"Browse styles":[`تصفح الأنماط`],"Bottom border":[`الحدّ السفلي`],"Right border":[`الحدّ الأيمن`],"Left border":[`الحدّ الأيسر`],"Top border":[`الحدّ العلويّ`],"Border color picker.":[`ملتقط لون الحدود`],"Border color and style picker.":[`ملتقط لون ونمط الحدود`],"Link sides":[`ربط الجوانب`],"Unlink sides":[`إلغاء ربط الجوانب`],"Quote citation":[`كتابة استشهاد`],"Choose a pattern for the query loop or start blank.":[`يرجى اختيار تأليفة جاهزة لحلقة الاستعلام أو البدأ فارغاً!`],"Navigation Menu successfully deleted.":[],"Arrange blocks vertically.":[`ترتيب المكوّنات عمودياً.`],Stack:Wt,"Arrange blocks horizontally.":[`تريبت المكوّنات أفقياً.`],"Use featured image":[`استخدام الصورة البارزة`],Week:Gt,"Group by":[],"Delete selection.":[`حذف التحديد.`],"Transform to %s":[`تحويل إلى %s`],"single horizontal lineRow":[`صف`],"Select parent block: %s":[],"Alignment optionNone":[`بدون`],"Whether the V2 of the quote block that uses inner blocks should be enabled.":[`ما إذا كان يجب تمكين ن2 من مكوّن الاقتباس الذي يستخدم مكوّنات داخلية.`],"Adding an RSS feed to this site’s homepage is not supported, as it could lead to a loop that slows down your site. Try using another block, like the Latest Posts block, to list posts from the site.":[`ميزة إضافة تغذية RSS إلى الصفحة الرئيسية لهذا الموقع غير مدعومة، إذ من الممكن أن تؤدي إلى حلقة تبطئ موقعك. حاول استخدام مكوّن آخر، مثل Block أحدث المقالات، لإدراج المقالات من الموقع.`],"block descriptionContains the block elements used to render content when no query results are found.":[`يحتوي على عناصر المكوّن المستخدمة لمعالجة المحتوى عند عدم العثور على نتائج استعلام.`],"block titleNo Results":[],"block titleList Item":[],"block descriptionAdd a user’s avatar.":[`إضافة الصورة الرمزية للمستخدم.`],"block titleAvatar":[`الصورة الرمزية`],"View Preview":[`مشاهدة المعاينة`],"Download your theme with updated templates and styles.":[`احصل على نسخة محدثة من القوالب والتنسيقات الخاصة بك.`],'Custom color picker. The currently selected color is called "%1$s" and has a value of "%2$s".':[`لاقط اللون المخصص. اللون المختار حاليًا يسمى "%1$s" ولديه القيمة %2$s".`],"Largest size":[`أكبر حجم`],"Smallest size":[`أصغر حجم`],"Add text or blocks that will display when a query returns no results.":[`أضف نصوصًا أو مكوّنات من شأنها أن تظهر عندما لا يُرجع الاستعلام أي نتائج.`],"Featured image: %s":[`الصورة البارزة: %s`],"Link to post":[`ربط بالمقال`],Invalid:Kt,"Link to user profile":[`رابط للملف الشخصي للعضو`],"Select the avatar user to display, if it is blank it will use the post/page author.":[`حدد المستخدم المراد عرض الصورة الرمزية الخاصة به، اذا تمّ ترك هذا الحقل فارغًا فإنّه سيتمّ تعيين الصورة الرمزية للناشر/الكاتب لهذا المحتوى بشكل افتراضي.`],"Default Avatar":[`الصورة الرمزية المبدئية`],"Enter a date or time format string.":[`أدخل صيغة التاريخ أو الوقت.`],"Custom format":[`تنسيق مخصص`],"Choose a format":[`اختيار صيغة`],"Enter your own date format":[`ادخال صيغة التاريخ الخاصة بك`],"long date formatF j, Y":[`j F، Y`],"medium date format with timeM j, Y g:i A":[],"medium date formatM j, Y":[],"short date format with timen/j/Y g:i A":[],"short date formatn/j/Y":[],"Default format":[`التنسيق المبدئي`],"%s link":[`%s رابط`],Lock:qt,Unlock:Jt,"Lock all":[`قفل الكل`],"Lock %s":[`قفل %s`],"(%s website link, opens in a new tab)":[`(رابط الموقع الإلكتروني لـ %s، يُفتح في تبويب جديد)`],"(%s author archive, opens in a new tab)":[`(أرشيف الكاتب %s، يفتح في تبويب جديد)`],"Preference activated - %s":[`تم تفعيل التفضيل - %s`],"Preference deactivated - %s":[`تم الغاء تفعيل التفضيل - %s`],"Insert a link to a post or page.":[`إدراج رابط لمقالة أو صفحة.`],"Classic menu import failed.":[`فشل في استيراد القائمة الكلاسيكية.`],"Classic menu imported successfully.":[`تم استيراد القائمة الكلاسيكية بنجاح.`],"Classic menu importing.":[`استيراد القائمة التقليدية.`],"Failed to create Navigation Menu.":[`فشل في إنشاء قائمة تنقّل.`],"Navigation Menu successfully created.":[`تم إنشاء قائمة التنقل بنجاح.`],"Creating Navigation Menu.":[`جار إنشاء قائمة تنقل.`],'Unable to create Navigation Menu "%s".':[`غير قادر على إنشاء قائمة التنقل "%s".`],'Unable to fetch classic menu "%s" from API.':[`غير قادر على جلب القائمة التقليدية "%s" من واجهة برمجة التطبيقات (API).`],"Navigation block setup options ready.":[`خيارات إعداد مكوّن التنقل جاهزة.`],"Loading navigation block setup options…":[],"Choose a %s":[`تحديد الـ%s`],"Existing template parts":[`أجزاء القالب الموجودة`],"Convert to Link":[`تحويل إلى رابط`],"%s blocks deselected.":[`تم إلغاء تحديد %s مكوّن.`],"%s deselected.":[`%s غير محدد.`],"block descriptionDisplays the link of a post, page, or any other content-type.":[`إظهار رابط لمقالة أو لصفحة أو لأي نوع محتوى آخر.`],"block titleRead More":[`اقرأ المزيد`],"block descriptionThe author biography.":[`النبذة التعريفيّة للكاتب.`],"block titleAuthor Biography":[`سيرة الكاتب`],'The "%s" plugin has encountered an error and cannot be rendered.':[`لقد واجهت الإضافة "%s" خطأ ولا يمكن معاينته.`],"The posts page template cannot be changed.":[`لا يمكن تغيير قالب صفحة المقالات.`],"Author Biography":[`النبذة التعريفيّة للكاتب`],"Create from '%s'":[`إنشاء من '%s'`],"Older comments page link":[`رابط صفحة التعليقات الأقدم`],"If you take over, the other user will lose editing control to the post, but their changes will be saved.":[`إذا توليت زمام الأمور، سيفقد المستخدم الآخر التحكم في تحرير المقالة، ولكن سيتم حفظ التغييرات التي أجروها.`],"Select the size of the source image.":[`تحديد حجم الصورة المصدر.`],"Configure the visual appearance of the button that toggles the overlay menu.":[],"Show icon button":[`إظهار زر الأيقونة`],"font weightBlack":[`أسود`],"font weightExtra Bold":[`سميك جداً`],"font weightBold":[`سميك`],"font weightSemi Bold":[`شبه سميك`],"font weightMedium":[`متوسط`],"font weightRegular":[`عادي`],"font weightLight":[`فاتح`],"font weightExtra Light":[`رفيع جدًا`],"font weightThin":[`رفيع`],"font styleItalic":[`مائل`],"font styleRegular":[`عادي`],"Transparent text may be hard for people to read.":[`قد يكون النص الشفاف صعبا على القراءة.`],"Sorry, you are not allowed to view this global style.":[`عذرًا، غير مسموح لك بعرض هذا التنسيق العام.`],"Sorry, you are not allowed to edit this global style.":[`عذرًا، غير مسموح لك بتحرير هذا التنسيق العام.`],"Older Comments":[`التعليقات القديمة`],"Newer Comments":[`تعليقات جديدة`],"block descriptionDisplay post author details such as name, avatar, and bio.":[`عرض تفاصيل كاتب المقالة مثل الاسم، الصورة الرمزية، والنبذة التعريفيّة.`],"Categories provide a helpful way to group related posts together and to quickly tell readers what a post is about.":[`توفر التصنيفات طريقة مفيدة لتجميع المقالات ذات الصلة معًا ولإخبار القراء بسرعة عن موضوع المقالة.`],"Assign a category":[`إسناد تصنيف`],"%s is currently working on this post (), which means you cannot make changes, unless you take over.":[`%s يعمل حاليًا على هذه المقالة ()، مما يعني أنه لا يمكنك إجراء تغييرات، إلا إذا توليت المهمة.`],preview:Yt,"%s now has editing control of this post (). Don’t worry, your changes up to this moment have been saved.":[`%s لديه التحكم حاليًا في تحرير هذه المقالة (). لا داعي للقلق، فقد تم حفظ تغييراتك حتى هذه اللحظة.`],"Exit editor":[`الخروج من المحرر`],"Draft saved.":[`تم حفظ المسودة.`],"site exporter menu itemExport":[`تصدير`],"Close Block Inserter":[],"Page List: Cannot retrieve Pages.":[`قائمة الصفحات: لا يمكن استرداد الصفحات.`],"Link is empty":[`الرابط فارغ`],"Button label to reveal tool panel options%s options":[`خيارات الـ %s`],"Search %s":[`البحث في %s`],"Set custom size":[`تعيين حجم مخصص`],"Use size preset":[`استخدام الحجم المحدد مسبقاً`],"Reset colors":[`إعادة تعيين الألوان`],"Reset gradient":[`إعادة تعيين التدرج`],"Remove all colors":[`إزالة كل الألوان`],"Remove all gradients":[`إزالة كل التدرجات`],"Color options":[`خيارات الألوان`],"Gradient options":[`خيارات التدرج`],"Add color":[`إضافة لون`],"Add gradient":[`إضافة تدرج`],Done:Xt,"Gradient name":[`اسم التدرج`],"Color %d":[],"Color format":[`تنسيق الألوان`],"Hex color":[`لون سداسي`],"block descriptionThe author name.":[`اسم الكاتب.`],"block titleAuthor Name":[`اسم الكاتب`],"block descriptionDisplays the previous comment's page link.":[`إظهار رابط صفحة التعليقات السابقة.`],"block descriptionDisplays the next comment's page link.":[`إظهار رابط صفحة التعليقات التالية.`],Icon:Zt,Delete:Qt,"Icon background":[`أيقونة الخلفية`],"Use as Site Icon":[],"Site Icons are what you see in browser tabs, bookmark bars, and within the WordPress mobile apps. To use a custom icon that is different from your site logo, use the Site Icon settings.":[`أيقونات الموقع هي ماتراه في علامات تبويب المتصفح، وأشرطة الإشارات المرجعية، وداخل تطبيقات ووردبريس للجوال. لاستخدام أيقونة مخصصة مختلفة عن شعار موقعك، استخدم إعدادات أيقونة الموقع.`],"Post type":[`نوع المشاركة`],"Link to author archive":[`رابط أرشيف الكاتب`],"Author Name":[`اسم الكاتب`],"You do not have permission to create Navigation Menus.":[`ليست لديك صلاحية إنشاء قوائم التنقل.`],"You do not have permission to edit this Menu. Any changes made will not be saved.":[`ليست لديك صلاحية لتحرير هذه القائمة. لن يتم حفظ أي من التغييرات التي تم إجراؤها.`],"Newer comments page link":[`رابط صفحة التعليقات الأحدث`],"Site icon.":[`أيقونة الموقع.`],"Font size nameExtra Large":[`كبير جدًا`],"block titlePagination":[`تعدد الصفحات`],"block titlePrevious Page":[`الصفحة السابقة`],"block titlePage Numbers":[`أرقام الصفحات`],"block titleNext Page":[`الصفحة التالية`],"block descriptionDisplays a list of page numbers for comments pagination.":[`إظهار قائمة بأرقام صفحات التعليقات.`],"Site updated.":[`تم تحديث الموقع`],"Saving failed.":[`فشلت عملية الحفظ.`],"%1$s ‹ %2$s — WordPress":[],"https://wordpress.org/documentation/article/styles-overview/":[`https://wordpress.org/support/article/styles-overview/`],"An error occurred while creating the site export.":[`حدث خطأ أثناء تصدير الموقع.`],"Manage menus":[`إدارة القوائم`],"%s submenu":[`القائمة الفرعية لـ %s`],"block descriptionDisplays a paginated navigation to next/previous set of comments, when applicable.":[`يعرض قائمة تنقل ذات صفحات مرقمة إلى مجموعة التعليقات التالية/السابقة، عند الاقتضاء.`],"block titleComments Pagination":[`ترقيم التعليقات`],Actions:$t,"An error occurred while restoring the post.":[`حدث خطأ أثناء استعادة المقالة.`],Rename:en,"An error occurred while setting the homepage.":[],"An error occurred while creating the template part.":[`حدث خطأ أثناء إنشاء جزء القالب.`],"An error occurred while creating the template.":[`حدث خطأ أثناء إنشاء القالب.`],"Manage the fonts and typography used on the links.":[`إدارة الخطوط المستخدمة على الروابط.`],"Manage the fonts used on the site.":[`إدارة الخطوط المستخدمة في الموقع.`],Aa:tn,"An error occurred while deleting the item.":[],"Show arrow":[`إظهار السهم`],"Arrow option for Comments Pagination Next/Previous blocksChevron":[`شارة رتبة`],"Arrow option for Comments Pagination Next/Previous blocksArrow":[`سهم`],"Arrow option for Comments Pagination Next/Previous blocksNone":[`بدون`],"A decorative arrow appended to the next and previous comments link.":[`سهم مزخرف ملحق بروابط التعليقات التالية والسابقة.`],"Indicates this palette is created by the user.Custom":[`مُخصص`],"Indicates this palette comes from WordPress.Default":[`افتراضي`],"Indicates this palette comes from the theme.Theme":[`قالب`],"Add default block":[`إضافة مكوّن افتراضي`],"Whether a template is a custom template.":[],"Unable to open export file (archive) for writing.":[`تعذر فتح ملف التصدير (الأرشيف) للكتابة.`],"Zip Export not supported.":[`تصدير ZIP غير مدعوم.`],"Displays latest posts written by a single author.":[`إظهار أحدث المقالات المنشورة بواسطة كاتب واحد.`],"Here’s a detailed guide to learn how to make the most of it.":[`هذا دليل تفصيلي حول كيفية الإستفادة القصوى منها.`],"New to block themes and styling your site?":[],"You can adjust your blocks to ensure a cohesive experience across your site — add your unique colors to a branded Button block, or adjust the Heading block to your preferred size.":[`يمكنك تعديل المكوّنات الخاصة بك لضمان تجربة متماسكة عبر موقعك — ​​أضف ألوانك الفريدة إلى مكوّن زر ذات علامة تجارية، أو عدّل مكوّن العنوان إلى الحجم المفضل لديك.`],"Personalize blocks":[`تخصيص المكوّنات`],"You can customize your site as much as you like with different colors, typography, and layouts. Or if you prefer, just leave it up to your theme to handle!":[],"Set the design":[`حدد التصميم`],"Tweak your site, or give it a whole new look! Get creative — how about a new color palette for your buttons, or choosing a new font? Take a look at what you can do here.":[`اضبط موقعك، أو أعطه مظهرًا جديدًا كلياً — ما رأيك بلائحة ألوان جديدة للأزرار، أو اختيار نوع خط جديد؟ ألقِ نظرة على ما يمكنك فعله هنا.`],"Welcome to Styles":[`مرحبًا بك في التنسيقات`],styles:nn,"Click to start designing your blocks, and choose your typography, layout, and colors.":[`انقر على للبدء بتصميم المكوّنات واختيار الخطوط والتخطيط والألوان.`],"Design everything on your site — from the header right down to the footer — using blocks.":[`صمم كل شيء على موقعك - من الترويسة إلى التذييل - باستخدام المكوّنات.`],"Edit your site":[`تحرير الموقع`],"Welcome to the site editor":[`مرحبًا بك في مُحرّر الموقع`],"Add a featured image":[`إضافة صورة بارزة`],"block descriptionThis block is deprecated. Please use the Comments block instead.":[],"block titleComment (deprecated)":[],"block descriptionShow a block pattern.":[`عرض نمط لـ مكوّن.`],"block titlePattern":[`نمط`],"block keywordequation":[],"block descriptionAn advanced block that allows displaying taxonomy terms based on different query parameters and visual configurations.":[],"block descriptionContains the block elements used to display a comment, like the title, date, author, avatar and more.":[`يحتوي على عناصر المكوِّن المُستخدمة لعرض تعليق، مثل العنوان والتاريخ والكاتب والصورة الرمزية والمزيد.`],"block titleComment Template":[`قالب التعليقات`],"block descriptionDisplays a link to reply to a comment.":[`يعرض رابط للرد على تعليق.`],"block titleComment Reply Link":[`رابط الرد على التعليقات`],"block descriptionDisplays a link to edit the comment in the WordPress Dashboard. This link is only visible to users with the edit comment capability.":[`يعرض رابط لتحرير التعليق في لوحة تحكم ووردبريس. هذا الرابط مرئي فقط للمستخدمين الذين يمتلكون صلاحية تحرير التعليق.`],"block titleComment Edit Link":[`رابط تحرير التعليق`],"block descriptionDisplays the contents of a comment.":[`إظهار محتوى التعليق.`],"block titleComment Author Name":[`اسم كاتب التعليق`],"%s applied.":[`%s تم تطبيقها.`],"%s removed.":[`تم إزالة %s.`],"%s: Sorry, you are not allowed to upload this file type.":[`%s: عذراً، غير مسموح لك بتحميل هذا النوع من الملفات.`],"This change will affect your whole site.":[`سيؤثر هذا التغيير على موقعك بالكامل.`],"Use left and right arrow keys to resize the canvas.":[`استخدم مفاتيح الأسهم الأيمن والأيسر لتغيير حجم اللوحة.`],"Drag to resize":[`اسحب لتغيير الحجم`],"Submenu & overlay background":[`خلفية القائمة الفرعية والغِشاء`],"Submenu & overlay text":[`نص القائمة الفرعية والغِشاء`],"Create new Menu":[],"Unsaved Navigation Menu.":[],Menus:rn,"Open List View":[],"Embed Wolfram notebook content.":[`تضمين محتوى دفتر Wolfram.`],Reply:an,"Displays more block tools":[`إظهار المزيد من أدوات المكوّن`],"Create a two-tone color effect without losing your original image.":[`إنشاء تأثير لوني بدرجتين دون أن تفقد صورتك الأصلية.`],"Remove %s":[`إزالة %s`],"Explore all patterns":[`استكشاف كل الأنماط`],"Allow to wrap to multiple lines":[`السماح للالتفاف إلى خطوط متعددة`],"No Navigation Menus found.":[],"Add New Navigation Menu":[],"Theme not found.":[`القالب غير موجود`],"HTML title for the post, transformed for display.":[`عنوان HTML للمقالة، مُعد للعرض.`],"Title for the global styles variation, as it exists in the database.":[`عنوان مجموعة التنسيقات العامة، كما هو موجود في قاعدة البيانات.`],"Title of the global styles variation.":[`عنوان مجموعة التنسيقات العامة.`],"Global settings.":[`الإعدادات العامة.`],"Global styles.":[`تنسيقات عامة.`],"ID of global styles config.":[`مُعرِّف إعداد التنسيقات العامة.`],"No global styles config exist with that id.":[`لا توجد تنسيقات عامة متوفر لها إعدادات مع هذا المُعرّف.`],"Sorry, you are not allowed to access the global styles on this site.":[`عذرًا، غير مسموح لك بالوصول إلى التنسيقات العامة على هذا الموقع.`],"The theme identifier":[`مُعرف القالب`],"%s Avatar":[`%s الصورة الرمزية`],"block style labelPlain":[`عادي`],Elements:on,"Customize the appearance of specific blocks and for the whole site.":[`تخصيص مظهر مكوّنات محددة ولكامل الموقع.`],"Link to comment":[`رابط للتعليق`],"Link to authors URL":[`رابط للكتاب URL`],"Choose an existing %s or create a new one.":[`إختار %s موجود أو انشئ واحد جديد.`],"Show icon":[],Submenus:sn,Always:cn,"Collapses the navigation options in a menu icon opening an overlay.":[`طيّ خيارات التنقل في أيقونة قائمة تفتح كـ غِشاء.`],Display:ln,"Embed Pinterest pins, boards, and profiles.":[`تضمين دبابيس Pinterest، اللوحات والملفات الشخصية.`],bookmark:un,"block descriptionDisplays the name of this site. Update the block, and the changes apply everywhere it’s used. This will also appear in the browser title bar and in search results.":[`عرض اسم الموقع. قم بتحديث المكوّن، وسيتم تطبيق التغييرات في كل مكان تم استخدامه فيه. يظهر هذا أيضًا في شريط عنوان المتصفح وفي نتائج البحث.`],Highlight:dn,"Create page: %s":[`إنشاء صفحة: %s`],"You do not have permission to create Pages.":[`ليس لديك صلاحية إنشاء صفحات.`],Palette:fn,"Include the label as part of the link":[`إضافة التسمية كجزء من الرابط`],"Previous: ":[`السابق:`],"Next: ":[`التالي:`],"Make title link to home":[`جعل رابط العنوان يشير الصفحة الرئيسة`],"Block spacing":[`تباعد المكوَنات`],"Max %s wide":[`أقصى عرض %s`],"label before the title of the previous postPrevious:":[`السابق:`],"label before the title of the next postNext:":[`التالي:`],"block descriptionAdd a submenu to your navigation.":[`أضف قائمة فرعية لقائمة التصفّح الخاص بك.`],"block titleSubmenu":[`القائمة الفرعية`],"block descriptionDisplay content in multiple columns, with blocks added to each column.":[`عرض المحتوى في عدة أعمدة، من خلال إضافة مكوّنات لكل عمود.`],"Customize the appearance of specific blocks for the whole site.":[`تخصيص مظهر مكوّنات محددة لكامل الموقع.`],Colors:pn,"Hide and reset %s":[`إخفاء وإعادة تعيين %s`],"Reset %s":[`إعادة تعيين %s`],"The