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/.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/.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/__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..12d53b270 --- /dev/null +++ b/__mocks__/@wordpress/block-editor.js @@ -0,0 +1,3 @@ +// Intentionally empty — prevents the real module from loading. + +export const store = { name: 'core/block-editor' }; 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..046ff29f8 --- /dev/null +++ b/__mocks__/@wordpress/core-data.js @@ -0,0 +1 @@ +export const store = { name: 'core/data' }; 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/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/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/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index 88bd94fb4..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,15 +40,21 @@ 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 -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. * + * 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,10 +90,12 @@ private const val ASSET_URL_HTTP = "http://appassets.androidplatform.net/assets/ * - 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 + private lateinit var assetDomain: String private val configuration: EditorConfiguration private lateinit var dependencies: EditorDependencies @@ -115,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 /** @@ -126,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) } } @@ -179,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.") } /** @@ -198,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}" + + webView.addJavascriptInterface(this, "editorDelegate") - this.webViewClient = object : WebViewClient() { + 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) } @@ -243,10 +358,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 +398,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 } @@ -308,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()) @@ -355,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 { @@ -370,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") } @@ -381,7 +504,7 @@ class GutenbergView : WebView { loadEditor(fetchedDependencies) } catch (e: Exception) { Log.e("GutenbergView", "Failed to load dependencies", e) - loadingListener?.onDependencyLoadingFailed(e) + showErrorPhase(e) } } } @@ -394,6 +517,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,25 +535,27 @@ 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() - // Notify that dependency loading is complete (spinner phase begins) - loadingListener?.onDependencyLoadingFinished() + // Transition to spinner phase (WebView initialization) + showSpinnerPhase() 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 } WebStorage.getInstance().deleteAllData() - this.clearCache(true) + webView.clearCache(true) // All cookies are third-party cookies because the root of this document - // lives under `appassets.androidplatform.net` - CookieManager.getInstance().setAcceptThirdPartyCookies(this, true) + // lives under the configured asset domain (e.g., `https://appassets.androidplatform.net`) + 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 @@ -434,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") } @@ -448,7 +578,7 @@ class GutenbergView : WebView { localStorage.setItem('GBKit', JSON.stringify(window.GBKit)); """.trimIndent() - this.evaluateJavascript(gbKitConfig, null) + webView.evaluateJavascript(gbKitConfig, null) } @@ -458,7 +588,7 @@ class GutenbergView : WebView { localStorage.removeItem('GBKit'); """.trimIndent() - this.evaluateJavascript(jsCode, null) + webView.evaluateJavascript(jsCode, null) } fun setContent(newContent: String) { @@ -467,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) { @@ -476,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 { @@ -565,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 @@ -591,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) } } @@ -614,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) } } @@ -627,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) } } @@ -732,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 } @@ -880,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 @@ -895,7 +1026,7 @@ class GutenbergView : WebView { requestInterceptor = DefaultGutenbergRequestInterceptor() latestContentProvider = null handler.removeCallbacksAndMessages(null) - this.destroy() + webView.destroy() } // Network Monitoring @@ -940,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 { @@ -958,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/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/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt index 2e44cbaef..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 @@ -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, @@ -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 { /** @@ -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?.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, @@ -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..cc1288d2c 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,9 +71,13 @@ 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 REST API base path for this post type (e.g., `posts`, `pages`). */ + val restBase: String, + /** The REST API namespace (e.g., `wp/v2`). */ + val restNamespace: String, /** The post status (e.g., `draft`, `publish`, `pending`). */ val status: String, /** The post title (URL-encoded). */ @@ -95,6 +99,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 +112,11 @@ 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", + restBase = restBaseFor(configuration.postType), + restNamespace = "wp/v2", + status = configuration.postStatus, title = configuration.title.encodeForEditor(), content = configuration.content.encodeForEditor() ), @@ -124,6 +132,18 @@ data class GBKitGlobal( } ) } + + /** + * Maps a post type slug to its WordPress REST API base path. + * + * Defaults to pluralizing the slug for unknown types (e.g., `product` → `products`), + * which matches the WordPress convention for most post types. + */ + private fun restBaseFor(postType: String): String = when (postType) { + "post" -> "posts" + "page" -> "pages" + else -> if (postType.endsWith("s")) postType else "${postType}s" + } } /** 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 b4e15c0c4..783d6abff 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 @@ -65,7 +68,7 @@ class GutenbergViewTest { } // When - gutenbergView.webChromeClient?.onShowFileChooser( + gutenbergView.editorWebView.webChromeClient?.onShowFileChooser( mockWebView, mockFilePathCallback, mockFileChooserParams @@ -105,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 @@ -131,7 +134,7 @@ class GutenbergViewTest { @Test fun `onShowFileChooser stores file path callback`() { // When - gutenbergView.webChromeClient?.onShowFileChooser( + gutenbergView.editorWebView.webChromeClient?.onShowFileChooser( mockWebView, mockFilePathCallback, mockFileChooserParams @@ -145,7 +148,7 @@ class GutenbergViewTest { @Test fun `resetFilePathCallback clears the callback`() { // Given - gutenbergView.webChromeClient?.onShowFileChooser( + gutenbergView.editorWebView.webChromeClient?.onShowFileChooser( mockWebView, mockFilePathCallback, mockFileChooserParams @@ -165,10 +168,44 @@ 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", 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.editorWebView.webViewClient.shouldOverrideUrlLoading(siteView.editorWebView, 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.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/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/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..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 @@ -72,16 +72,25 @@ 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 zero results in null`() { + val config = builder() + .setPostId(0u) + .build() + + assertNull(config.postId) } @Test fun `setPostId with null clears postId`() { val config = builder() - .setPostId(123) + .setPostId(123u) .setPostId(null) .build() @@ -265,7 +274,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 +283,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 +309,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 +339,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 +348,7 @@ class EditorConfigurationBuilderTest { assertEquals("Original Title", original.title) assertEquals("Modified Title", modified.title) - assertEquals(100, modified.postId) + assertEquals(100u, modified.postId) } @Test @@ -377,7 +386,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 +395,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 +541,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 +812,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 +853,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 +876,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..b49068af3 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,36 @@ class GBKitGlobalTest { assertEquals(-1, globalWithout.post.id) } + @Test + fun `populates restBase and restNamespace for post type`() { + val configuration = makeConfiguration(postType = "post") + val global = GBKitGlobal.fromConfiguration(configuration, makeDependencies()) + assertEquals("posts", global.post.restBase) + assertEquals("wp/v2", global.post.restNamespace) + } + + @Test + fun `populates restBase for page post type`() { + val configuration = makeConfiguration(postType = "page") + val global = GBKitGlobal.fromConfiguration(configuration, makeDependencies()) + assertEquals("pages", global.post.restBase) + assertEquals("wp/v2", global.post.restNamespace) + } + + @Test + fun `pluralizes custom post type slugs for restBase`() { + val configuration = makeConfiguration(postType = "product") + val global = GBKitGlobal.fromConfiguration(configuration, makeDependencies()) + assertEquals("products", global.post.restBase) + } + + @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 +179,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 +195,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/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/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" 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/e2e/editor-page.js b/e2e/editor-page.js index d93417ef1..2b16da354 100644 --- a/e2e/editor-page.js +++ b/e2e/editor-page.js @@ -187,6 +187,17 @@ export default class EditorPage { }, index ); } + /** + * Call the bridge's `getTitleAndContent()` and return the result. + * + * @return {Promise<{title: string, content: string, changed: boolean}>} The editor state. + */ + async getTitleAndContent() { + return await this.#page.evaluate( () => + window.editor.getTitleAndContent() + ); + } + /** * Retrieve all blocks from the editor via the WP data store. * diff --git a/e2e/get-title-and-content.spec.js b/e2e/get-title-and-content.spec.js new file mode 100644 index 000000000..8ca9b47bf --- /dev/null +++ b/e2e/get-title-and-content.spec.js @@ -0,0 +1,182 @@ +/** + * External dependencies + */ +import { test, expect } from '@playwright/test'; + +/** + * Internal dependencies + */ +import EditorPage from './editor-page'; + +test.describe( 'getTitleAndContent', () => { + test( 'returns correct title and content before any edits', async ( { + page, + } ) => { + const editor = new EditorPage( page ); + await editor.setup( { + post: { + id: 1, + type: 'post', + status: 'draft', + title: 'Initial Title', + content: + '\n

Hello

\n', + }, + } ); + + const result = await editor.getTitleAndContent(); + + expect( typeof result.title ).toBe( 'string' ); + expect( typeof result.content ).toBe( 'string' ); + expect( result.title ).toBe( 'Initial Title' ); + expect( result.content ).toBe( + '\n

Hello

\n' + ); + expect( result.changed ).toBe( false ); + } ); + + test( 'returns plain strings after editing the title', async ( { + page, + } ) => { + const editor = new EditorPage( page ); + await editor.setup( { + post: { + id: 1, + type: 'post', + status: 'draft', + title: 'Original', + content: '', + }, + } ); + + const titleInput = page.getByRole( 'textbox', { + name: 'Add title', + } ); + await titleInput.click(); + await page.keyboard.press( 'ControlOrMeta+a' ); + await page.keyboard.type( 'Updated Title' ); + + const result = await editor.getTitleAndContent(); + + expect( typeof result.title ).toBe( 'string' ); + expect( result.title ).toBe( 'Updated Title' ); + expect( result.changed ).toBe( true ); + } ); + + test( 'returns plain strings after editing content', async ( { page } ) => { + const editor = new EditorPage( page ); + await editor.setup( { + post: { + id: 1, + type: 'post', + status: 'draft', + title: 'Title', + content: '', + }, + } ); + + await editor.clickBlockAppender(); + await page.keyboard.type( 'New paragraph' ); + + const result = await editor.getTitleAndContent(); + + expect( typeof result.title ).toBe( 'string' ); + expect( typeof result.content ).toBe( 'string' ); + expect( result.title ).toBe( 'Title' ); + expect( result.content ).toContain( 'New paragraph' ); + expect( result.changed ).toBe( true ); + } ); + + test( 'returns plain strings with empty initial state', async ( { + page, + } ) => { + const editor = new EditorPage( page ); + await editor.setup(); + + const result = await editor.getTitleAndContent(); + + expect( typeof result.title ).toBe( 'string' ); + expect( typeof result.content ).toBe( 'string' ); + expect( result.title ).toBe( '' ); + expect( result.content ).toBe( '' ); + expect( result.changed ).toBe( false ); + } ); + + test( 'returns plain strings when data store title is a {raw, rendered} object', async ( { + page, + } ) => { + const editor = new EditorPage( page ); + await editor.setup( { + post: { + id: 1, + type: 'post', + status: 'draft', + title: 'Initial Title', + content: '', + }, + } ); + + // Inject an object-shaped title edit via editEntityRecord. + // This simulates the Gutenberg data store bug where + // getEditedPostAttribute bypasses getPostRawValue normalization + // for values in the edits layer. + await page.evaluate( () => { + window.wp.data + .dispatch( 'core' ) + .editEntityRecord( 'postType', 'post', 1, { + title: { + raw: 'Object Title', + rendered: 'Object Title', + }, + } ); + } ); + + const result = await editor.getTitleAndContent(); + + expect( typeof result.title ).toBe( 'string' ); + expect( result.title ).toBe( 'Object Title' ); + expect( result.changed ).toBe( true ); + + // Second call should report no further changes. + const second = await editor.getTitleAndContent(); + expect( second.changed ).toBe( false ); + } ); + + test( 'returns plain strings when data store content is a {raw, rendered} object', async ( { + page, + } ) => { + const editor = new EditorPage( page ); + await editor.setup( { + post: { + id: 1, + type: 'post', + status: 'draft', + title: 'Title', + content: '', + }, + } ); + + await page.evaluate( () => { + window.wp.data + .dispatch( 'core' ) + .editEntityRecord( 'postType', 'post', 1, { + content: { + raw: '

Test

', + rendered: '

Test

', + }, + } ); + } ); + + const result = await editor.getTitleAndContent(); + + expect( typeof result.content ).toBe( 'string' ); + expect( result.content ).toContain( + '

Test

' + ); + expect( result.changed ).toBe( true ); + + // Second call should report no further changes. + const second = await editor.getTitleAndContent(); + expect( second.changed ).toBe( false ); + } ); +} ); 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/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 1993d818d..dbe9fe522 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -374,19 +374,25 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro evaluate("editor.setContent('\(escapedString)');", isCritical: true) } - /// Returns the current editor content. - public func getContent() async throws -> String { - try await webView.evaluateJavaScript("editor.getContent();") as! String - } - public struct EditorTitleAndContent: Decodable { public let title: String public let content: String public let changed: Bool } + /// Returns just the current editor content, without the title. + /// + /// Use this when the editor is used without a title field (e.g. as a + /// comment editor). Delegates to `getTitleAndContent()` internally so + /// the same normalization is applied. + public func getContent() async throws -> String { + let result = try await getTitleAndContent() + return result.content + } + /// 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 +405,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 +582,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 +689,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 +747,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/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift b/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift index 979acd61b..e614f7259 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift @@ -18,7 +18,7 @@ public protocol EditorViewControllerDelegate: AnyObject { /// Notifies the client about the new edits. /// - /// - note: To get the latest content, call ``EditorViewController/getContent()``. + /// - note: To get the latest content, call ``EditorViewController/getTitleAndContent()``. /// Retrieving the content is a relatively expensive operation and should not /// be performed too frequently during editing. /// 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/GutenbergKit/Sources/Media/MediaUploadServer.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift new file mode 100644 index 000000000..c06a0fa79 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift @@ -0,0 +1,536 @@ +import Foundation +import GutenbergKitHTTP +import OSLog + +/// A local HTTP server that receives file uploads from the WebView and routes +/// them through the native media processing pipeline. +/// +/// Built on ``HTTPServer`` from `GutenbergKitHTTP`, which handles TCP binding, +/// HTTP parsing, bearer token authentication, and multipart form-data parsing. +/// This class provides the upload-specific handler: receiving a file, delegating +/// to the host app for processing/upload, and returning the result as JSON. +/// +/// Lifecycle is tied to `EditorViewController` — start when the editor loads, +/// stop on deinit. +final class MediaUploadServer: Sendable { + + /// The port the server is listening on. + let port: UInt16 + + /// Per-session auth token for validating incoming requests. + let token: String + + private let server: HTTPServer + + /// Creates and starts a new upload server. + /// + /// - Parameters: + /// - uploadDelegate: Optional delegate for customizing file processing and upload. + /// - defaultUploader: Fallback uploader used when no delegate provides `uploadFile`. + /// - maxRequestBodySize: The maximum allowed request body size in bytes. + /// Requests exceeding this limit receive a 413 response. Defaults to 4 GB. + static func start( + uploadDelegate: (any MediaUploadDelegate)? = nil, + defaultUploader: DefaultMediaUploader? = nil, + maxRequestBodySize: Int64 = HTTPRequestParser.defaultMaxBodySize + ) async throws -> MediaUploadServer { + let context = UploadContext(uploadDelegate: uploadDelegate, defaultUploader: defaultUploader) + + let server = try await HTTPServer.start( + name: "media-upload", + requiresAuthentication: true, + maxRequestBodySize: maxRequestBodySize, + handler: { request in + await Self.handleRequest(request, context: context) + } + ) + + return MediaUploadServer(server: server) + } + + private init(server: HTTPServer) { + self.server = server + self.port = server.port + self.token = server.token + } + + /// Stops the server and releases resources. + func stop() { + server.stop() + } + + // MARK: - Request Handling + + private static func handleRequest(_ request: HTTPServer.Request, context: UploadContext) async -> HTTPResponse { + let parsed = request.parsed + + // Server-detected error (e.g., payload too large) — build the + // error response here so it includes CORS headers. + if let serverError = request.serverError { + let message: String = switch serverError { + case .payloadTooLarge: "The file is too large to upload in the editor." + default: "\(serverError.httpStatusText)" + } + return errorResponse(status: serverError.httpStatus, body: message) + } + + // CORS preflight — the library exempts OPTIONS from auth, so this is + // reached without a token. + if parsed.method.uppercased() == "OPTIONS" { + return corsPreflightResponse() + } + + // Route: only POST /upload is handled. + guard parsed.method.uppercased() == "POST", parsed.target == "/upload" else { + return errorResponse(status: 404, body: "Not found") + } + + return await handleUpload(request, context: context) + } + + private static func handleUpload(_ request: HTTPServer.Request, context: UploadContext) async -> HTTPResponse { + let parts: [MultipartPart] + do { + parts = try request.parsed.multipartParts() + } catch { + Logger.uploadServer.error("Multipart parse failed: \(error)") + return errorResponse(status: 400, body: "Expected multipart/form-data") + } + + // Find the file part (the first part with a filename). + guard let filePart = parts.first(where: { $0.filename != nil }) else { + return errorResponse(status: 400, body: "No file found in request") + } + + // Write part body to a dedicated temp file for the delegate. + // + // The library's RequestBody may be a byte-range slice of a larger temp + // file whose lifecycle is tied to ARC. The delegate needs a standalone + // file that outlives the handler return, so we stream to our own file. + let filename = sanitizeFilename(filePart.filename ?? "upload") + let mimeType = filePart.contentType + + let tempDir = FileManager.default.temporaryDirectory + .appending(component: "GutenbergKit-uploads", directoryHint: .isDirectory) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + + let fileURL = tempDir.appending(component: "\(UUID().uuidString)-\(filename)") + do { + let inputStream = try filePart.body.makeInputStream() + try writeStream(inputStream, to: fileURL) + } catch { + Logger.uploadServer.error("Failed to write upload to disk: \(error)") + return errorResponse(status: 500, body: "Failed to save file") + } + + // Process and upload through the delegate pipeline. + let result: Result + var processedURL: URL? + do { + let uploadResult = try await processAndUpload( + fileURL: fileURL, mimeType: mimeType, filename: filePart.filename ?? "upload", context: context + ) + switch uploadResult { + case .uploaded(let media, let processed): + processedURL = processed + Logger.uploadServer.debug("Uploading processed file to WordPress") + result = .success(media) + case .passthrough: + // Delegate didn't modify the file — forward the original + // request body to WordPress without re-encoding. + Logger.uploadServer.debug("Passthrough: forwarding original request body to WordPress") + guard let body = request.parsed.body, + let contentType = request.parsed.header("Content-Type"), + let defaultUploader = context.defaultUploader else { + result = .failure(UploadError.noUploader) + break + } + let media = try await defaultUploader.passthroughUpload(body: body, contentType: contentType) + result = .success(media) + } + } catch { + result = .failure(error) + } + + // Clean up temp files (success or failure). + try? FileManager.default.removeItem(at: fileURL) + if let processedURL, processedURL != fileURL { + try? FileManager.default.removeItem(at: processedURL) + } + + switch result { + case .success(let media): + do { + let json = try JSONEncoder().encode(media) + return HTTPResponse( + status: 200, + headers: corsHeaders + [("Content-Type", "application/json")], + body: json + ) + } catch { + return errorResponse(status: 500, body: "Failed to encode response") + } + case .failure(let error): + Logger.uploadServer.error("Upload processing failed: \(error)") + return errorResponse(status: 500, body: error.localizedDescription) + } + } + + // MARK: - Delegate Pipeline + + /// Result of the delegate processing + upload pipeline. + private enum UploadResult { + /// The delegate (or default uploader) completed the upload. + case uploaded(MediaUploadResult, processedURL: URL) + /// The delegate didn't modify the file and `uploadFile` returned nil. + /// The caller should forward the original request body to WordPress. + case passthrough + } + + private static func processAndUpload( + fileURL: URL, mimeType: String, filename: String, context: UploadContext + ) async throws -> UploadResult { + // Step 1: Process (resize, transcode, etc.) + let processedURL: URL + if let delegate = context.uploadDelegate { + processedURL = try await delegate.processFile(at: fileURL, mimeType: mimeType) + } else { + processedURL = fileURL + } + + // Step 2: Upload to remote WordPress + if let delegate = context.uploadDelegate, + let result = try await delegate.uploadFile(at: processedURL, mimeType: mimeType, filename: filename) { + return .uploaded(result, processedURL: processedURL) + } else if let defaultUploader = context.defaultUploader { + // If the delegate didn't modify the file, the original request + // body can be forwarded directly — skip multipart re-encoding. + if processedURL == fileURL { + return .passthrough + } + let result = try await defaultUploader.upload(fileURL: processedURL, mimeType: mimeType, filename: filename) + return .uploaded(result, processedURL: processedURL) + } else { + throw UploadError.noUploader + } + } + + // MARK: - CORS + + private static let corsHeaders: [(String, String)] = [ + ("Access-Control-Allow-Origin", "*"), + ("Access-Control-Allow-Headers", "Relay-Authorization, Content-Type"), + ] + + private static func corsPreflightResponse() -> HTTPResponse { + HTTPResponse( + status: 204, + headers: corsHeaders + [ + ("Access-Control-Allow-Methods", "POST, OPTIONS"), + ("Access-Control-Max-Age", "86400"), + ], + body: Data() + ) + } + + private static func errorResponse(status: Int, body: String) -> HTTPResponse { + HTTPResponse( + status: status, + headers: corsHeaders + [("Content-Type", "text/plain")], + body: Data(body.utf8) + ) + } + + // MARK: - Helpers + + /// Sanitizes a filename to prevent path traversal. + private static func sanitizeFilename(_ name: String) -> String { + let safe = (name as NSString).lastPathComponent + .replacingOccurrences(of: "/", with: "") + .replacingOccurrences(of: "\\", with: "") + return safe.isEmpty ? "upload" : safe + } + + /// Streams an InputStream to a file URL. + private static func writeStream(_ inputStream: InputStream, to url: URL) throws { + inputStream.open() + defer { inputStream.close() } + + let outputStream = OutputStream(url: url, append: false)! + outputStream.open() + defer { outputStream.close() } + + let bufferSize = 65_536 + let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + defer { buffer.deallocate() } + + // Use read() return value as the sole termination signal. Do NOT check + // hasBytesAvailable — for piped streams (used by file-slice RequestBody), + // it can return false before the writer thread has pumped the next chunk, + // causing an early exit and a truncated file. + while true { + let bytesRead = inputStream.read(buffer, maxLength: bufferSize) + if bytesRead < 0 { + throw inputStream.streamError ?? UploadError.streamReadFailed + } + if bytesRead == 0 { break } + + var totalWritten = 0 + while totalWritten < bytesRead { + let written = outputStream.write(buffer.advanced(by: totalWritten), maxLength: bytesRead - totalWritten) + if written < 0 { + throw outputStream.streamError ?? UploadError.streamWriteFailed + } + totalWritten += written + } + } + } + + // MARK: - Errors + + enum UploadError: Error, LocalizedError { + case noUploader + case streamReadFailed + case streamWriteFailed + + var errorDescription: String? { + switch self { + case .noUploader: "No upload delegate or default uploader configured" + case .streamReadFailed: "Failed to read upload stream" + case .streamWriteFailed: "Failed to write upload to disk" + } + } + } +} + +// MARK: - Upload Context + +/// Thread-safe container for the upload delegate and default uploader, +/// captured by the HTTPServer handler closure. +private struct UploadContext: Sendable { + let uploadDelegate: (any MediaUploadDelegate)? + let defaultUploader: DefaultMediaUploader? +} + +// MARK: - Default Media Uploader + +/// Uploads files to the WordPress REST API using site credentials from EditorConfiguration. +class DefaultMediaUploader: @unchecked Sendable { + private let httpClient: EditorHTTPClientProtocol + private let siteApiRoot: URL + private let siteApiNamespace: String? + + init(httpClient: EditorHTTPClientProtocol, siteApiRoot: URL, siteApiNamespace: [String] = []) { + self.httpClient = httpClient + self.siteApiRoot = siteApiRoot + self.siteApiNamespace = siteApiNamespace.first + } + + /// The WordPress media endpoint URL, accounting for site API namespaces. + private var mediaEndpointURL: URL { + let mediaPath = if let siteApiNamespace { + "wp/v2/\(siteApiNamespace)media" + } else { + "wp/v2/media" + } + return siteApiRoot.appending(path: mediaPath) + } + + func upload(fileURL: URL, mimeType: String, filename: String) async throws -> MediaUploadResult { + let boundary = UUID().uuidString + + let (bodyStream, contentLength) = try Self.multipartBodyStream( + fileURL: fileURL, boundary: boundary, filename: filename, mimeType: mimeType + ) + + var request = URLRequest(url: mediaEndpointURL) + request.httpMethod = "POST" + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + request.setValue("\(contentLength)", forHTTPHeaderField: "Content-Length") + request.httpBodyStream = bodyStream + + return try await performUpload(request) + } + + /// Forwards the original request body to WordPress without re-encoding. + /// + /// Used when the delegate's `processFile` returned the file unchanged — + /// the incoming multipart body is already valid for WordPress. + func passthroughUpload(body: RequestBody, contentType: String) async throws -> MediaUploadResult { + var request = URLRequest(url: mediaEndpointURL) + request.httpMethod = "POST" + request.setValue(contentType, forHTTPHeaderField: "Content-Type") + request.setValue("\(body.count)", forHTTPHeaderField: "Content-Length") + request.httpBodyStream = try body.makeInputStream() + + return try await performUpload(request) + } + + private func performUpload(_ request: URLRequest) async throws -> MediaUploadResult { + let (data, response) = try await httpClient.perform(request) + + guard (200...299).contains(response.statusCode) else { + let preview = String(data: data.prefix(500), encoding: .utf8) ?? "" + throw MediaUploadError.uploadFailed(statusCode: response.statusCode, preview: preview) + } + + // Parse the WordPress media response into our result type + let wpMedia: WPMediaResponse + do { + wpMedia = try JSONDecoder().decode(WPMediaResponse.self, from: data) + } catch { + let preview = String(data: data.prefix(500), encoding: .utf8) ?? "" + throw MediaUploadError.unexpectedResponse(preview: preview, underlyingError: error) + } + + return MediaUploadResult( + id: wpMedia.id, + url: wpMedia.source_url, + alt: wpMedia.alt_text ?? "", + caption: wpMedia.caption?.rendered ?? "", + title: wpMedia.title.rendered, + mime: wpMedia.mime_type, + type: wpMedia.media_type, + width: wpMedia.media_details?.width, + height: wpMedia.media_details?.height + ) + } + + // MARK: - Streaming Multipart Body + + /// Builds a multipart/form-data body as an `InputStream` that streams the + /// file from disk without loading it into memory. + /// + /// Uses a bound stream pair with a background writer thread — the same + /// pattern as `RequestBody.makePipedFileSliceStream`. + /// + /// - Returns: A tuple of the input stream and the total content length. + static func multipartBodyStream( + fileURL: URL, + boundary: String, + filename: String, + mimeType: String + ) throws -> (InputStream, Int) { + let preamble = Data( + ("--\(boundary)\r\n" + + "Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n" + + "Content-Type: \(mimeType)\r\n\r\n").utf8 + ) + let epilogue = Data("\r\n--\(boundary)--\r\n".utf8) + + guard let fileSize = try FileManager.default.attributesOfItem(atPath: fileURL.path(percentEncoded: false))[.size] as? Int else { + throw MediaUploadError.streamReadFailed + } + let contentLength = preamble.count + fileSize + epilogue.count + + let fileHandle = try FileHandle(forReadingFrom: fileURL) + + var readStream: InputStream? + var writeStream: OutputStream? + Stream.getBoundStreams(withBufferSize: 65_536, inputStream: &readStream, outputStream: &writeStream) + + guard let inputStream = readStream, let outputStream = writeStream else { + try? fileHandle.close() + throw MediaUploadError.streamReadFailed + } + + outputStream.open() + + // OutputStream is not Sendable but is safely transferred to the + // writer thread — only the thread accesses it after this point. + nonisolated(unsafe) let output = outputStream + + Thread.detachNewThread { + defer { + output.close() + try? fileHandle.close() + } + + // Write preamble (multipart headers). + guard Self.writeAll(preamble, to: output) else { return } + + // Stream file content in chunks. + var remaining = fileSize + while remaining > 0 { + let chunkSize = min(65_536, remaining) + guard let chunk = try? fileHandle.read(upToCount: chunkSize), + !chunk.isEmpty else { + break + } + guard Self.writeAll(chunk, to: output) else { return } + remaining -= chunk.count + } + + // Write epilogue (closing boundary). + _ = Self.writeAll(epilogue, to: output) + } + + return (inputStream, contentLength) + } + + /// Writes all bytes of `data` to the output stream, handling partial writes. + private static func writeAll(_ data: Data, to output: OutputStream) -> Bool { + data.withUnsafeBytes { buffer in + guard let base = buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else { return false } + var written = 0 + while written < data.count { + let result = output.write(base.advanced(by: written), maxLength: data.count - written) + if result <= 0 { return false } + written += result + } + return true + } + } +} + +/// WordPress REST API media response (subset of fields). +private struct WPMediaResponse: Decodable { + let id: Int + let source_url: String + let alt_text: String? + let caption: RenderedField? + let title: RenderedField + let mime_type: String + let media_type: String + let media_details: MediaDetails? + + struct RenderedField: Decodable { + let rendered: String + } + + struct MediaDetails: Decodable { + let width: Int? + let height: Int? + } +} + +/// Errors specific to the native media upload pipeline. +enum MediaUploadError: Error, LocalizedError { + /// The WordPress REST API returned a non-success HTTP status code. + case uploadFailed(statusCode: Int, preview: String) + + /// The WordPress REST API returned a non-JSON response (e.g. HTML error page). + case unexpectedResponse(preview: String, underlyingError: Error) + + /// Failed to read the file for streaming upload. + case streamReadFailed + + var errorDescription: String? { + switch self { + case .uploadFailed(let statusCode, let preview): + return "Upload failed (\(statusCode)): \(preview)" + case .unexpectedResponse(let preview, _): + return "WordPress returned an unexpected response: \(preview)" + case .streamReadFailed: + return "Failed to read file for upload" + } + } +} + +// MARK: - Helpers + +private extension Data { + mutating func append(_ string: String) { + append(Data(string.utf8)) + } +} 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/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/Sources/GutenbergKitHTTP/HTTPRequestParser.swift b/ios/Sources/GutenbergKitHTTP/HTTPRequestParser.swift index 293c92773..186946493 100644 --- a/ios/Sources/GutenbergKitHTTP/HTTPRequestParser.swift +++ b/ios/Sources/GutenbergKitHTTP/HTTPRequestParser.swift @@ -27,6 +27,10 @@ public final class HTTPRequestParser: @unchecked Sendable { case needsMoreData /// Headers have been fully received but the body is still incomplete. case headersComplete + /// The request body exceeds the maximum allowed size and is being + /// drained (read and discarded) so the server can send a clean 413 + /// response. No body bytes are buffered in this state. + case draining /// All data has been received (headers and body). case complete } @@ -46,7 +50,7 @@ public final class HTTPRequestParser: @unchecked Sendable { private var buffer: Buffer private let maxBodySize: Int64 private let inMemoryBodyThreshold: Int - private var bytesWritten: Int = 0 + private var bytesWritten: Int64 = 0 private var _state: State = .needsMoreData // Lightweight scan results (populated by append) @@ -103,6 +107,15 @@ public final class HTTPRequestParser: @unchecked Sendable { lock.withLock { _state } } + /// The parse error detected during buffering, if any. + /// + /// Non-fatal errors like ``HTTPRequestParseError/payloadTooLarge`` are + /// exposed here instead of being thrown by ``parseRequest()``, allowing + /// the caller to still access the parsed headers. + public var parseError: HTTPRequestParseError? { + lock.withLock { _parseError } + } + /// The expected body length from `Content-Length`, available once headers have been received. public var expectedBodyLength: Int64? { lock.withLock { @@ -124,12 +137,16 @@ public final class HTTPRequestParser: @unchecked Sendable { try lock.withLock { guard _state.hasHeaders else { return nil } - if let error = _parseError { + // Payload-too-large means "valid headers, rejected body" — let + // the caller access the parsed headers so the handler can build + // a response (e.g., with CORS headers). Other parse errors + // indicate genuinely malformed requests and are still thrown. + if let error = _parseError, error != .payloadTooLarge { throw error } if _parsedHeaders == nil { - let headerData = try buffer.read(from: 0, maxLength: min(bytesWritten, Self.maxHeaderSize)) + let headerData = try buffer.read(from: 0, maxLength: Int(min(bytesWritten, Int64(Self.maxHeaderSize)))) switch HTTPRequestSerializer.parseHeaders(from: headerData) { case .parsed(let headers): _parsedHeaders = headers @@ -143,7 +160,11 @@ public final class HTTPRequestParser: @unchecked Sendable { guard let headers = _parsedHeaders else { return nil } - guard _state.isComplete else { + // Return partial (headers only) when the body was rejected or + // hasn't fully arrived yet. The payloadTooLarge case goes through + // drain mode which discards body bytes without buffering them, so + // there is no body to extract even though the state is .complete. + guard _state.isComplete, _parseError == nil else { return .partial( method: headers.method, target: headers.target, @@ -179,6 +200,17 @@ public final class HTTPRequestParser: @unchecked Sendable { lock.withLock { guard !_state.isComplete else { return } + // In drain mode, discard bytes without buffering and check + // whether the full Content-Length has been consumed. + if case .draining = _state { + bytesWritten += Int64(data.count) + if let offset = headerEndOffset, + bytesWritten - Int64(offset) >= expectedContentLength { + _state = .complete + } + return + } + let accepted: Bool do { accepted = try buffer.append(data) @@ -192,12 +224,12 @@ public final class HTTPRequestParser: @unchecked Sendable { _state = .complete return } - bytesWritten += data.count + bytesWritten += Int64(data.count) if headerEndOffset == nil { let buffered: Data do { - buffered = try buffer.read(from: 0, maxLength: min(bytesWritten, Self.maxHeaderSize)) + buffered = try buffer.read(from: 0, maxLength: Int(min(bytesWritten, Int64(Self.maxHeaderSize)))) } catch { _parseError = .bufferIOError _state = .complete @@ -215,7 +247,7 @@ public final class HTTPRequestParser: @unchecked Sendable { let effectiveData = buffered[scanStart...] guard let separatorRange = effectiveData.range(of: separator) else { - if bytesWritten > Self.maxHeaderSize { + if bytesWritten > Int64(Self.maxHeaderSize) { _parseError = .headersTooLarge _state = .complete } else { @@ -236,15 +268,15 @@ public final class HTTPRequestParser: @unchecked Sendable { if expectedContentLength > maxBodySize { _parseError = .payloadTooLarge - _state = .complete + _state = .draining return } } guard let offset = headerEndOffset else { return } - let bodyBytesAvailable = bytesWritten - offset + let bodyBytesAvailable = bytesWritten - Int64(offset) - if Int64(bodyBytesAvailable) >= expectedContentLength { + if bodyBytesAvailable >= expectedContentLength { _state = .complete } else { _state = .headersComplete @@ -403,14 +435,16 @@ extension HTTPRequestParser.State { /// Whether all data has been received (headers and body). public var isComplete: Bool { - if case .complete = self { return true } - return false + switch self { + case .complete: return true + case .needsMoreData, .headersComplete, .draining: return false + } } - /// Whether headers have been fully received (true for both `.headersComplete` and `.complete`). + /// Whether headers have been fully received (true for `.headersComplete`, `.draining`, and `.complete`). public var hasHeaders: Bool { switch self { - case .headersComplete, .complete: return true + case .headersComplete, .draining, .complete: return true case .needsMoreData: return false } } 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 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